Skip to content

fix(gateway): enforce IAM provider fallback rules - #1819

Merged
steebchen merged 6 commits into
mainfrom
ariana/curie-fd72
Mar 12, 2026
Merged

steebchen merged 6 commits into
mainfrom
ariana/curie-fd72

Conversation

@steebchen

@steebchen steebchen commented Mar 12, 2026

Copy link
Copy Markdown
Member

Summary

  • enforce provider allow/deny IAM filtering even when a specific provider was requested
  • route low-uptime fallback and provider selection through the IAM-filtered provider list
  • add regression coverage for requested-provider IAM filtering

Testing

  • pnpm vitest run apps/gateway/src/lib/iam.spec.ts
  • pnpm vitest run apps/gateway/src/chat/tools/retry-with-fallback.spec.ts

Summary by CodeRabbit

  • Improvements
    • IAM enforcement strengthened: provider availability, routing and low-uptime fallbacks now consistently honor IAM rules and provider capabilities (web search, JSON output/schema, vision, reasoning), preferring IAM-allowed alternatives when applicable.
  • Tests
    • Expanded coverage for allow/deny provider rules, multi-provider fallback behavior, routing/retry flows (streaming and non-streaming); added shared helpers to validate IAM-driven routing decisions and logs.

Copilot AI review requested due to automatic review settings March 12, 2026 07:10
@coderabbitai

coderabbitai Bot commented Mar 12, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Replaces usage of modelInfo.providers with iamFilteredModelProviders during routing, retries, and low-uptime fallback; integrates IAM-aware capability checks (web_search, JSON output/schema, vision, reasoning) into provider eligibility and updates IAM rule evaluation and tests.

Changes

Cohort / File(s) Summary
Chat routing & fallback
apps/gateway/src/chat/chat.ts
Switched selection to use iamFilteredModelProviders for available providers across initial routing, retry, and low-uptime fallback; added capability checks for web_search, JSON output/schema, vision (images), and reasoning (reasoning_effort) when filtering providers; removed per-provider inline IAM checks in favor of IAM-filtered set.
IAM evaluation logic
apps/gateway/src/lib/iam.ts
Refactored evaluateRule to build newAllowedProviders by intersecting/excluding ruleValue.providers; enforces provider-specific validation and returns filtered provider lists or deny when empty.
IAM tests
apps/gateway/src/lib/iam.spec.ts
Updated assertions to expect explicit allowedProviders arrays in deny_providers and allow_providers tests.
Fallback & routing tests
apps/gateway/src/fallback.spec.ts
Added top-level helpers for multi-provider keys and IAM rule insertion; added tests verifying low-uptime fallback and retry behavior respect IAM allow/deny rules for streaming and non-streaming flows; updated imports to include and, eq; adjusted some expected upstream error codes.
Manifest
package.json
Small change (one-line addition).

Sequence Diagram(s)

mermaid
sequenceDiagram
rect rgba(40,116,240,0.5)
participant Client
end
rect rgba(16,185,129,0.5)
participant Gateway
end
rect rgba(249,115,22,0.5)
participant IAM
end
rect rgba(234,67,53,0.5)
participant Providers
end

Client->>Gateway: request(model, options)
Gateway->>IAM: evaluate allowed providers for request
IAM-->>Gateway: iamFilteredModelProviders
Gateway->>Gateway: apply capability checks (web_search, json, vision, reasoning)
Gateway->>Providers: route to eligible provider(s)
Providers-->>Gateway: response / failure
alt failure and fallback
Gateway->>Gateway: use iamFilteredModelProviders for fallback selection
Gateway->>Providers: retry with alternate provider
end
Gateway-->>Client: final response

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

auto-merge

Suggested reviewers

  • smakosh
  • rcogal
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main change: enforcing IAM provider fallback rules. This is the primary objective evident across the modified files (chat.ts, iam.ts, iam.spec.ts, fallback.spec.ts).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ariana/curie-fd72

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR tightens IAM enforcement in the Gateway so provider allow/deny rules are applied consistently even when a specific provider is requested, and ensures provider fallback/selection logic only considers IAM-permitted providers.

Changes:

  • Update IAM rule evaluation so allow_providers / deny_providers always produce an IAM-filtered provider set (including when requestedProvider is specified).
  • Route provider selection and low-uptime fallback filtering through the IAM-filtered provider mappings in chat.ts.
  • Add regression assertions ensuring allowedProviders reflects IAM filtering for requested-provider scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
apps/gateway/src/lib/iam.ts Ensures provider allow/deny rules update the allowed-provider set even when a provider was explicitly requested.
apps/gateway/src/lib/iam.spec.ts Adds regression expectations that allowedProviders is correctly filtered for requested-provider cases.
apps/gateway/src/chat/chat.ts Uses IAM-filtered provider mappings for available-provider filtering, selection, and fallback routing paths.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/gateway/src/lib/iam.ts (1)

135-150: ⚠️ Potential issue | 🟠 Major

Keep price-limit checks scoped to the IAM-filtered provider set.

These new intersections make currentAllowedProviders the source of truth, but allow_pricing still walks every modelDef.providers entry. That means a provider already removed by deny_providers/allow_providers can still block access later. Example: if openai is denied first, anthropic remains allowed, and a later maxInputPrice only excludes openai, the model is still rejected even though the surviving provider is within policy.

Possible fix
 		case "allow_pricing":
 			if (
 				ruleValue.maxInputPrice !== undefined ||
 				ruleValue.maxOutputPrice !== undefined
 			) {
 				for (const provider of modelDef.providers) {
+					if (!currentAllowedProviders.has(provider.providerId)) {
+						continue;
+					}
+
 					if (requestedProvider && provider.providerId !== requestedProvider) {
 						continue;
 					}

Also applies to: 165-180

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/lib/iam.ts` around lines 135 - 150, The pricing checks in
allow_pricing (and the similar block later) still iterate modelDef.providers
instead of the IAM-filtered set, allowing providers previously removed by
deny_providers/allow_providers to veto access; update allow_pricing to iterate
only over the IAM-filtered provider set (use
currentAllowedProviders/newAllowedProviders) when computing price limits and
deciding allowedProviders, and when requestedProvider is present ensure you
validate it against and return it from that same filtered set (use the same
newAllowedProviders/currentAllowedProviders intersection rather than
modelDef.providers).
apps/gateway/src/chat/chat.ts (1)

1010-1050: ⚠️ Potential issue | 🟠 Major

Apply the full request-capability filter during low-uptime fallback.

This path now honors IAM, but it still only screens on web search / JSON / vision. A request with reasoning_effort can be rerouted here to a provider that the normal !usedProvider path would reject, and that later path never runs once usedProvider has been reassigned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/chat.ts` around lines 1010 - 1050, The low-uptime
fallback filter (building availableModelProviders from
iamFilteredModelProviders) currently only checks webSearchTool, response_format,
and hasImages but misses other request capabilities like reasoning_effort,
causing misrouted providers when usedProvider changes; update the filter to
apply the full request-capability checks used elsewhere (e.g., include a check
for reasoning_effort and any other capability flags tested in the normal
provider-selection path) or refactor to reuse the same capability-checking
helper (create/use a function like
filterProvidersByRequestCapabilities(provider, request) and call it inside the
availableModelProviders filter) so availableModelProviders and the regular
selection path enforce identical capability constraints.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 1217-1237: The current hasNonReasoningAlternative check can
wrongly prefer a non-reasoning sibling that is actually unusable for this
request; change the predicate so the "non-reasoning alternative" must also
satisfy the same eligibility checks you apply to mappings elsewhere (i.e., the
same capability/requirement filters such as vision, json_schema, etc.) before
deciding to exclude the reasoning mapping. Concretely, when computing
hasNonReasoningAlternative from modelInfo.providers, only count mappings with
matching providerId and reasoning !== true that also pass the same
usable/matching logic you apply to provider mappings in this function (or call a
shared helper like isMappingEligible(mapping, currentRequestRequirements) if one
exists) so you only prefer a non-reasoning sibling if it is actually usable for
this request.

---

Outside diff comments:
In `@apps/gateway/src/chat/chat.ts`:
- Around line 1010-1050: The low-uptime fallback filter (building
availableModelProviders from iamFilteredModelProviders) currently only checks
webSearchTool, response_format, and hasImages but misses other request
capabilities like reasoning_effort, causing misrouted providers when
usedProvider changes; update the filter to apply the full request-capability
checks used elsewhere (e.g., include a check for reasoning_effort and any other
capability flags tested in the normal provider-selection path) or refactor to
reuse the same capability-checking helper (create/use a function like
filterProvidersByRequestCapabilities(provider, request) and call it inside the
availableModelProviders filter) so availableModelProviders and the regular
selection path enforce identical capability constraints.

In `@apps/gateway/src/lib/iam.ts`:
- Around line 135-150: The pricing checks in allow_pricing (and the similar
block later) still iterate modelDef.providers instead of the IAM-filtered set,
allowing providers previously removed by deny_providers/allow_providers to veto
access; update allow_pricing to iterate only over the IAM-filtered provider set
(use currentAllowedProviders/newAllowedProviders) when computing price limits
and deciding allowedProviders, and when requestedProvider is present ensure you
validate it against and return it from that same filtered set (use the same
newAllowedProviders/currentAllowedProviders intersection rather than
modelDef.providers).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: ce2c311a-a6ac-40df-bf8a-b1b14b8ade84

📥 Commits

Reviewing files that changed from the base of the PR and between 42249b7 and f429677.

📒 Files selected for processing (3)
  • apps/gateway/src/chat/chat.ts
  • apps/gateway/src/lib/iam.spec.ts
  • apps/gateway/src/lib/iam.ts

Comment on lines +1217 to +1237
// If reasoning_effort is specified, only include providers with reasoning support
if (reasoning_effort !== undefined) {
if ((provider as ProviderModelMapping).reasoning !== true) {
return false;
}
}
// If reasoning_effort is NOT specified, prefer non-reasoning providers
// by excluding reasoning providers when a non-reasoning alternative exists for same provider
if (reasoning_effort === undefined) {
const hasNonReasoningAlternative = modelInfo.providers.some(
(p) =>
p.providerId === provider.providerId &&
(p as ProviderModelMapping).reasoning !== true,
);
if (
hasNonReasoningAlternative &&
(provider as ProviderModelMapping).reasoning === true
) {
return false;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only prefer a non-reasoning sibling if it is actually usable.

hasNonReasoningAlternative scans every modelInfo.providers mapping for the same provider ID. Because capability flags are per mapping, this can discard the reasoning mapping even when the non-reasoning sibling fails the current request's other requirements (for example vision or JSON-schema support), leaving no selectable mapping for a provider that actually could serve the request.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/chat/chat.ts` around lines 1217 - 1237, The current
hasNonReasoningAlternative check can wrongly prefer a non-reasoning sibling that
is actually unusable for this request; change the predicate so the
"non-reasoning alternative" must also satisfy the same eligibility checks you
apply to mappings elsewhere (i.e., the same capability/requirement filters such
as vision, json_schema, etc.) before deciding to exclude the reasoning mapping.
Concretely, when computing hasNonReasoningAlternative from modelInfo.providers,
only count mappings with matching providerId and reasoning !== true that also
pass the same usable/matching logic you apply to provider mappings in this
function (or call a shared helper like isMappingEligible(mapping,
currentRequestRequirements) if one exists) so you only prefer a non-reasoning
sibling if it is actually usable for this request.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/gateway/src/fallback.spec.ts (1)

32-54: ⚠️ Potential issue | 🟠 Major

Reset modelProviderMapping between tests.

setRoutingMetrics mutates seeded routing data, but this cleanup only clears request-scoped tables. That makes later suites inherit the last low-uptime values and turns this file into order-dependent test state. Please reset or restore the touched modelProviderMapping rows here as well.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/fallback.spec.ts` around lines 32 - 54, The test suite
mutates routing state via setRoutingMetrics which alters the
modelProviderMapping rows, so update the beforeEach to reset
modelProviderMapping to its seeded/default state: after clearing tables call the
same seeding logic or explicitly restore modelProviderMapping rows (e.g., using
db.insert/update against modelProviderMapping) so tests do not inherit
low-uptime values; reference the beforeEach hook, setRoutingMetrics, and
modelProviderMapping when locating where to add the reset.
🧹 Nitpick comments (1)
apps/gateway/src/fallback.spec.ts (1)

611-727: Add one positive low-uptime reroute case.

These new cases only assert “no fallback”. The suite would still go green if low-uptime rerouting were broken entirely. A control case where together.ai is unhealthy and cerebras is IAM-allowed would prove the reroute path still works.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/fallback.spec.ts` around lines 611 - 727, Add a positive
control test that verifies low-uptime rerouting works: create a new test (e.g.,
"reroutes to IAM-allowed healthy provider when requested provider is 0% uptime")
inside the same describe block, use setupMultiProviderKeys() and
setRoutingMetrics(modelId, "together.ai", 0) / setRoutingMetrics(modelId,
"cerebras", 100), insertIamRules to allow only "cerebras" (via insertIamRules
with ruleType "allow_providers" and providers ["cerebras"]), send the same
app.request payload with model "together.ai/llama-3.1-8b-instruct", then assert
the response metadata.used_provider is "cerebras", metadata.requested_provider
is "together.ai", and that waitForLogs(1) shows logs[0].usedProvider ===
"cerebras" and logs[0].routingMetadata.selectedProvider === "cerebras" and
logs[0].routingMetadata.selectionReason === "low-uptime-fallback".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/gateway/src/fallback.spec.ts`:
- Around line 151-170: The update in setRoutingMetrics silently does nothing if
the modelId/providerId pair is missing; capture the result of db.update(...)
against tables.modelProviderMapping and assert that at least one row was
affected (e.g., throw or fail the test if the affected-row count is 0) so the
test will fail fast when the mapping is not present rather than letting
low-uptime tests pass without exercising the fallback branch.

---

Outside diff comments:
In `@apps/gateway/src/fallback.spec.ts`:
- Around line 32-54: The test suite mutates routing state via setRoutingMetrics
which alters the modelProviderMapping rows, so update the beforeEach to reset
modelProviderMapping to its seeded/default state: after clearing tables call the
same seeding logic or explicitly restore modelProviderMapping rows (e.g., using
db.insert/update against modelProviderMapping) so tests do not inherit
low-uptime values; reference the beforeEach hook, setRoutingMetrics, and
modelProviderMapping when locating where to add the reset.

---

Nitpick comments:
In `@apps/gateway/src/fallback.spec.ts`:
- Around line 611-727: Add a positive control test that verifies low-uptime
rerouting works: create a new test (e.g., "reroutes to IAM-allowed healthy
provider when requested provider is 0% uptime") inside the same describe block,
use setupMultiProviderKeys() and setRoutingMetrics(modelId, "together.ai", 0) /
setRoutingMetrics(modelId, "cerebras", 100), insertIamRules to allow only
"cerebras" (via insertIamRules with ruleType "allow_providers" and providers
["cerebras"]), send the same app.request payload with model
"together.ai/llama-3.1-8b-instruct", then assert the response
metadata.used_provider is "cerebras", metadata.requested_provider is
"together.ai", and that waitForLogs(1) shows logs[0].usedProvider === "cerebras"
and logs[0].routingMetadata.selectedProvider === "cerebras" and
logs[0].routingMetadata.selectionReason === "low-uptime-fallback".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: f16fc3f2-3037-4994-82bf-9331f9c8fe82

📥 Commits

Reviewing files that changed from the base of the PR and between 0179657 and 23b9c56.

📒 Files selected for processing (1)
  • apps/gateway/src/fallback.spec.ts

Comment on lines +151 to +170
async function setRoutingMetrics(
modelId: string,
providerId: string,
routingUptime: number,
) {
await db
.update(tables.modelProviderMapping)
.set({
routingUptime,
routingLatency: 100,
routingThroughput: 100,
routingTotalRequests: 100,
})
.where(
and(
eq(tables.modelProviderMapping.modelId, modelId),
eq(tables.modelProviderMapping.providerId, providerId),
),
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when the routing-metrics setup is a no-op.

If the modelId/providerId pair stops existing in seeded data, this update silently affects zero rows and the low-uptime tests below can still pass without ever exercising the fallback branch. Add an assertion that the target mapping was actually updated.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/gateway/src/fallback.spec.ts` around lines 151 - 170, The update in
setRoutingMetrics silently does nothing if the modelId/providerId pair is
missing; capture the result of db.update(...) against
tables.modelProviderMapping and assert that at least one row was affected (e.g.,
throw or fail the test if the affected-row count is 0) so the test will fail
fast when the mapping is not present rather than letting low-uptime tests pass
without exercising the fallback branch.

@steebchen
steebchen added this pull request to the merge queue Mar 12, 2026
Merged via the queue into main with commit dc812f1 Mar 12, 2026
12 of 13 checks passed
@steebchen
steebchen deleted the ariana/curie-fd72 branch March 12, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants