Skip to content

feat(routing): named routing policies with failover, budget tier-down, and enforced guardrails - #492

Merged
njbrake merged 9 commits into
mainfrom
alias
Aug 5, 2026
Merged

feat(routing): named routing policies with failover, budget tier-down, and enforced guardrails#492
njbrake merged 9 commits into
mainfrom
alias

Conversation

@njbrake

@njbrake njbrake commented Aug 4, 2026

Copy link
Copy Markdown
Member

Description

Closes #463. Also delivers the observability half of #401.

An alias maps one name to one model. This turns that into a routing policy: a
model name your callers send, which decides which real model serves the request,
what is tried after a retryable failure, and which guardrails always run. An alias
is the one-target case, so aliases: keeps working as its shorthand and nothing
existing changes behavior.

The gap this closes: a standalone gateway had no failover at all. The
multi-attempt walker existed but was hybrid-only, driven by the otari.ai resolve
payload, so any provider blip was a 502.

routing:
  policies:
    fast:
      select:
        - when: {budget_used_pct: {gte: 80}}
          target: openai:gpt-5-nano      # tier down as the budget fills up
        - default: openai:gpt-5-mini
      on_failure:
        - anthropic:claude-haiku-4-5     # tried if the selected model fails
      guardrails:
        - {profile: prompt-injection, mode: block, on_unavailable: block}

Policies are also fully manageable from the dashboard (new Routing page) and
over /v1/routing/policies, so nothing here requires editing a file and
restarting.

Design decisions worth reviewing

Two axes, deliberately separate. select decides where the plan starts;
on_failure is what runs after a failure. One combined list would make "did this
entry not apply, or did it fail?" ambiguous in every log line. The fallthrough is
an explicit default rather than a positional last entry, so a misordered policy
is refused at load instead of silently carrying dead rules.

instance and provider are carried separately through the walker. Pricing,
budgets, and the usage log key on the instance, while any-llm dispatches against
the implementation. Collapsing them would silently re-key usage_logs.provider
and break pricing for every named-instance deployment, and it would ship green
because the existing test does not cover the fallback path.

absorbed is a third outcome, not an error. A recovered attempt writes its own
usage row correlated by request_group_id, with status="absorbed". Every error
metric in the product counts status == "error" exactly, and request_count
excludes absorbed rows, so a working fallback chain cannot read as an outage. This
is why lib/overview.ts's 2% amber threshold and the activity timeline stay honest.

The reservation tops up before each later candidate. A chain that falls over to
a pricier model would otherwise run against the cheaper model's reservation and
take spend past a cap the gate already approved. A refused top-up stops the chain
rather than overshooting.

401/403 are terminal locally, unlike hybrid mode, which retries them because a
workspace key can be rotated upstream. A standalone operator owns both keys, so
failing over would move traffic and spend to another provider and hide the
misconfiguration.

Every schema model is extra="forbid". GatewayConfig is extra="ignore", so
a typo'd key inside a policy would otherwise vanish and the policy would quietly
not do what it says.

A budget threshold at or above 100 is refused at load. The budget gate rejects
the request before selection, so such a rule could never fire; an operator writing
one believes they have configured "keep serving on a cheaper model after the budget
runs out" when they have configured nothing.

Verified against a live gateway, not only tests

Real OpenAI traffic through a real gateway, because several of these claims are
only checkable end to end:

Check Result
Failover against a real provider 404 head, fallback served, caller saw 200
Two correlated rows absorbed attempt 1/2 + success attempt 2/2, same group id
Every candidate fails both rows written plus a clean 502 (this used to write nothing)
Absorbed excluded from counts 12 rows, 2 absorbed, request_count: 10, error_count: 5
Conditional selection per-user targets with reason=condition:user_id
Catalog policies listed, dynamic ones as pricing_source: "dynamic" with no price
Guardrail, no service configured served, check recorded as skipped

That exercise caught three real bugs that the test suite had missed, including
stored policies being absent from /v1/models (the existing test covered only
config policies) and an exhausted chain writing no usage row at all.

Not included

  • The kNN router (Smart routing: pluggable LLM router with kNN routing memory + preference collection #187). select: [{router: knn}] validates, and currently warns
    and falls through to the default rather than pretending.
  • Policy-shaped allow-list grants, so "this key may only use policy fast" is not
    yet expressible.
  • Output-direction guardrails, which the policy schema deliberately refuses rather
    than accepting as a silent no-op.
  • Hybrid mode refuses a policy name with a 400: the platform resolves the model
    there, so a local policy name would reach it as an unknown model.

PR Type

  • New Feature
  • Bug Fix
  • Refactor
  • Documentation
  • Infrastructure / CI

Relevant issues

Closes #463. Delivers the observability half of #401.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change (tests/unit, tests/integration).
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec (uv run python scripts/generate_openapi.py).

Checks

make lint, make typecheck, make openapi-check, make postman-check, 1222
unit, 964 integration, 442 dashboard tests, and the committed bundle rebuilt.

Migrations: e8b1d3f5a7c9 (routing_policies) and f4c6a8b0d2e5 (usage
attribution), both verified up and down against PostgreSQL.

Review round

An independent review found six real defects, all now fixed in 84d62d3c with
the tests that would have caught them. The most serious: policy guardrails were
never enforced, so the third item in this PR's title was a no-op with a full
supporting cast of schema, API, CLI output, dashboard editor, and documentation
asserting otherwise. Also fixed: failover reached only /v1/chat/completions;
streamed requests recorded no attribution; a terminal failure was attributed to a
candidate that was never called; require_pricing was bypassable through a
fallback; and limits was a knob that could not take effect, so it was removed
rather than shipped.

AI Usage

  • No AI was used.
  • AI was used for drafting/refactoring.
  • This is fully AI-generated.

AI Model/Tool used:

Claude Opus 5 via Claude Code.

Any additional AI details you'd like to share:

Written by Claude through back-and-forth with @njbrake, who made the design
calls: value-first sequencing, the routing policies name, no RoutingPort for
now, one usage row per attempt with a distinct absorbed outcome, refusing policy
names in hybrid mode, and the on_failure / explicit default key naming.

The implementation was reviewed by a separate agent with no knowledge of how it
was built, which is what caught the six defects above; the verification was also
run against a live gateway with real provider traffic, not only against tests.

  • I am an AI Agent filling out this form (check box if true)

Summary

  • Added named routing policies with conditional model selection, budget tier-down, retry failover, guardrails, and usage attribution.
  • Preserved aliases as a single-target routing shorthand and moved stored aliases into routing policies.
  • Added policy management through configuration, CLI, API, dashboard, and documentation.
  • Added policy caching, migrations, validation, model catalog support, and comprehensive tests.
  • Updated activity and usage views to show recovered attempts as absorbed instead of successful requests.
  • Replaced the dashboard Aliases page with the Routing page.

Technical notes

  • Routing policies are available in standalone mode only. Hybrid mode rejects policy names.
  • Guardrails fail closed by default when unavailable. on_unavailable: monitor enables monitoring behavior.
  • The implementation includes an attempt walker for ordered provider attempts and accurate attribution.

njbrake and others added 2 commits August 4, 2026 23:52
…, and enforced guardrails

Turns an alias into a policy: a model name callers send, which decides which real
model serves the request, what is tried after a retryable failure, and which
guardrails always run. An alias is the one-target case, so `aliases:` keeps
working as its shorthand.

Closes the gap where a standalone gateway had no failover at all: any provider
blip was a 502.

Two axes, kept separate on purpose. `select` decides where the plan starts
(a static target, a condition, or a router); `on_failure` is what is tried after
a failure. Collapsing them would make "did this entry not apply, or did it fail?"
ambiguous in every log line. The fallthrough is an explicit `default` rather than
a positional last entry, so a misordered policy is refused instead of silently
having dead rules.

What is here:

* A credential-agnostic attempt walker (`api/routes/_attempts.py`) plus an
  `Attempt` type in `gateway/types/`. It carries `instance` and `provider`
  separately, because pricing, budgets, and the usage log key on the instance
  while any-llm is dispatched against the implementation; collapsing them would
  silently re-key `usage_logs.provider` and break pricing for named instances.
  `kwargs` is an opaque dict rather than a required `api_key`, since a locally
  resolved attempt may have no API key at all (Vertex AI).
* Config and stored policies, with the same scoping and precedence as aliases
  (`user-scoped > config.yml > global stored`), a `routing_policies` table, and
  `/v1/routing/policies` CRUD plus an `explain` dry run. Every schema model is
  `extra="forbid"`: GatewayConfig is `extra="ignore"`, so a typo'd key inside a
  policy would otherwise vanish and the policy would quietly not do what it says.
* Budget-conditional selection, evaluated before the reservation. A threshold at
  or above 100 is refused at load: the budget gate rejects the request before
  selection, so such a rule could never fire.
* Policy guardrails, with `mode` required (the request-level field defaults to
  monitor, so an omitted mode would look like a mandate and behave as shadow
  mode) and a new `on_unavailable` escape hatch for the fail-closed behavior.
* Attribution on usage rows (`policy_name`, `selection_reason`,
  `attempt_position`, `attempt_count`, `request_group_id`). A recovered attempt
  is recorded as `status="absorbed"`, never `error`, and is excluded from
  `request_count` as well: every error metric counts `status == "error"` exactly,
  so a working fallback chain must not read as an outage.
* A reservation top-up before each later candidate, so a chain that falls over to
  a pricier model cannot take spend past a cap the gate already approved. A
  refused top-up stops the chain rather than overshooting.
* 401/403 are terminal for locally credentialed attempts, unlike hybrid mode: the
  operator owns these keys, so failing over would move traffic and spend to
  another provider and hide the misconfiguration.
* A Routing page in the dashboard, `otari routing explain`, and docs/routing.md.

Verified against a live gateway and real OpenAI traffic, not only tests: a 404
head fails over to a working model and writes two correlated rows (absorbed +
success), an exhausted chain writes both rows and a 502, conditional selection
picks per user, and the counts stay honest (12 rows, 2 absorbed, 10 requests,
5 errors).

Deliberately not included: the kNN router (#187) is accepted by the schema and
currently warns and falls through to the default; policy-shaped allow-list grants;
output-direction guardrails. Hybrid mode refuses a policy name with a 400, since
the platform resolves the model there.

Refs #463
Refs #401

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Conflicts were confined to the committed dashboard bundle, which is build output:
resolved by deleting it and rebuilding from the merged sources, so it matches
web/src rather than either side's stale copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 4, 2026 23:58 — with GitHub Actions Inactive
@github-actions github-actions Bot added the missing-template PR is missing required template sections label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

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

Changes

Routing policy feature

Layer / File(s) Summary
Policy contracts and persistence
src/gateway/models/routing.py, src/gateway/models/entities.py, src/gateway/services/policy_store.py, alembic/versions/*
Adds validated routing-policy specifications, scoped persistence, migrations, budget state, guardrail availability handling, configuration validation, and cache refresh lifecycle.
Policy compilation and management API
src/gateway/services/routing/*, src/gateway/api/routes/routing.py, src/gateway/api/routes/models.py, src/gateway/cli.py
Compiles ordered candidates, applies conditions and allow-lists, exposes CRUD and explain endpoints, and adds CLI policy inspection.
Candidate dispatch and failover
src/gateway/api/routes/_attempts.py, src/gateway/api/routes/_pipeline.py, src/gateway/api/routes/{chat,messages,responses}.py
Adds retryable attempt walking, candidate-specific provider arguments, streaming and non-streaming failover, guardrail merging, reservation updates, and policy attribution.
Usage attribution and analytics
src/gateway/api/routes/usage.py, src/gateway/models/entities.py, web/src/pages/{ActivityPage,OverviewPage}.tsx
Adds routing metadata and absorbed attempts to usage records, excludes recovered attempts from logical request analytics, and displays routing details in the dashboard.
Dashboard and migration support
web/src/pages/RoutingPage.tsx, web/src/api/*, web/src/App.tsx, alembic/versions/b5d7f9a1c3e6_move_stored_aliases_into_routing_policies.py
Replaces the aliases page with routing management while preserving alias operations and migrates stored aliases into compatible routing policies.
Validation, documentation, examples, and tests
tests/integration/*, tests/unit/*, docs/*, scripts/*, web/e2e/*
Adds routing documentation, Postman requests, a demo seeding utility, architecture and settlement checks, routing integration coverage, and dashboard tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • mozilla-ai/otari#130: Both changes extend _pipeline.py and completion adapters around provider execution and routing behavior.
  • mozilla-ai/otari#229: This change extends model-alias resolution and migrates stored aliases into routing policies.
  • mozilla-ai/otari#345: Both changes modify usage tracking and dashboard activity data.

Suggested reviewers: tbille

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title clearly describes the routing-policy feature but exceeds the approximately 70-character limit and does not use imperative mood. Shorten the title to about 70 characters and use imperative wording while retaining the feat prefix.
Linked Issues check ⚠️ Warning The PR implements budget-based and failure-based fallbacks for #463, but dynamic router selection is only accepted with a warning and is not implemented. Implement dynamic router selection for #463 or split that requirement into a separate issue and clarify the deferred scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 2.98% 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 The description includes the required sections, issue references, implementation details, checklist, verification results, and AI usage disclosure.
Out of Scope Changes check ✅ Passed The reviewed changes support routing policies, observability, guardrails, migrations, documentation, dashboard management, and related tests without clear unrelated code.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alias
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch alias

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.

@coderabbitai
coderabbitai Bot requested a review from khaledosman August 4, 2026 23:59

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 12

🧹 Nitpick comments (14)
src/gateway/api/routes/routing.py (2)

238-248: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add a pagination bound to list_policies.

select(RoutingPolicy) fetches every row with no limit. In practice this table stays small because only an operator writes to it, so this is unlikely to bite soon. The house rule is unconditional for list endpoints, though, and a deployment that scopes a policy per user can grow this table faster than expected.

Adding limit/offset query parameters with a default and a maximum, matching the other management list routes, keeps this endpoint predictable.

As per coding guidelines: "Every list endpoint must have a sane default and maximum pagination bound; never select an unbounded growing table such as UsageLog or ModelPricing."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/routing.py` around lines 238 - 248, Update the
list_policies endpoint to accept limit and offset query parameters using the
same sane defaults and maximum bounds as other management list routes. Apply
both bounds to the RoutingPolicy query before execution while preserving its
existing name ordering and response behavior.

Source: Coding guidelines


371-376: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

explain_policy never uses db.

The handler declares db: Annotated[AsyncSession, Depends(get_db)] but resolves everything from config and the policy cache. FastAPI will still open and close a session for every call. Removing the parameter drops that cost and makes the "no dispatch, no database" contract in the docstring visible in the signature.

If a future revision needs the session for budget lookups, adding it back is a one-line change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/routing.py` around lines 371 - 376, Remove the unused
db dependency parameter from explain_policy and its associated
AsyncSession/get_db imports only if they become unused; retain config injection
and all existing response behavior. This makes the endpoint avoid creating a
database session when explaining policies.
src/gateway/services/routing/compiler.py (2)

162-164: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Prefer an explicit guard over assert for the schema invariant.

assert statements are removed when Python runs with -O. If that happens and a when entry ever reaches here without a target, None flows into resolve_provider_selector instead of failing loudly. The schema does enforce the invariant today, so this is defensive rather than an active bug.

♻️ Proposed fix
-        if entry.when is not None and _matches(entry.when, user_id=user_id, key_id=key_id, budget=budget):
-            assert entry.target is not None  # schema: a `when` entry always carries a target
-            return entry.target, f"condition:{','.join(entry.when.conditions())}"
+        if (
+            entry.when is not None
+            and entry.target is not None
+            and _matches(entry.when, user_id=user_id, key_id=key_id, budget=budget)
+        ):
+            return entry.target, f"condition:{','.join(entry.when.conditions())}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/routing/compiler.py` around lines 162 - 164, Replace the
assert in the conditional-entry branch of the routing compiler with an explicit
runtime guard that verifies entry.target is not None before returning it.
Preserve the existing condition label and return behavior for valid targets,
while failing loudly when a matching when entry lacks a target, including when
Python runs with optimizations.

153-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider rate-limiting the router warning.

compile_policy runs per request, so a policy that names a router logs a WARNING on every single request that uses it. The routing behavior is correct and the fallthrough is the right choice. The volume is the concern: a busy policy will bury the rest of the log, and the message tells the operator nothing new after the first time.

A one-time warning at config validation or policy load, plus a debug-level line here, keeps the signal without the flood.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/routing/compiler.py` around lines 153 - 161, Reduce
repeated WARNING logs in compile_policy for unavailable routers: move the
operator-facing warning to configuration validation or policy loading so it is
emitted once, and change the per-request logger.warning in the entry.router
branch to a debug-level message while preserving the existing fallthrough
behavior.
src/gateway/services/policy_store.py (1)

44-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add all_policy_names to __all__.

all_policy_names is imported by src/gateway/api/routes/routing.py (line 34), so it is part of this module's public surface. The __all__ list omits it, which makes the declared surface disagree with real usage.

♻️ Proposed fix
 __all__ = [
     "POLICY_CACHE_TTL_SECONDS",
+    "all_policy_names",
     "cached_policies",
     "effective_policies",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/policy_store.py` around lines 44 - 54, Add the existing
all_policy_names symbol to the __all__ list in the policy store module,
alongside the other exported policy helpers, so its declared public surface
matches its import from the routing module.
scripts/check_architecture.py (1)

68-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment claims more than the rule enforces.

The comment states that shared types "may not import any other gateway layer", but gateway.models is absent from forbidden, so a module under gateway/types can import it and the check stays green. Either add gateway.models to the list, or narrow the comment to name the layers that are actually enforced. Right now a reader trusts the comment and a future type quietly grows a pydantic dependency.

No behavior change is needed today; both current type modules import only the standard library and any_llm.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_architecture.py` around lines 68 - 77, The gateway/types
architecture rule’s comment claims all gateway layers are forbidden, but its
forbidden list omits gateway.models. Update the “gateway/types” entry so the
documented restriction matches enforcement by adding gateway.models to
forbidden, without changing unrelated rules.
src/gateway/api/routes/models.py (1)

298-309: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Read the policy map once, then derive both values from it.

The comment on line 413 already states the rule for aliases: read once, so the withheld set and the listed names agree even if a write lands between the two reads. _policy_target_keys breaks that rule for policies, because it calls effective_policies a second time. If a policy is created or deleted between line 419 and line 427, the catalog can list a policy whose candidates were not withheld, or withhold candidates for a policy it no longer lists.

The cache makes this cheap and rare, so this is about the invariant rather than cost. Passing the already-resolved mapping in also removes the duplicate lookup.

♻️ Suggested shape
-def _policy_target_keys(config: GatewayConfig, caller_user_id: str | None) -> set[str]:
+def _policy_target_keys(config: GatewayConfig, policies: dict[str, PolicySpec]) -> set[str]:
     """Canonical pricing keys of every selector any policy in force can reach.
 
     Withheld from the listing for the same reason alias targets are: a policy exists
     partly so the provider/model behind it stays private, and that has to hold for
     its fallback candidates too, not just its default.
     """
     return {
         normalize_pricing_key(config, selector)
-        for spec in effective_policies(config, caller_user_id).values()
+        for spec in policies.values()
         for selector in spec.static_selectors()
     }

_policy_catalog_entries would return the resolved mapping alongside the split, or the caller resolves effective_policies once and passes it to both helpers.

Also applies to: 419-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/models.py` around lines 298 - 309, Resolve
effective_policies once in the policy catalog flow and reuse that mapping for
both catalog entries and _policy_target_keys. Update the relevant helper
signatures and caller, including _policy_catalog_entries if needed, so policy
listing and withheld target derivation operate on the same snapshot.
src/gateway/services/guardrails.py (1)

206-214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the docstring to match the new two-field decision.

The logic is right, and keeping block/block as the default preserves the old behavior. The function docstring above still states that block guardrails always fail closed and that GuardrailsNotReachableError is raised for any block-mode guardrail. With on_unavailable="monitor" a block guardrail now fails open, so the Raises: section is no longer accurate. A future reader debugging a served request during a guardrails outage will read the docstring first.

The Failure handling bullets and the Raises: line need the on_unavailable condition added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/guardrails.py` around lines 206 - 214, Update the
docstring for the function containing the GuardrailsNotReachableError handler to
describe fail-closed behavior only when both cfg.mode and cfg.on_unavailable are
"block"; revise the Failure handling bullets and Raises section accordingly,
while documenting that other on_unavailable settings fail open.
web/src/pages/RoutingPage.test.tsx (1)

84-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a test for scoped delete, and let the mock honor user_id.

The DELETE branch resolves the name from the path and filters list by name alone, so the user_id query parameter is ignored. No test currently deletes anything, which means the mock cannot catch the subtlest rule in useDeleteRoutingPolicy: only a null or absent userId means global scope, because "" is a legal user id. That rule is exactly the kind that a later refactor turns into a truthiness check, and then a global policy gets deleted instead of a user-scoped one.

A short test that renders a user-scoped row, presses Delete, and asserts the request URL carries ?user_id=alice would pin the behavior down.

♻️ Proposed mock change so scope is observable
       if (method === "DELETE") {
-        const name = decodeURIComponent((url.split("?")[0].split("/").pop() ?? ""));
-        list = list.filter((item) => item.name !== name);
+        const [path, query] = url.split("?");
+        const name = decodeURIComponent(path.split("/").pop() ?? "");
+        const scoped = new URLSearchParams(query ?? "").get("user_id");
+        list = list.filter((item) => item.name !== name || item.user_id !== scoped);
         return new Response(null, { status: 204 });
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` around lines 84 - 96, Update the DELETE
branch of the routing-policy mock to read and honor the user_id query parameter,
distinguishing null/absent global scope from any provided value, including an
empty string, when filtering by policy name and scope. Add a test covering
deletion of a user-scoped policy that renders the row, presses Delete, and
verifies the request URL includes ?user_id=alice.
src/gateway/static/dashboard/assets/index-BmxAZYrI.css (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial

Ignore the generated dashboard bundle where lint runs

The build output under src/gateway/static/dashboard is meant to be committed, and machine-generated Tailwind/HeroUI CSS should not fail CI on every rebuild. If this project configures a stylelint check, add src/gateway/static/dashboard/** to .stylelintignore so web/src CSS stays the signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/static/dashboard/assets/index-BmxAZYrI.css` at line 1, Add
src/gateway/static/dashboard/** to the project’s .stylelintignore configuration
so generated Tailwind/HeroUI assets such as the dashboard CSS bundle are
excluded from stylelint while web/src styles continue to be checked.

Source: Linters/SAST tools

tests/unit/test_attempt_walker.py (1)

48-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two new walker parameters have no unit coverage.

The harness never exercises on_absorbed or build_kwargs, and both carry real meaning. on_absorbed decides which failures become status="absorbed" audit rows, and the walker deliberately skips it for the last attempt. build_kwargs is how the responses format rebuilds per-candidate kwargs; a regression there would send one provider's arguments to another. The fast tier is the right place to pin both.

💚 Suggested tests
`@pytest.mark.asyncio`
async def test_absorbed_is_called_for_recovered_attempts_only() -> None:
    """The terminal failure is the request's own outcome; the caller logs that one."""
    absorbed: list[int] = []

    async def run_attempt(attempt: Attempt, call_kwargs: dict[str, Any], mark_locked_in: Any) -> Any:
        raise _http_error(503)

    async def on_absorbed(attempt: Attempt, exc: BaseException, total: int) -> None:
        absorbed.append(attempt.position)

    with pytest.raises(HTTPException):
        await walk_attempts(
            attempts=[_attempt(1, "a"), _attempt(2, "b")],
            base_request_fields={},
            run_attempt=run_attempt,
            max_tool_iterations=10,
            on_absorbed=on_absorbed,
        )

    assert absorbed == [1]


`@pytest.mark.asyncio`
async def test_build_kwargs_runs_per_candidate() -> None:
    """The transformation must apply to the candidate being tried, not the one that failed."""
    seen: list[str] = []

    def build_kwargs(attempt: Attempt, fields: dict[str, Any]) -> dict[str, Any]:
        return {**fields, "provider": attempt.instance, "model": attempt.model}

    async def run_attempt(attempt: Attempt, call_kwargs: dict[str, Any], mark_locked_in: Any) -> Any:
        seen.append(call_kwargs["model"])
        if attempt.position == 1:
            raise _http_error(503)
        return "ok"

    await walk_attempts(
        attempts=[_attempt(1, "a"), _attempt(2, "b", instance="anthropic")],
        base_request_fields={},
        run_attempt=run_attempt,
        max_tool_iterations=10,
        build_kwargs=build_kwargs,
    )

    assert seen == ["a", "b"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_attempt_walker.py` around lines 48 - 70, Add fast-tier unit
tests covering both new walker hooks: verify on_absorbed is called only for
recovered failures and skipped for the terminal attempt, and verify build_kwargs
is invoked per candidate so each attempt’s provider/model arguments reach
run_attempt after fallback.
tests/integration/test_routing_policies.py (1)

770-784: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

This test passes for the wrong reason on one nearby case.

Here every candidate returns 503, so the walk really does reach position 2 and attempt_position == 2 is correct. The gap is the terminal-failure case: a 400 or 401 on candidate 1 stops the walk immediately, but the error row is still attributed to attempts[-1] (see the _failure_attribution comment on src/gateway/api/routes/_pipeline.py). A second test would catch that, and would fail today:

💚 Suggested additional test
def test_a_terminal_failure_is_attributed_to_the_candidate_that_failed(client: TestClient) -> None:
    """A 401 stops the chain at candidate 1, so the row must name candidate 1."""
    _create_user(client)
    with patch("gateway.api.routes.chat.acompletion", new=AsyncMock(side_effect=_http_error(401))):
        _chat(client, "fast")

    errors = [r for r in _usage_rows(client) if r["status"] == "error"]
    assert len(errors) == 1
    assert errors[0]["attempt_position"] == 1
    assert errors[0]["model"] == "gpt-5-mini"
    assert errors[0]["provider"] == "openai"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_routing_policies.py` around lines 770 - 784, The
existing test does not cover terminal failures that stop routing at the first
candidate. Add a test alongside
test_a_failed_request_still_says_which_policy_it_went_through that mocks a 401
response, verifies one error usage row, and asserts attempt_position is 1 with
the expected model and provider, so _failure_attribution handles the candidate
that actually failed rather than the final configured attempt.
tests/unit/test_pipeline_settlement.py (1)

75-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider letting _ctx build a plan-shaped context.

This block is the fast tier for settlement, and its own header says the merged executor must preserve these behaviors. Right now no test here builds a RequestContext with a plan, so the failover branch and the settlement rules that matter most for routing are covered only in the Postgres-backed integration tier:

  • exactly one reconcile for a request that fell over,
  • exactly one refund when the plan is exhausted,
  • a refused reservation top-up stopping the chain instead of serving the pricier candidate.

Adding plan: CompiledPlan | None = None and request_group_id to _ctx would make those reachable here, where they run in milliseconds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_pipeline_settlement.py` around lines 75 - 95, Extend the test
helper _ctx to accept optional plan and request_group_id parameters and pass
them into RequestContext, using the existing CompiledPlan type and appropriate
default. Add fast-tier unit coverage for failover settlement: exactly one
reconcile, one refund when the plan is exhausted, and stopping the chain when
reservation top-up is refused.
web/src/pages/ActivityPage.tsx (1)

879-902: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add Routing and absorbed-status coverage to ActivityPage.test.tsx.

Routing is a new column, and status: "absorbed" is a new status pill, but the existing tests still do not assert either. Add two small row cases that mock usage rows, then query the rendered text or roles: one unrouted row must have no routing text, and one routed row must show the attempt 2/2 attribution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/ActivityPage.tsx` around lines 879 - 902, Add focused cases in
ActivityPage.test.tsx covering the new Routing column and absorbed status pill:
mock an unrouted usage row and assert it has no routing text, then mock a routed
row with status "absorbed" and assert the rendered UI exposes the absorbed
status and “attempt 2/2” attribution. Use existing row-rendering helpers and
query text or roles rather than implementation details.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/routing.md`:
- Around line 82-85: Update the routing documentation to remove the master-key
case from the “no budget configured” exception. Keep the explanation for callers
without a budget and unlimited budgets, while documenting that master-key
requests use the supplied user_id and may match a policy branch based on that
user’s budget.

In `@otari.db-shm`:
- Line 1: Remove the committed SQLite runtime artifacts otari.db-shm and
otari.db-wal from version control, and broaden the existing root .gitignore rule
for otari.db so these companion files are ignored as well. Keep otari.db itself
ignored and ensure future local runs cannot reintroduce any of the three
database files.

In `@scripts/seed_routing_demo.py`:
- Around line 4-6: Update the descriptive output in the seed script to say
“failover” and avoid guaranteeing an absorbed attempt row; state that fallback
and absorbed activity may appear only when the seeded traffic triggers a
retryable primary-model failure. Apply the same wording correction to the
corresponding description around the other affected output block.

In `@src/gateway/api/routes/_attempts.py`:
- Around line 189-206: Add import asyncio and update the exception handling
around the retry flow so asyncio.CancelledError is explicitly re-raised before
the broad BaseException classification and provider-error mapping in the visible
catch block. Keep timeout handling unchanged, relying on the existing Python
3.13+ TimeoutError behavior.

In `@src/gateway/api/routes/_pipeline.py`:
- Around line 2626-2668: Update the exhausted-plan flow so it records the actual
attempt where walk_attempts stopped, rather than always using
ctx.plan.attempts[-1]. Propagate that attempt through both exception-catching
call sites into log_exhausted_plan, and use it for _failure_attribution plus the
logged model and provider; preserve tail attribution only when the walker truly
reaches the final candidate.

In `@src/gateway/api/routes/messages.py`:
- Line 535: The streaming Messages request path is missing the base request
fields needed for failover. Update the streaming call near the non-streaming
request handling to pass base_request_fields=request_fields, matching the
existing non-streaming path and chat.py behavior so run_single_attempt_stream
can apply the on_failure chain.

In `@src/gateway/api/routes/models.py`:
- Around line 254-270: The ModelsPage pricing-source mapping must preserve
"dynamic" instead of collapsing it to "none" when pricing is null. Update the
model-to-PriceSource logic in ModelsPage.tsx to explicitly map or permit
model.pricing_source === "dynamic", and ensure the resulting PriceSource
rendering handles that value without assigning an alias label.

In `@src/gateway/models/routing.py`:
- Around line 133-152: Update _budget_thresholds_stay_under_the_cap to apply the
unreachable-threshold validation only when budget_used_pct uses the upward gte
or gt comparator and its value is at least 100. Allow lt and lte thresholds at
or above 100 to pass validation, while preserving the existing error and
behavior for unreachable upward thresholds.

In `@src/gateway/static/dashboard/assets/RoutingPage-DKobULws.js`:
- Line 1: The edit form in D currently rebuilds spec.select through se,
preserving only budget thresholds and the default while dropping router entries.
Update the select-state initialization and serialization around se and the $
useMemo so existing router entries remain intact when saving, while retaining
the current budget-threshold editing behavior; alternatively, prevent D from
editing policies containing router-backed select entries.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 82-91: The Status filter UI should expose an “Absorbed” option
alongside All, Success, and Error, using the existing status-filter value and
query flow so it requests status=absorbed. Update the status select
definition/rendering in ActivityPage without changing activity row styling or
other filter behavior.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 614-615: Make the adding and editing state transitions in
RoutingPage mutually exclusive: when opening a new PolicyForm, clear editing,
and when opening an existing policy for editing, clear adding. Update the
handlers used by the “New policy” action and the table’s Edit button, while
preserving the existing PolicyForm close behavior.
- Around line 201-214: Update the policy editor state and submit logic around
the PolicySpec useMemo so editing preserves all loaded spec fields, including
spec_version, limits, and select entries the form does not model. On save,
replace only the form-owned conditions/default target while carrying through
unrecognized select rules and existing metadata; ensure the Edit action for
stored policies cannot silently discard data.

---

Nitpick comments:
In `@scripts/check_architecture.py`:
- Around line 68-77: The gateway/types architecture rule’s comment claims all
gateway layers are forbidden, but its forbidden list omits gateway.models.
Update the “gateway/types” entry so the documented restriction matches
enforcement by adding gateway.models to forbidden, without changing unrelated
rules.

In `@src/gateway/api/routes/models.py`:
- Around line 298-309: Resolve effective_policies once in the policy catalog
flow and reuse that mapping for both catalog entries and _policy_target_keys.
Update the relevant helper signatures and caller, including
_policy_catalog_entries if needed, so policy listing and withheld target
derivation operate on the same snapshot.

In `@src/gateway/api/routes/routing.py`:
- Around line 238-248: Update the list_policies endpoint to accept limit and
offset query parameters using the same sane defaults and maximum bounds as other
management list routes. Apply both bounds to the RoutingPolicy query before
execution while preserving its existing name ordering and response behavior.
- Around line 371-376: Remove the unused db dependency parameter from
explain_policy and its associated AsyncSession/get_db imports only if they
become unused; retain config injection and all existing response behavior. This
makes the endpoint avoid creating a database session when explaining policies.

In `@src/gateway/services/guardrails.py`:
- Around line 206-214: Update the docstring for the function containing the
GuardrailsNotReachableError handler to describe fail-closed behavior only when
both cfg.mode and cfg.on_unavailable are "block"; revise the Failure handling
bullets and Raises section accordingly, while documenting that other
on_unavailable settings fail open.

In `@src/gateway/services/policy_store.py`:
- Around line 44-54: Add the existing all_policy_names symbol to the __all__
list in the policy store module, alongside the other exported policy helpers, so
its declared public surface matches its import from the routing module.

In `@src/gateway/services/routing/compiler.py`:
- Around line 162-164: Replace the assert in the conditional-entry branch of the
routing compiler with an explicit runtime guard that verifies entry.target is
not None before returning it. Preserve the existing condition label and return
behavior for valid targets, while failing loudly when a matching when entry
lacks a target, including when Python runs with optimizations.
- Around line 153-161: Reduce repeated WARNING logs in compile_policy for
unavailable routers: move the operator-facing warning to configuration
validation or policy loading so it is emitted once, and change the per-request
logger.warning in the entry.router branch to a debug-level message while
preserving the existing fallthrough behavior.

In `@src/gateway/static/dashboard/assets/index-BmxAZYrI.css`:
- Line 1: Add src/gateway/static/dashboard/** to the project’s .stylelintignore
configuration so generated Tailwind/HeroUI assets such as the dashboard CSS
bundle are excluded from stylelint while web/src styles continue to be checked.

In `@tests/integration/test_routing_policies.py`:
- Around line 770-784: The existing test does not cover terminal failures that
stop routing at the first candidate. Add a test alongside
test_a_failed_request_still_says_which_policy_it_went_through that mocks a 401
response, verifies one error usage row, and asserts attempt_position is 1 with
the expected model and provider, so _failure_attribution handles the candidate
that actually failed rather than the final configured attempt.

In `@tests/unit/test_attempt_walker.py`:
- Around line 48-70: Add fast-tier unit tests covering both new walker hooks:
verify on_absorbed is called only for recovered failures and skipped for the
terminal attempt, and verify build_kwargs is invoked per candidate so each
attempt’s provider/model arguments reach run_attempt after fallback.

In `@tests/unit/test_pipeline_settlement.py`:
- Around line 75-95: Extend the test helper _ctx to accept optional plan and
request_group_id parameters and pass them into RequestContext, using the
existing CompiledPlan type and appropriate default. Add fast-tier unit coverage
for failover settlement: exactly one reconcile, one refund when the plan is
exhausted, and stopping the chain when reservation top-up is refused.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 879-902: Add focused cases in ActivityPage.test.tsx covering the
new Routing column and absorbed status pill: mock an unrouted usage row and
assert it has no routing text, then mock a routed row with status "absorbed" and
assert the rendered UI exposes the absorbed status and “attempt 2/2”
attribution. Use existing row-rendering helpers and query text or roles rather
than implementation details.

In `@web/src/pages/RoutingPage.test.tsx`:
- Around line 84-96: Update the DELETE branch of the routing-policy mock to read
and honor the user_id query parameter, distinguishing null/absent global scope
from any provided value, including an empty string, when filtering by policy
name and scope. Add a test covering deletion of a user-scoped policy that
renders the row, presses Delete, and verifies the request URL includes
?user_id=alice.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 21ecf7ea-8c50-4a00-9827-28882ff33829

📥 Commits

Reviewing files that changed from the base of the PR and between 4795754 and 39e1b47.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (77)
  • alembic/versions/e8b1d3f5a7c9_add_routing_policies.py
  • alembic/versions/f4c6a8b0d2e5_add_usage_logs_routing_attribution.py
  • config.example.yml
  • docs/configuration.md
  • docs/dashboard.md
  • docs/index.md
  • docs/models.md
  • docs/public/otari.postman_collection.json
  • docs/routing.md
  • otari.db-shm
  • otari.db-wal
  • scripts/check_architecture.py
  • scripts/seed_routing_demo.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/chat.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/routing.py
  • src/gateway/api/routes/usage.py
  • src/gateway/cli.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/models/guardrails.py
  • src/gateway/models/routing.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/guardrails.py
  • src/gateway/services/policy_store.py
  • src/gateway/services/provider_kwargs.py
  • src/gateway/services/routing/__init__.py
  • src/gateway/services/routing/compiler.py
  • src/gateway/static/dashboard/assets/ActivityPage-ChQ9nFtF.js
  • src/gateway/static/dashboard/assets/ActivityPage-D3zW3w7G.js
  • src/gateway/static/dashboard/assets/AliasesPage-BQwHhTMQ.js
  • src/gateway/static/dashboard/assets/AliasesPage-Gb_wsTGI.js
  • src/gateway/static/dashboard/assets/BudgetsPage-DPpY7c4J.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-Ug1xmzTh.js
  • src/gateway/static/dashboard/assets/DocsPage-CFMmMs_h.js
  • src/gateway/static/dashboard/assets/KeysPage-CmiGNdVE.js
  • src/gateway/static/dashboard/assets/ModelComboBox-Bb0dsqaN.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-BSC5r_qF.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DOAM-IRn.js
  • src/gateway/static/dashboard/assets/ModelsPage-CRJ_Wl2U.js
  • src/gateway/static/dashboard/assets/ModelsPage-qNMhih0k.js
  • src/gateway/static/dashboard/assets/OverviewPage-B28JIhAq.js
  • src/gateway/static/dashboard/assets/OverviewPage-CTW9HJc-.js
  • src/gateway/static/dashboard/assets/ProvidersPage-CkydSD4T.js
  • src/gateway/static/dashboard/assets/ProvidersPage-V9eY6Y_V.js
  • src/gateway/static/dashboard/assets/RoutingPage-DKobULws.js
  • src/gateway/static/dashboard/assets/SettingsPage-0CTHb8Ij.js
  • src/gateway/static/dashboard/assets/SettingsPage-Cg7H88vI.js
  • src/gateway/static/dashboard/assets/TablePagination-ZKa66bAr.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CQmrtqXw.js
  • src/gateway/static/dashboard/assets/UsagePage-4BFMRtYx.js
  • src/gateway/static/dashboard/assets/UsersPage-CGPsxV6u.js
  • src/gateway/static/dashboard/assets/index-BmxAZYrI.css
  • src/gateway/static/dashboard/assets/index-CW8dw4O4.css
  • src/gateway/static/dashboard/assets/index-CvNLvggU.js
  • src/gateway/static/dashboard/assets/index-DGvD8Pfl.js
  • src/gateway/static/dashboard/index.html
  • src/gateway/types/attempt.py
  • src/gateway/types/budget_state.py
  • tests/integration/test_routing_policies.py
  • tests/integration/test_usage_endpoint.py
  • tests/unit/test_attempt_walker.py
  • tests/unit/test_check_architecture.py
  • tests/unit/test_pipeline_settlement.py
  • web/src/App.tsx
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/components/AppShell.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx
💤 Files with no reviewable changes (8)
  • src/gateway/static/dashboard/assets/AliasesPage-BQwHhTMQ.js
  • src/gateway/static/dashboard/assets/ModelsPage-CRJ_Wl2U.js
  • src/gateway/static/dashboard/assets/ActivityPage-ChQ9nFtF.js
  • src/gateway/static/dashboard/assets/SettingsPage-Cg7H88vI.js
  • src/gateway/static/dashboard/assets/ProvidersPage-V9eY6Y_V.js
  • src/gateway/static/dashboard/assets/OverviewPage-B28JIhAq.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DOAM-IRn.js
  • src/gateway/static/dashboard/assets/index-DGvD8Pfl.js

Comment thread docs/routing.md Outdated
Comment thread scripts/seed_routing_demo.py Outdated
Comment thread src/gateway/api/routes/_attempts.py
Comment thread src/gateway/api/routes/_pipeline.py Outdated
Comment thread src/gateway/api/routes/messages.py
Comment thread src/gateway/models/routing.py
Comment thread src/gateway/static/dashboard/assets/RoutingPage-DKobULws.js Outdated
Comment thread web/src/pages/ActivityPage.tsx
Comment thread web/src/pages/RoutingPage.tsx
Comment thread web/src/pages/RoutingPage.tsx Outdated

@coderabbitai coderabbitai Bot 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.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 12

🧹 Nitpick comments (14)
src/gateway/api/routes/routing.py (2)

238-248: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add a pagination bound to list_policies.

select(RoutingPolicy) fetches every row with no limit. In practice this table stays small because only an operator writes to it, so this is unlikely to bite soon. The house rule is unconditional for list endpoints, though, and a deployment that scopes a policy per user can grow this table faster than expected.

Adding limit/offset query parameters with a default and a maximum, matching the other management list routes, keeps this endpoint predictable.

As per coding guidelines: "Every list endpoint must have a sane default and maximum pagination bound; never select an unbounded growing table such as UsageLog or ModelPricing."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/routing.py` around lines 238 - 248, Update the
list_policies endpoint to accept limit and offset query parameters using the
same sane defaults and maximum bounds as other management list routes. Apply
both bounds to the RoutingPolicy query before execution while preserving its
existing name ordering and response behavior.

Source: Coding guidelines


371-376: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

explain_policy never uses db.

The handler declares db: Annotated[AsyncSession, Depends(get_db)] but resolves everything from config and the policy cache. FastAPI will still open and close a session for every call. Removing the parameter drops that cost and makes the "no dispatch, no database" contract in the docstring visible in the signature.

If a future revision needs the session for budget lookups, adding it back is a one-line change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/routing.py` around lines 371 - 376, Remove the unused
db dependency parameter from explain_policy and its associated
AsyncSession/get_db imports only if they become unused; retain config injection
and all existing response behavior. This makes the endpoint avoid creating a
database session when explaining policies.
src/gateway/services/routing/compiler.py (2)

162-164: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Prefer an explicit guard over assert for the schema invariant.

assert statements are removed when Python runs with -O. If that happens and a when entry ever reaches here without a target, None flows into resolve_provider_selector instead of failing loudly. The schema does enforce the invariant today, so this is defensive rather than an active bug.

♻️ Proposed fix
-        if entry.when is not None and _matches(entry.when, user_id=user_id, key_id=key_id, budget=budget):
-            assert entry.target is not None  # schema: a `when` entry always carries a target
-            return entry.target, f"condition:{','.join(entry.when.conditions())}"
+        if (
+            entry.when is not None
+            and entry.target is not None
+            and _matches(entry.when, user_id=user_id, key_id=key_id, budget=budget)
+        ):
+            return entry.target, f"condition:{','.join(entry.when.conditions())}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/routing/compiler.py` around lines 162 - 164, Replace the
assert in the conditional-entry branch of the routing compiler with an explicit
runtime guard that verifies entry.target is not None before returning it.
Preserve the existing condition label and return behavior for valid targets,
while failing loudly when a matching when entry lacks a target, including when
Python runs with optimizations.

153-161: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider rate-limiting the router warning.

compile_policy runs per request, so a policy that names a router logs a WARNING on every single request that uses it. The routing behavior is correct and the fallthrough is the right choice. The volume is the concern: a busy policy will bury the rest of the log, and the message tells the operator nothing new after the first time.

A one-time warning at config validation or policy load, plus a debug-level line here, keeps the signal without the flood.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/routing/compiler.py` around lines 153 - 161, Reduce
repeated WARNING logs in compile_policy for unavailable routers: move the
operator-facing warning to configuration validation or policy loading so it is
emitted once, and change the per-request logger.warning in the entry.router
branch to a debug-level message while preserving the existing fallthrough
behavior.
src/gateway/services/policy_store.py (1)

44-54: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add all_policy_names to __all__.

all_policy_names is imported by src/gateway/api/routes/routing.py (line 34), so it is part of this module's public surface. The __all__ list omits it, which makes the declared surface disagree with real usage.

♻️ Proposed fix
 __all__ = [
     "POLICY_CACHE_TTL_SECONDS",
+    "all_policy_names",
     "cached_policies",
     "effective_policies",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/policy_store.py` around lines 44 - 54, Add the existing
all_policy_names symbol to the __all__ list in the policy store module,
alongside the other exported policy helpers, so its declared public surface
matches its import from the routing module.
scripts/check_architecture.py (1)

68-77: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The comment claims more than the rule enforces.

The comment states that shared types "may not import any other gateway layer", but gateway.models is absent from forbidden, so a module under gateway/types can import it and the check stays green. Either add gateway.models to the list, or narrow the comment to name the layers that are actually enforced. Right now a reader trusts the comment and a future type quietly grows a pydantic dependency.

No behavior change is needed today; both current type modules import only the standard library and any_llm.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check_architecture.py` around lines 68 - 77, The gateway/types
architecture rule’s comment claims all gateway layers are forbidden, but its
forbidden list omits gateway.models. Update the “gateway/types” entry so the
documented restriction matches enforcement by adding gateway.models to
forbidden, without changing unrelated rules.
src/gateway/api/routes/models.py (1)

298-309: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Read the policy map once, then derive both values from it.

The comment on line 413 already states the rule for aliases: read once, so the withheld set and the listed names agree even if a write lands between the two reads. _policy_target_keys breaks that rule for policies, because it calls effective_policies a second time. If a policy is created or deleted between line 419 and line 427, the catalog can list a policy whose candidates were not withheld, or withhold candidates for a policy it no longer lists.

The cache makes this cheap and rare, so this is about the invariant rather than cost. Passing the already-resolved mapping in also removes the duplicate lookup.

♻️ Suggested shape
-def _policy_target_keys(config: GatewayConfig, caller_user_id: str | None) -> set[str]:
+def _policy_target_keys(config: GatewayConfig, policies: dict[str, PolicySpec]) -> set[str]:
     """Canonical pricing keys of every selector any policy in force can reach.
 
     Withheld from the listing for the same reason alias targets are: a policy exists
     partly so the provider/model behind it stays private, and that has to hold for
     its fallback candidates too, not just its default.
     """
     return {
         normalize_pricing_key(config, selector)
-        for spec in effective_policies(config, caller_user_id).values()
+        for spec in policies.values()
         for selector in spec.static_selectors()
     }

_policy_catalog_entries would return the resolved mapping alongside the split, or the caller resolves effective_policies once and passes it to both helpers.

Also applies to: 419-427

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/api/routes/models.py` around lines 298 - 309, Resolve
effective_policies once in the policy catalog flow and reuse that mapping for
both catalog entries and _policy_target_keys. Update the relevant helper
signatures and caller, including _policy_catalog_entries if needed, so policy
listing and withheld target derivation operate on the same snapshot.
src/gateway/services/guardrails.py (1)

206-214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the docstring to match the new two-field decision.

The logic is right, and keeping block/block as the default preserves the old behavior. The function docstring above still states that block guardrails always fail closed and that GuardrailsNotReachableError is raised for any block-mode guardrail. With on_unavailable="monitor" a block guardrail now fails open, so the Raises: section is no longer accurate. A future reader debugging a served request during a guardrails outage will read the docstring first.

The Failure handling bullets and the Raises: line need the on_unavailable condition added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/services/guardrails.py` around lines 206 - 214, Update the
docstring for the function containing the GuardrailsNotReachableError handler to
describe fail-closed behavior only when both cfg.mode and cfg.on_unavailable are
"block"; revise the Failure handling bullets and Raises section accordingly,
while documenting that other on_unavailable settings fail open.
web/src/pages/RoutingPage.test.tsx (1)

84-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a test for scoped delete, and let the mock honor user_id.

The DELETE branch resolves the name from the path and filters list by name alone, so the user_id query parameter is ignored. No test currently deletes anything, which means the mock cannot catch the subtlest rule in useDeleteRoutingPolicy: only a null or absent userId means global scope, because "" is a legal user id. That rule is exactly the kind that a later refactor turns into a truthiness check, and then a global policy gets deleted instead of a user-scoped one.

A short test that renders a user-scoped row, presses Delete, and asserts the request URL carries ?user_id=alice would pin the behavior down.

♻️ Proposed mock change so scope is observable
       if (method === "DELETE") {
-        const name = decodeURIComponent((url.split("?")[0].split("/").pop() ?? ""));
-        list = list.filter((item) => item.name !== name);
+        const [path, query] = url.split("?");
+        const name = decodeURIComponent(path.split("/").pop() ?? "");
+        const scoped = new URLSearchParams(query ?? "").get("user_id");
+        list = list.filter((item) => item.name !== name || item.user_id !== scoped);
         return new Response(null, { status: 204 });
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` around lines 84 - 96, Update the DELETE
branch of the routing-policy mock to read and honor the user_id query parameter,
distinguishing null/absent global scope from any provided value, including an
empty string, when filtering by policy name and scope. Add a test covering
deletion of a user-scoped policy that renders the row, presses Delete, and
verifies the request URL includes ?user_id=alice.
src/gateway/static/dashboard/assets/index-BmxAZYrI.css (1)

1-1: 📐 Maintainability & Code Quality | 🔵 Trivial

Ignore the generated dashboard bundle where lint runs

The build output under src/gateway/static/dashboard is meant to be committed, and machine-generated Tailwind/HeroUI CSS should not fail CI on every rebuild. If this project configures a stylelint check, add src/gateway/static/dashboard/** to .stylelintignore so web/src CSS stays the signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gateway/static/dashboard/assets/index-BmxAZYrI.css` at line 1, Add
src/gateway/static/dashboard/** to the project’s .stylelintignore configuration
so generated Tailwind/HeroUI assets such as the dashboard CSS bundle are
excluded from stylelint while web/src styles continue to be checked.

Source: Linters/SAST tools

tests/unit/test_attempt_walker.py (1)

48-70: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Two new walker parameters have no unit coverage.

The harness never exercises on_absorbed or build_kwargs, and both carry real meaning. on_absorbed decides which failures become status="absorbed" audit rows, and the walker deliberately skips it for the last attempt. build_kwargs is how the responses format rebuilds per-candidate kwargs; a regression there would send one provider's arguments to another. The fast tier is the right place to pin both.

💚 Suggested tests
`@pytest.mark.asyncio`
async def test_absorbed_is_called_for_recovered_attempts_only() -> None:
    """The terminal failure is the request's own outcome; the caller logs that one."""
    absorbed: list[int] = []

    async def run_attempt(attempt: Attempt, call_kwargs: dict[str, Any], mark_locked_in: Any) -> Any:
        raise _http_error(503)

    async def on_absorbed(attempt: Attempt, exc: BaseException, total: int) -> None:
        absorbed.append(attempt.position)

    with pytest.raises(HTTPException):
        await walk_attempts(
            attempts=[_attempt(1, "a"), _attempt(2, "b")],
            base_request_fields={},
            run_attempt=run_attempt,
            max_tool_iterations=10,
            on_absorbed=on_absorbed,
        )

    assert absorbed == [1]


`@pytest.mark.asyncio`
async def test_build_kwargs_runs_per_candidate() -> None:
    """The transformation must apply to the candidate being tried, not the one that failed."""
    seen: list[str] = []

    def build_kwargs(attempt: Attempt, fields: dict[str, Any]) -> dict[str, Any]:
        return {**fields, "provider": attempt.instance, "model": attempt.model}

    async def run_attempt(attempt: Attempt, call_kwargs: dict[str, Any], mark_locked_in: Any) -> Any:
        seen.append(call_kwargs["model"])
        if attempt.position == 1:
            raise _http_error(503)
        return "ok"

    await walk_attempts(
        attempts=[_attempt(1, "a"), _attempt(2, "b", instance="anthropic")],
        base_request_fields={},
        run_attempt=run_attempt,
        max_tool_iterations=10,
        build_kwargs=build_kwargs,
    )

    assert seen == ["a", "b"]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_attempt_walker.py` around lines 48 - 70, Add fast-tier unit
tests covering both new walker hooks: verify on_absorbed is called only for
recovered failures and skipped for the terminal attempt, and verify build_kwargs
is invoked per candidate so each attempt’s provider/model arguments reach
run_attempt after fallback.
tests/integration/test_routing_policies.py (1)

770-784: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

This test passes for the wrong reason on one nearby case.

Here every candidate returns 503, so the walk really does reach position 2 and attempt_position == 2 is correct. The gap is the terminal-failure case: a 400 or 401 on candidate 1 stops the walk immediately, but the error row is still attributed to attempts[-1] (see the _failure_attribution comment on src/gateway/api/routes/_pipeline.py). A second test would catch that, and would fail today:

💚 Suggested additional test
def test_a_terminal_failure_is_attributed_to_the_candidate_that_failed(client: TestClient) -> None:
    """A 401 stops the chain at candidate 1, so the row must name candidate 1."""
    _create_user(client)
    with patch("gateway.api.routes.chat.acompletion", new=AsyncMock(side_effect=_http_error(401))):
        _chat(client, "fast")

    errors = [r for r in _usage_rows(client) if r["status"] == "error"]
    assert len(errors) == 1
    assert errors[0]["attempt_position"] == 1
    assert errors[0]["model"] == "gpt-5-mini"
    assert errors[0]["provider"] == "openai"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_routing_policies.py` around lines 770 - 784, The
existing test does not cover terminal failures that stop routing at the first
candidate. Add a test alongside
test_a_failed_request_still_says_which_policy_it_went_through that mocks a 401
response, verifies one error usage row, and asserts attempt_position is 1 with
the expected model and provider, so _failure_attribution handles the candidate
that actually failed rather than the final configured attempt.
tests/unit/test_pipeline_settlement.py (1)

75-95: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider letting _ctx build a plan-shaped context.

This block is the fast tier for settlement, and its own header says the merged executor must preserve these behaviors. Right now no test here builds a RequestContext with a plan, so the failover branch and the settlement rules that matter most for routing are covered only in the Postgres-backed integration tier:

  • exactly one reconcile for a request that fell over,
  • exactly one refund when the plan is exhausted,
  • a refused reservation top-up stopping the chain instead of serving the pricier candidate.

Adding plan: CompiledPlan | None = None and request_group_id to _ctx would make those reachable here, where they run in milliseconds.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_pipeline_settlement.py` around lines 75 - 95, Extend the test
helper _ctx to accept optional plan and request_group_id parameters and pass
them into RequestContext, using the existing CompiledPlan type and appropriate
default. Add fast-tier unit coverage for failover settlement: exactly one
reconcile, one refund when the plan is exhausted, and stopping the chain when
reservation top-up is refused.
web/src/pages/ActivityPage.tsx (1)

879-902: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add Routing and absorbed-status coverage to ActivityPage.test.tsx.

Routing is a new column, and status: "absorbed" is a new status pill, but the existing tests still do not assert either. Add two small row cases that mock usage rows, then query the rendered text or roles: one unrouted row must have no routing text, and one routed row must show the attempt 2/2 attribution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/ActivityPage.tsx` around lines 879 - 902, Add focused cases in
ActivityPage.test.tsx covering the new Routing column and absorbed status pill:
mock an unrouted usage row and assert it has no routing text, then mock a routed
row with status "absorbed" and assert the rendered UI exposes the absorbed
status and “attempt 2/2” attribution. Use existing row-rendering helpers and
query text or roles rather than implementation details.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/routing.md`:
- Around line 82-85: Update the routing documentation to remove the master-key
case from the “no budget configured” exception. Keep the explanation for callers
without a budget and unlimited budgets, while documenting that master-key
requests use the supplied user_id and may match a policy branch based on that
user’s budget.

In `@otari.db-shm`:
- Line 1: Remove the committed SQLite runtime artifacts otari.db-shm and
otari.db-wal from version control, and broaden the existing root .gitignore rule
for otari.db so these companion files are ignored as well. Keep otari.db itself
ignored and ensure future local runs cannot reintroduce any of the three
database files.

In `@scripts/seed_routing_demo.py`:
- Around line 4-6: Update the descriptive output in the seed script to say
“failover” and avoid guaranteeing an absorbed attempt row; state that fallback
and absorbed activity may appear only when the seeded traffic triggers a
retryable primary-model failure. Apply the same wording correction to the
corresponding description around the other affected output block.

In `@src/gateway/api/routes/_attempts.py`:
- Around line 189-206: Add import asyncio and update the exception handling
around the retry flow so asyncio.CancelledError is explicitly re-raised before
the broad BaseException classification and provider-error mapping in the visible
catch block. Keep timeout handling unchanged, relying on the existing Python
3.13+ TimeoutError behavior.

In `@src/gateway/api/routes/_pipeline.py`:
- Around line 2626-2668: Update the exhausted-plan flow so it records the actual
attempt where walk_attempts stopped, rather than always using
ctx.plan.attempts[-1]. Propagate that attempt through both exception-catching
call sites into log_exhausted_plan, and use it for _failure_attribution plus the
logged model and provider; preserve tail attribution only when the walker truly
reaches the final candidate.

In `@src/gateway/api/routes/messages.py`:
- Line 535: The streaming Messages request path is missing the base request
fields needed for failover. Update the streaming call near the non-streaming
request handling to pass base_request_fields=request_fields, matching the
existing non-streaming path and chat.py behavior so run_single_attempt_stream
can apply the on_failure chain.

In `@src/gateway/api/routes/models.py`:
- Around line 254-270: The ModelsPage pricing-source mapping must preserve
"dynamic" instead of collapsing it to "none" when pricing is null. Update the
model-to-PriceSource logic in ModelsPage.tsx to explicitly map or permit
model.pricing_source === "dynamic", and ensure the resulting PriceSource
rendering handles that value without assigning an alias label.

In `@src/gateway/models/routing.py`:
- Around line 133-152: Update _budget_thresholds_stay_under_the_cap to apply the
unreachable-threshold validation only when budget_used_pct uses the upward gte
or gt comparator and its value is at least 100. Allow lt and lte thresholds at
or above 100 to pass validation, while preserving the existing error and
behavior for unreachable upward thresholds.

In `@src/gateway/static/dashboard/assets/RoutingPage-DKobULws.js`:
- Line 1: The edit form in D currently rebuilds spec.select through se,
preserving only budget thresholds and the default while dropping router entries.
Update the select-state initialization and serialization around se and the $
useMemo so existing router entries remain intact when saving, while retaining
the current budget-threshold editing behavior; alternatively, prevent D from
editing policies containing router-backed select entries.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 82-91: The Status filter UI should expose an “Absorbed” option
alongside All, Success, and Error, using the existing status-filter value and
query flow so it requests status=absorbed. Update the status select
definition/rendering in ActivityPage without changing activity row styling or
other filter behavior.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 614-615: Make the adding and editing state transitions in
RoutingPage mutually exclusive: when opening a new PolicyForm, clear editing,
and when opening an existing policy for editing, clear adding. Update the
handlers used by the “New policy” action and the table’s Edit button, while
preserving the existing PolicyForm close behavior.
- Around line 201-214: Update the policy editor state and submit logic around
the PolicySpec useMemo so editing preserves all loaded spec fields, including
spec_version, limits, and select entries the form does not model. On save,
replace only the form-owned conditions/default target while carrying through
unrecognized select rules and existing metadata; ensure the Edit action for
stored policies cannot silently discard data.

---

Nitpick comments:
In `@scripts/check_architecture.py`:
- Around line 68-77: The gateway/types architecture rule’s comment claims all
gateway layers are forbidden, but its forbidden list omits gateway.models.
Update the “gateway/types” entry so the documented restriction matches
enforcement by adding gateway.models to forbidden, without changing unrelated
rules.

In `@src/gateway/api/routes/models.py`:
- Around line 298-309: Resolve effective_policies once in the policy catalog
flow and reuse that mapping for both catalog entries and _policy_target_keys.
Update the relevant helper signatures and caller, including
_policy_catalog_entries if needed, so policy listing and withheld target
derivation operate on the same snapshot.

In `@src/gateway/api/routes/routing.py`:
- Around line 238-248: Update the list_policies endpoint to accept limit and
offset query parameters using the same sane defaults and maximum bounds as other
management list routes. Apply both bounds to the RoutingPolicy query before
execution while preserving its existing name ordering and response behavior.
- Around line 371-376: Remove the unused db dependency parameter from
explain_policy and its associated AsyncSession/get_db imports only if they
become unused; retain config injection and all existing response behavior. This
makes the endpoint avoid creating a database session when explaining policies.

In `@src/gateway/services/guardrails.py`:
- Around line 206-214: Update the docstring for the function containing the
GuardrailsNotReachableError handler to describe fail-closed behavior only when
both cfg.mode and cfg.on_unavailable are "block"; revise the Failure handling
bullets and Raises section accordingly, while documenting that other
on_unavailable settings fail open.

In `@src/gateway/services/policy_store.py`:
- Around line 44-54: Add the existing all_policy_names symbol to the __all__
list in the policy store module, alongside the other exported policy helpers, so
its declared public surface matches its import from the routing module.

In `@src/gateway/services/routing/compiler.py`:
- Around line 162-164: Replace the assert in the conditional-entry branch of the
routing compiler with an explicit runtime guard that verifies entry.target is
not None before returning it. Preserve the existing condition label and return
behavior for valid targets, while failing loudly when a matching when entry
lacks a target, including when Python runs with optimizations.
- Around line 153-161: Reduce repeated WARNING logs in compile_policy for
unavailable routers: move the operator-facing warning to configuration
validation or policy loading so it is emitted once, and change the per-request
logger.warning in the entry.router branch to a debug-level message while
preserving the existing fallthrough behavior.

In `@src/gateway/static/dashboard/assets/index-BmxAZYrI.css`:
- Line 1: Add src/gateway/static/dashboard/** to the project’s .stylelintignore
configuration so generated Tailwind/HeroUI assets such as the dashboard CSS
bundle are excluded from stylelint while web/src styles continue to be checked.

In `@tests/integration/test_routing_policies.py`:
- Around line 770-784: The existing test does not cover terminal failures that
stop routing at the first candidate. Add a test alongside
test_a_failed_request_still_says_which_policy_it_went_through that mocks a 401
response, verifies one error usage row, and asserts attempt_position is 1 with
the expected model and provider, so _failure_attribution handles the candidate
that actually failed rather than the final configured attempt.

In `@tests/unit/test_attempt_walker.py`:
- Around line 48-70: Add fast-tier unit tests covering both new walker hooks:
verify on_absorbed is called only for recovered failures and skipped for the
terminal attempt, and verify build_kwargs is invoked per candidate so each
attempt’s provider/model arguments reach run_attempt after fallback.

In `@tests/unit/test_pipeline_settlement.py`:
- Around line 75-95: Extend the test helper _ctx to accept optional plan and
request_group_id parameters and pass them into RequestContext, using the
existing CompiledPlan type and appropriate default. Add fast-tier unit coverage
for failover settlement: exactly one reconcile, one refund when the plan is
exhausted, and stopping the chain when reservation top-up is refused.

In `@web/src/pages/ActivityPage.tsx`:
- Around line 879-902: Add focused cases in ActivityPage.test.tsx covering the
new Routing column and absorbed status pill: mock an unrouted usage row and
assert it has no routing text, then mock a routed row with status "absorbed" and
assert the rendered UI exposes the absorbed status and “attempt 2/2”
attribution. Use existing row-rendering helpers and query text or roles rather
than implementation details.

In `@web/src/pages/RoutingPage.test.tsx`:
- Around line 84-96: Update the DELETE branch of the routing-policy mock to read
and honor the user_id query parameter, distinguishing null/absent global scope
from any provided value, including an empty string, when filtering by policy
name and scope. Add a test covering deletion of a user-scoped policy that
renders the row, presses Delete, and verifies the request URL includes
?user_id=alice.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 21ecf7ea-8c50-4a00-9827-28882ff33829

📥 Commits

Reviewing files that changed from the base of the PR and between 4795754 and 39e1b47.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (77)
  • alembic/versions/e8b1d3f5a7c9_add_routing_policies.py
  • alembic/versions/f4c6a8b0d2e5_add_usage_logs_routing_attribution.py
  • config.example.yml
  • docs/configuration.md
  • docs/dashboard.md
  • docs/index.md
  • docs/models.md
  • docs/public/otari.postman_collection.json
  • docs/routing.md
  • otari.db-shm
  • otari.db-wal
  • scripts/check_architecture.py
  • scripts/seed_routing_demo.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/chat.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/routing.py
  • src/gateway/api/routes/usage.py
  • src/gateway/cli.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/models/guardrails.py
  • src/gateway/models/routing.py
  • src/gateway/services/budget_service.py
  • src/gateway/services/guardrails.py
  • src/gateway/services/policy_store.py
  • src/gateway/services/provider_kwargs.py
  • src/gateway/services/routing/__init__.py
  • src/gateway/services/routing/compiler.py
  • src/gateway/static/dashboard/assets/ActivityPage-ChQ9nFtF.js
  • src/gateway/static/dashboard/assets/ActivityPage-D3zW3w7G.js
  • src/gateway/static/dashboard/assets/AliasesPage-BQwHhTMQ.js
  • src/gateway/static/dashboard/assets/AliasesPage-Gb_wsTGI.js
  • src/gateway/static/dashboard/assets/BudgetsPage-DPpY7c4J.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-Ug1xmzTh.js
  • src/gateway/static/dashboard/assets/DocsPage-CFMmMs_h.js
  • src/gateway/static/dashboard/assets/KeysPage-CmiGNdVE.js
  • src/gateway/static/dashboard/assets/ModelComboBox-Bb0dsqaN.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-BSC5r_qF.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DOAM-IRn.js
  • src/gateway/static/dashboard/assets/ModelsPage-CRJ_Wl2U.js
  • src/gateway/static/dashboard/assets/ModelsPage-qNMhih0k.js
  • src/gateway/static/dashboard/assets/OverviewPage-B28JIhAq.js
  • src/gateway/static/dashboard/assets/OverviewPage-CTW9HJc-.js
  • src/gateway/static/dashboard/assets/ProvidersPage-CkydSD4T.js
  • src/gateway/static/dashboard/assets/ProvidersPage-V9eY6Y_V.js
  • src/gateway/static/dashboard/assets/RoutingPage-DKobULws.js
  • src/gateway/static/dashboard/assets/SettingsPage-0CTHb8Ij.js
  • src/gateway/static/dashboard/assets/SettingsPage-Cg7H88vI.js
  • src/gateway/static/dashboard/assets/TablePagination-ZKa66bAr.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CQmrtqXw.js
  • src/gateway/static/dashboard/assets/UsagePage-4BFMRtYx.js
  • src/gateway/static/dashboard/assets/UsersPage-CGPsxV6u.js
  • src/gateway/static/dashboard/assets/index-BmxAZYrI.css
  • src/gateway/static/dashboard/assets/index-CW8dw4O4.css
  • src/gateway/static/dashboard/assets/index-CvNLvggU.js
  • src/gateway/static/dashboard/assets/index-DGvD8Pfl.js
  • src/gateway/static/dashboard/index.html
  • src/gateway/types/attempt.py
  • src/gateway/types/budget_state.py
  • tests/integration/test_routing_policies.py
  • tests/integration/test_usage_endpoint.py
  • tests/unit/test_attempt_walker.py
  • tests/unit/test_check_architecture.py
  • tests/unit/test_pipeline_settlement.py
  • web/src/App.tsx
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/components/AppShell.tsx
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx
💤 Files with no reviewable changes (8)
  • src/gateway/static/dashboard/assets/AliasesPage-BQwHhTMQ.js
  • src/gateway/static/dashboard/assets/ModelsPage-CRJ_Wl2U.js
  • src/gateway/static/dashboard/assets/ActivityPage-ChQ9nFtF.js
  • src/gateway/static/dashboard/assets/SettingsPage-Cg7H88vI.js
  • src/gateway/static/dashboard/assets/ProvidersPage-V9eY6Y_V.js
  • src/gateway/static/dashboard/assets/OverviewPage-B28JIhAq.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DOAM-IRn.js
  • src/gateway/static/dashboard/assets/index-DGvD8Pfl.js
🛑 Comments failed to post (1)
otari.db-shm (1)

1-1: 📐 Maintainability & Code Quality | 🟠 Major | 💤 Low value

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Which SQLite artifacts are tracked, and are they ignored?
git ls-files | rg -n 'otari\.db|\.sqlite|\.db-wal|\.db-shm' || echo "no tracked sqlite artifacts"
fd -H -t f '^\.gitignore$' | while IFS= read -r f; do
  echo "== $f"; rg -n 'db|sqlite' "$f" || echo "(no db patterns)"
done

Repository: mozilla-ai/otari

Length of output: 571


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked SQLite artifacts:"
git ls-files | rg -n 'otari\.db|\.sqlite|\.db-wal|\.db-shm' || echo "no tracked sqlite artifacts"

echo
echo "Relevant .gitignore entries:"
for f in $(fd -H -t f '^\.gitignore$'); do
  echo "== $f"
  rg -n -i 'db|sqlite|\.db(-wal|-shm)?' "$f" || echo "(no matching db patterns)"
done

echo
echo "SQLite artifact files by type:"
git ls-files -z | xargs -0 file | rg 'otari\.db|\.sqlite|\.db-wal|\.db-shm' || true

Repository: mozilla-ai/otari

Length of output: 725


Remove the committed SQLite runtime artifacts from the repository.

otari.db-shm and otari.db-wal are SQLite runtime files created alongside the database. .gitignore already ignores otari.db, but not the -shm and -wal artifacts, so the next local run can add them back unless the root ignore rule is broadened for this database name. Delete both committed artifacts from the index and cover them in .gitignore; the database file itself can contain local dev state that should not be reviewed or merged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@otari.db-shm` at line 1, Remove the committed SQLite runtime artifacts
otari.db-shm and otari.db-wal from version control, and broaden the existing
root .gitignore rule for otari.db so these companion files are ignored as well.
Keep otari.db itself ignored and ensure future local runs cannot reintroduce any
of the three database files.

Review of #492 found that the headline feature did not work and that several
claims the docs made were untrue. Each fix comes with the test that would have
caught it, because every one of these was invisible to a green build and to a
request returning 200.

* **Policy guardrails were never enforced.** The compiler built the list and no
  route read it, so the schema, the API, the CLI output, the dashboard editor,
  and the docs all described enforcement that never happened. Merged in
  `prepare_gateway_tools`, which all three completion routes already call, so no
  route can forget it. Union by profile, stricter setting wins on both `mode` and
  `on_unavailable`: a caller may add a guardrail or tighten one, never weaken a
  mandate.

* **Failover only reached `/v1/chat/completions`.** `messages.py` passed the base
  request fields on its non-streaming call only and `responses.py` on neither, so
  those runners never walked the chain and `_ResponsesAdapter.local_attempt_kwargs`
  was dead code. All three endpoints now fail over, streaming and not.

* **Streamed requests recorded no attribution.** `build_streaming_response` never
  received it, so the serving row of a streamed fallover carried no
  `request_group_id` and the absorbed attempt it belonged to was an orphan, which
  is the one thing that column exists to prevent.

* **A terminal failure named the wrong candidate.** Attribution defaulted to the
  end of the plan, but a non-retryable status or a tool-loop lock-in stops the
  walk early, so a 401 on candidate 1 was logged against candidate 2 and the
  by-provider breakdown blamed a provider that was never called. The walker now
  reports which candidate it stopped on.

* **`require_pricing` was bypassable through a fallback.** The gate prices only
  the head candidate, so an unpriced model that 402s when named directly would
  serve, and log `cost=null`, by being reached as an `on_failure` entry. The gate
  now applies to every candidate.

* **`limits` was a dead knob.** The streaming first-chunk deadline it would
  override is applied solely by the hybrid walker, and policies are
  standalone-only, so the field validated, stored, and did nothing. Removed
  rather than shipped, with a note on what has to exist before it returns.

Also: `absorbed` is filterable in the activity log (it was rendered and styled
but not in the filter's options); mean latency excludes absorbed rows, so a
policy that recovers quickly no longer looks slower than one that never fails;
the dashboard hides Edit for a policy whose shape the form cannot represent,
since a lossy save is worse than no button; a policy-name error no longer talks
about aliases; and two SQLite WAL sidecars that should never have been committed
are removed and gitignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 00:28 — with GitHub Actions Inactive
@github-actions github-actions Bot removed the missing-template PR is missing required template sections label Aug 5, 2026
The review noted that `docs/routing.md` explained the object and the config block
but never mentioned `/v1/routing/policies`, so the half of the feature that does
not need a file edit or a restart was undiscoverable from its own page.

Adds a "Managing policies at runtime" section with the create, list, explain, and
delete calls, and says plainly that every verb needs the master key, `explain`
included, because its response enumerates the targets a policy exists to hide.

`docs/api-reference.md` gains sections for both routing policies and aliases.
Aliases were already missing there, which contradicted the page's claim to
document every endpoint, so both land together where a reader can compare them.
The availability matrix lists them too.

Every curl in the new sections was run verbatim against a live gateway, since a
previous alias example in these docs shipped without a Content-Type header and
would have answered 422.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 00:34 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (2)
docs/public/otari.postman_collection.json (1)

1958-2083: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Add master-key auth credentials to the policy management requests.

The routes are master-key gated, but PolicyRequest field validation is not affected by the auth mode, and Delete Policy already preserves the user_id query scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/public/otari.postman_collection.json` around lines 1958 - 2083, Add the
collection’s established master-key authentication credentials to the List
Policies, Set Policy, Explain Policy, and Delete Policy requests, ensuring each
request can access the master-key-gated routes. Preserve the existing request
bodies, methods, paths, and Delete Policy user_id query scope.
web/src/pages/RoutingPage.test.tsx (1)

109-115: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Run the link assertion with HashRouter.

MemoryRouter renders this Link as /tools; the deployed dashboard uses HashRouter, so this route should expose #/tools. Update the assertion to use HashRouter, or add a hash-router render helper, so this test matches the deployed routing contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` around lines 109 - 115, Update the
renderPage helper in RoutingPage.test.tsx to use HashRouter instead of
MemoryRouter, then run the link assertion against the hash-based route so it
verifies the deployed contract and expects `#/tools`.

Source: Coding guidelines

🧹 Nitpick comments (4)
tests/unit/test_attempt_walker.py (1)

373-383: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for the operator-owned guardrail URL.

The merge now copies the mandated GuardrailConfig and only updates mode and on_unavailable, but the guardrail URL ownership case is only documented. Add a test with different url values for the mandated and caller entries and assert the mandated URL is preserved.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_attempt_walker.py` around lines 373 - 383, The test
test_a_caller_cannot_weaken_a_mandated_guardrail should also cover guardrail URL
ownership: give the mandated and caller _guardrail entries different url values,
then assert merged[0].url retains the mandated URL while the existing mode and
on_unavailable assertions remain unchanged.
web/src/pages/RoutingPage.test.tsx (3)

144-151: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that config-defined policies hide both controls.

The test title says the UI suppresses Edit and Delete, but it checks only Delete. A regression that exposes a lossy Edit action would pass. Add a negative assertion for the Edit button.

Suggested assertion
    expect(within(autoRow).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
+   expect(within(autoRow).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` around lines 144 - 151, Update the test
case “does not offer to edit or delete a policy that lives in config.yml” to
assert that the config-defined policy row also lacks the Edit button, alongside
the existing Delete assertion. Use the same autoRow and role-based query pattern
to verify both controls are hidden.

51-58: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Mock the apiFetch boundary instead of globalThis.fetch.

This test currently exercises the real apiFetch implementation and intercepts the lower-level transport. That couples page tests to request serialization and transport details. Mock the exported apiFetch function while keeping the real hooks and providers.

As per coding guidelines, mock only the apiFetch network boundary, not globalThis.fetch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` around lines 51 - 58, Update the mockApi
setup in RoutingPage.test.tsx to mock the exported apiFetch function rather than
spying on globalThis.fetch. Keep the existing policy and guardrail response
behavior and call tracking at the apiFetch boundary, while leaving the real
hooks and providers unchanged.

Source: Coding guidelines


104-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fail on unexpected API requests.

The fallback returns 200 [] for every unmodeled URL. A hook can call a wrong or new endpoint and these tests can still pass with empty data. Throw for unknown routes, then add explicit responses only when a test needs them.

Suggested strict fallback
-    return jsonResponse([]);
+    throw new Error(`Unexpected API request: ${method} ${url}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.test.tsx` at line 104, Update the request-mocking
fallback in the RoutingPage tests to throw for any unmodeled URL instead of
returning jsonResponse([]). Add explicit route responses for each endpoint
required by existing tests, preserving their expected behavior while ensuring
unexpected API requests fail immediately.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/gateway/static/dashboard/assets/OverviewPage-CTPTo-KN.js`:
- Line 1: Update OverviewIndex and Rt so the stored-provider query error is
propagated into the Overview error state: pass the query error alongside
needsSetup or include it in Rt’s aggregated et value. Ensure a provider-query
failure is surfaced by the existing V error display and does not incorrectly
trigger the setup state.

In `@src/gateway/static/dashboard/assets/ProvidersPage-dKyb-Stc.js`:
- Line 1: Update the tutorial link rendered by Ce to use the hash router,
replacing the raw href="/welcome" navigation with the router Link component
targeting "/welcome" or an equivalent "/#/welcome" URL. Keep the existing link
styling and label unchanged, then rebuild the generated dashboard asset.

In `@src/gateway/static/dashboard/assets/SettingsPage-ByleVDJK.js`:
- Line 1: Update the dialog close handler m in X so every close path clears the
generated master-key state n and resets the regeneration mutation, including
closes after successful generation. Ensure reopening the dialog cannot reuse a
previously generated key, while preserving the existing open behavior and state
reset.

---

Outside diff comments:
In `@docs/public/otari.postman_collection.json`:
- Around line 1958-2083: Add the collection’s established master-key
authentication credentials to the List Policies, Set Policy, Explain Policy, and
Delete Policy requests, ensuring each request can access the master-key-gated
routes. Preserve the existing request bodies, methods, paths, and Delete Policy
user_id query scope.

In `@web/src/pages/RoutingPage.test.tsx`:
- Around line 109-115: Update the renderPage helper in RoutingPage.test.tsx to
use HashRouter instead of MemoryRouter, then run the link assertion against the
hash-based route so it verifies the deployed contract and expects `#/tools`.

---

Nitpick comments:
In `@tests/unit/test_attempt_walker.py`:
- Around line 373-383: The test test_a_caller_cannot_weaken_a_mandated_guardrail
should also cover guardrail URL ownership: give the mandated and caller
_guardrail entries different url values, then assert merged[0].url retains the
mandated URL while the existing mode and on_unavailable assertions remain
unchanged.

In `@web/src/pages/RoutingPage.test.tsx`:
- Around line 144-151: Update the test case “does not offer to edit or delete a
policy that lives in config.yml” to assert that the config-defined policy row
also lacks the Edit button, alongside the existing Delete assertion. Use the
same autoRow and role-based query pattern to verify both controls are hidden.
- Around line 51-58: Update the mockApi setup in RoutingPage.test.tsx to mock
the exported apiFetch function rather than spying on globalThis.fetch. Keep the
existing policy and guardrail response behavior and call tracking at the
apiFetch boundary, while leaving the real hooks and providers unchanged.
- Line 104: Update the request-mocking fallback in the RoutingPage tests to
throw for any unmodeled URL instead of returning jsonResponse([]). Add explicit
route responses for each endpoint required by existing tests, preserving their
expected behavior while ensuring unexpected API requests fail immediately.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a6b51f67-cb66-42df-b397-f2950cf007c6

📥 Commits

Reviewing files that changed from the base of the PR and between 39e1b47 and 84d62d3.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (36)
  • .gitignore
  • config.example.yml
  • docs/public/otari.postman_collection.json
  • docs/routing.md
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/routing.py
  • src/gateway/api/routes/usage.py
  • src/gateway/models/routing.py
  • src/gateway/static/dashboard/assets/ActivityPage-C4pUjPLK.js
  • src/gateway/static/dashboard/assets/AliasesPage-SLdCGrYC.js
  • src/gateway/static/dashboard/assets/BudgetsPage-Dob3Jbrv.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-BI7FL6ES.js
  • src/gateway/static/dashboard/assets/DocsPage-Dt20I1DV.js
  • src/gateway/static/dashboard/assets/KeysPage-BsMi6pWI.js
  • src/gateway/static/dashboard/assets/ModelComboBox-BhChLCAf.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-0f1Cnnou.js
  • src/gateway/static/dashboard/assets/ModelsPage-KERPudvl.js
  • src/gateway/static/dashboard/assets/OverviewPage-CTPTo-KN.js
  • src/gateway/static/dashboard/assets/ProvidersPage-dKyb-Stc.js
  • src/gateway/static/dashboard/assets/RoutingPage-ByMeUo5k.js
  • src/gateway/static/dashboard/assets/SettingsPage-ByleVDJK.js
  • src/gateway/static/dashboard/assets/TablePagination-DCrGjxFF.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-CIrs_9py.js
  • src/gateway/static/dashboard/assets/UsagePage-CDQ43iIU.js
  • src/gateway/static/dashboard/assets/UsersPage-BAdJkiiM.js
  • src/gateway/static/dashboard/assets/index-BvuEgn2W.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_routing_policies.py
  • tests/unit/test_attempt_walker.py
  • web/src/api/types.ts
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx
💤 Files with no reviewable changes (2)
  • web/src/api/types.ts
  • config.example.yml
🚧 Files skipped from review as they are similar to previous changes (9)
  • src/gateway/static/dashboard/index.html
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/messages.py
  • web/src/pages/ActivityPage.tsx
  • web/src/pages/RoutingPage.tsx
  • docs/routing.md
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/routing.py
  • src/gateway/api/routes/usage.py

Comment thread src/gateway/static/dashboard/assets/OverviewPage-CTPTo-KN.js Outdated
Comment thread src/gateway/static/dashboard/assets/ProvidersPage-dKyb-Stc.js Outdated
Comment thread src/gateway/static/dashboard/assets/SettingsPage-ByleVDJK.js Outdated
…ones

A policy is the general form of an alias, so two tables, two endpoints, and two
dashboard pages for one concept was one too many of each.

Migration `b5d7f9a1c3e6` moves every `model_aliases` row into `routing_policies`
as `{select: [{default: <target>}]}`, carrying scope and timestamps across. Rows
are moved rather than copied: leaving them behind would put the same name in both
stores, and alias resolution runs first, so the stale alias would win and silently
shadow every later edit made through the policy API. The downgrade moves back
every policy an alias can represent and leaves the rest in place rather than
flattening them, so it is reversible in both directions.

The Aliases page is deleted. Routing lists whatever aliases remain (the ones in
`config.yml`, plus any created through `/v1/aliases`, which stays as the
one-target API) tagged `alias`, and routes their edits and deletes to the alias
endpoint, because they are still rows in `model_aliases` and writing them back as
policies would leave the originals behind. `/#/aliases` redirects to `/#/routing`
so bookmarks keep working, and the Models page's "Make an alias" link now opens
Routing with the target prefilled.

Growing an alias a chain, a condition, or a guardrail is refused in the form with
an explanation rather than attempted: an alias holds one target, and saving it as
a policy under the same name is the collision the API already rejects.

Also closes the reverse footgun the split allowed: creating an alias named after
an existing policy now 400s. Alias resolution runs first, so it would otherwise
have silently stopped that policy taking effect.

Verified in a browser: the tab is gone, `/#/aliases` lands on `/#/routing`, and a
policy and an alias appear side by side with the alias tagged and deletable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 01:33 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
web/src/pages/RoutingPage.tsx (1)

78-86: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve every supported policy field on save.

isEditableInForm accepts a policy with limits or spec_version, but spec omits both fields. Saving the form therefore submits a truncated policy document and can remove policy limits.

Preserve these fields in spec, or mark such policies read-only before offering Edit. Add coverage for saving a policy with limits and an explicit spec_version.

Proposed preservation of non-form metadata
   const spec: PolicySpec = useMemo(
     () => ({
+      ...(existing?.spec.spec_version === undefined ? {} : { spec_version: existing.spec.spec_version }),
+      ...(existing?.spec.limits === undefined ? {} : { limits: existing.spec.limits }),
       select: [
         ...conditions.map((condition) => ({
           when: { budget_used_pct: { gte: condition.threshold } },
           target: condition.target.trim(),
         })),
         { default: target.trim() },
       ],
       ...(chain.length > 0 ? { on_failure: chain.map((entry) => entry.trim()) } : {}),
       ...(guardrails.length > 0 ? { guardrails } : {}),
     }),
-    [conditions, target, chain, guardrails],
+    [existing, conditions, target, chain, guardrails],
   );

Also applies to: 254-265

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/RoutingPage.tsx` around lines 78 - 86, Update the policy
edit/save flow around isEditableInForm and the spec construction to preserve
supported non-form metadata, specifically limits and an explicit spec_version,
instead of submitting a truncated policy. Ensure policies containing these
fields remain editable only if saving retains them, otherwise mark them
read-only before showing Edit. Add coverage for saving policies with limits and
an explicit spec_version.
🧹 Nitpick comments (1)
web/src/pages/ModelsPage.tsx (1)

1590-1590: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for the Models-to-Routing deep link.

web/src/pages/ModelsPage.test.tsx does not click “Make an alias” or assert the selected model carries target to /routing. Since this changed frontend behavior needs colocated Vitest coverage, add that regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/pages/ModelsPage.tsx` at line 1590, Add a colocated Vitest regression
test in ModelsPage.test.tsx that clicks “Make an alias” and verifies navigation
to /routing includes the selected model’s target query parameter. Reuse the
existing ModelsPage test setup and navigation mock/assertion patterns, covering
the onMakeAlias behavior in ModelsPage.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@alembic/versions/b5d7f9a1c3e6_move_stored_aliases_into_routing_policies.py`:
- Around line 63-87: Update both upgrade() and downgrade() collision handling so
same-scope alias/policy name conflicts abort the migration instead of silently
skipping one representation and deleting it. Allow continuation only when the
alias target and policy spec are proven equivalent, reusing _spec_for where
applicable; otherwise raise an explicit migration error requiring manual
resolution while preserving reversible downgrade behavior.
- Around line 104-135: Track provenance for each policy migrated by upgrade(),
recording which routing policy produced each alias. Update downgrade() to
convert or delete only policies present in that alias-origin mapping, leaving
pre-existing or independently created policies intact. Ensure policies with
additional fields such as limits are preserved or explicitly rejected rather
than converted lossy, while maintaining a reversible migration.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 707-710: Ensure the policy editor states are mutually exclusive in
the handlers that open each editor: clear editing when starting a new policy,
and clear adding when selecting an existing policy for editing. Keep the
conditional rendering around PolicyForm unchanged so only one instance can be
open at a time.

---

Outside diff comments:
In `@web/src/pages/RoutingPage.tsx`:
- Around line 78-86: Update the policy edit/save flow around isEditableInForm
and the spec construction to preserve supported non-form metadata, specifically
limits and an explicit spec_version, instead of submitting a truncated policy.
Ensure policies containing these fields remain editable only if saving retains
them, otherwise mark them read-only before showing Edit. Add coverage for saving
policies with limits and an explicit spec_version.

---

Nitpick comments:
In `@web/src/pages/ModelsPage.tsx`:
- Line 1590: Add a colocated Vitest regression test in ModelsPage.test.tsx that
clicks “Make an alias” and verifies navigation to /routing includes the selected
model’s target query parameter. Reuse the existing ModelsPage test setup and
navigation mock/assertion patterns, covering the onMakeAlias behavior in
ModelsPage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b4bf820c-33cb-4ae0-b78a-7fc38c4f5406

📥 Commits

Reviewing files that changed from the base of the PR and between 84d62d3 and 2554b7d.

📒 Files selected for processing (31)
  • alembic/versions/b5d7f9a1c3e6_move_stored_aliases_into_routing_policies.py
  • docs/api-reference.md
  • docs/dashboard.md
  • docs/routing.md
  • src/gateway/api/routes/aliases.py
  • src/gateway/static/dashboard/assets/ActivityPage-DvM1Qq47.js
  • src/gateway/static/dashboard/assets/BudgetsPage-BwbBPut6.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-BMAUR-Vt.js
  • src/gateway/static/dashboard/assets/DocsPage-BoOWI6AJ.js
  • src/gateway/static/dashboard/assets/KeysPage-CJtqAEHt.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-BoQJEWO0.js
  • src/gateway/static/dashboard/assets/ModelsPage-DoBSThuy.js
  • src/gateway/static/dashboard/assets/OverviewPage-BdYEUAon.js
  • src/gateway/static/dashboard/assets/ProvidersPage-BWtXuXHa.js
  • src/gateway/static/dashboard/assets/RoutingPage-D6RB-Kzs.js
  • src/gateway/static/dashboard/assets/SettingsPage-CxHdvm0U.js
  • src/gateway/static/dashboard/assets/TablePagination-CeoyZYzl.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-DCkaJoq3.js
  • src/gateway/static/dashboard/assets/UsagePage-BHBeFxGf.js
  • src/gateway/static/dashboard/assets/UsersPage-BpaQskrG.js
  • src/gateway/static/dashboard/assets/index-BNcwLMM7.js
  • src/gateway/static/dashboard/assets/index-Bz4ocl69.css
  • src/gateway/static/dashboard/index.html
  • web/AGENTS.md
  • web/src/App.tsx
  • web/src/components/AppShell.tsx
  • web/src/pages/AliasesPage.test.tsx
  • web/src/pages/AliasesPage.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx
💤 Files with no reviewable changes (3)
  • web/src/pages/AliasesPage.tsx
  • web/src/pages/AliasesPage.test.tsx
  • web/src/components/AppShell.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • web/src/App.tsx
  • docs/dashboard.md
  • src/gateway/static/dashboard/index.html
  • docs/routing.md

Comment thread web/src/pages/RoutingPage.tsx
The Playwright suite navigated to the deleted Aliases page, so removing the tab
broke it. It is a hand-maintained list of nav items outside the diff, which is
the class of thing a code change silently invalidates, and I did not grep for it.

The nav walk now visits Routing, "create an alias" becomes "create a routing
policy", and a second case covers growing one a fallback chain, which also pins
that the chain section stays hidden until asked for.

The combobox is matched on a substring rather than an anchored name, matching the
alias test this replaces: the accessible name carries the required marker, so an
anchored match never hits. Role-scoping is what separates the "Serves" field from
the "Serves" column header.

Run locally this time (8 passed) rather than left to CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 01:40 — with GitHub Actions Inactive

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@web/e2e/dashboard.spec.ts`:
- Around line 118-126: Update the dashboard tests around the created policy rows
so assertions for the model and fallback are scoped within the specific “fast”
and “chained” row containers rather than matching page-wide elements. Add test
isolation by resetting or deleting existing routing-policy state between runs,
ensuring stale policies cannot satisfy these assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ff01a29-33fc-4bcb-afe3-3decaedeb0c6

📥 Commits

Reviewing files that changed from the base of the PR and between 2554b7d and 47300a0.

📒 Files selected for processing (1)
  • web/e2e/dashboard.spec.ts

Comment thread web/e2e/dashboard.spec.ts

Copilot AI 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.

Pull request overview

Adds first-class named routing policies to otari (standalone mode) so a caller-provided model name can drive conditional selection, retryable-failure failover, and enforced guardrails, while also persisting routing attribution to usage logs and surfacing it in the dashboard and APIs.

Changes:

  • Introduces routing policy schema, compilation (pure compiler), and a local multi-attempt executor (walk_attempts) plus supporting shared types.
  • Adds stored policy cache + refresher, config validation, CLI explain command, and new DB tables/migrations for routing policies and usage attribution.
  • Updates dashboard navigation/pages (Aliases → Routing), usage/activity UI to represent absorbed attempts, docs, Postman collection, and rebuilds the committed dashboard bundle.

Reviewed changes

Copilot reviewed 79 out of 83 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
web/src/pages/ModelsPage.tsx Routes “Make alias” action to the new Routing page.
web/src/pages/AliasesPage.tsx Removes the old Aliases page implementation.
web/src/pages/ActivityPage.tsx Adds absorbed-row styling and a Routing attribution column.
web/src/components/AppShell.tsx Sidebar nav: Aliases → Routing (icon + path).
web/src/App.tsx Adds /routing route and redirects legacy /aliases to /routing.
web/src/api/types.ts Adds routing policy request/response types + usage attribution fields.
web/src/api/hooks.ts Adds TanStack Query hooks for routing policy CRUD + explain.
web/e2e/dashboard.spec.ts Updates E2E flow to create policies and add fallback chains.
web/AGENTS.md Updates dashboard docs to reflect Routing page absorbing Aliases.
tests/unit/test_pipeline_settlement.py Adds characterization tests for non-streaming settlement behavior.
tests/unit/test_check_architecture.py Adds architecture rule coverage for shared gateway/types leaf layer.
tests/integration/test_usage_endpoint.py Extends expected usage row shape with routing attribution nulls.
src/gateway/types/budget_state.py Adds leaf BudgetState type for routing budget conditions.
src/gateway/types/attempt.py Adds leaf Attempt type shared by services and API execution.
src/gateway/static/dashboard/index.html Updates bundle asset hashes after dashboard rebuild.
src/gateway/static/dashboard/assets/TablePagination-CeoyZYzl.js Rebuilt dashboard asset.
src/gateway/static/dashboard/assets/OverviewPage-BdYEUAon.js Rebuilt/new dashboard asset.
src/gateway/static/dashboard/assets/OverviewPage-B28JIhAq.js Removed old dashboard asset after rebuild.
src/gateway/static/dashboard/assets/ModelScopeControl-DOAM-IRn.js Removed old dashboard asset after rebuild.
src/gateway/static/dashboard/assets/ModelScopeControl-BoQJEWO0.js Rebuilt/new dashboard asset.
src/gateway/static/dashboard/assets/ConfirmDialog-BMAUR-Vt.js Rebuilt dashboard asset.
src/gateway/static/dashboard/assets/AliasesPage-BQwHhTMQ.js Removed old Aliases bundle after page removal.
src/gateway/services/routing/compiler.py New compiler: PolicySpec → ordered Attempt plan + guardrails + drops.
src/gateway/services/routing/init.py Exposes routing compiler API from gateway.services.routing.
src/gateway/services/provider_kwargs.py Resolves static policy targets like aliases for non-completion surfaces.
src/gateway/services/policy_store.py Adds stored routing policy cache + TTL refresher + startup loader.
src/gateway/services/guardrails.py Adds on_unavailable behavior when guardrails service is unreachable.
src/gateway/services/budget_service.py Adds get_budget_state() for policy budget conditions.
src/gateway/models/routing.py New routing policy schema (extra=forbid) + validation invariants.
src/gateway/models/guardrails.py Adds on_unavailable to GuardrailConfig.
src/gateway/models/entities.py Adds routing_policies entity + usage log attribution columns.
src/gateway/main.py Loads policies at startup and runs policy refresher task in standalone mode.
src/gateway/core/config.py Adds routing config block + startup validation for routing policies.
src/gateway/cli.py Adds otari routing explain command for offline compilation inspection.
src/gateway/api/routes/usage.py Persists/exposes routing attribution; counts requests excluding absorbed rows.
src/gateway/api/routes/responses.py Adds local attempt kwargs wiring for multi-attempt execution.
src/gateway/api/routes/models.py Lists routing policies in /v1/models (dynamic policies as pricing_source=dynamic).
src/gateway/api/routes/messages.py Adds local attempt kwargs wiring for multi-attempt execution.
src/gateway/api/routes/chat.py Adds local attempt kwargs wiring for multi-attempt execution.
src/gateway/api/routes/aliases.py Adds validation to prevent alias names shadowing routing policy names.
src/gateway/api/routes/_attempts.py New local multi-attempt walker with absorbed/terminal hooks and retry classification.
src/gateway/api/main.py Registers the new routing router.
scripts/seed_routing_demo.py Adds demo seeding script for policies + traffic generation.
scripts/check_architecture.py Adds new layer rule for shared leaf types under gateway/types.
docs/routing.md New routing policies documentation.
docs/public/otari.postman_collection.json Adds routing endpoints + updates usage status filter docs.
docs/models.md Cross-links aliases to routing policies; fixes missing Content-Type in curl examples.
docs/index.md Adds routing policies doc link.
docs/dashboard.md Updates dashboard guide for Routing page + absorbed semantics.
docs/configuration.md Adds routing to config reference and env override notes.
docs/api-reference.md Adds routing policy endpoints to management API reference.
config.example.yml Adds example routing: block with commentary.
alembic/versions/f4c6a8b0d2e5_add_usage_logs_routing_attribution.py Adds usage_logs routing attribution columns + indexes.
alembic/versions/e8b1d3f5a7c9_add_routing_policies.py Adds routing_policies table + indexes/constraints.
alembic/versions/b5d7f9a1c3e6_move_stored_aliases_into_routing_policies.py Migrates stored aliases from model_aliases → routing_policies.
.gitignore Ignores SQLite WAL sidecars.

Comment thread src/gateway/services/routing/compiler.py
Comment thread src/gateway/services/routing/compiler.py Outdated
Comment on lines +102 to +106
if name in all_policy_names(config):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"'{name}' is already a routing policy. An alias resolves before a policy, so this would "

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note: this reply was drafted by Claude via back-and-forth with @njbrake. The reasoning and decisions are his; the prose is Claude's.

Right on both halves, and this is the sharpest framing of it: the migration drained the second store rather than retiring it, and POST /v1/aliases refills it.

Doing the second of your two options here, and the first as a follow-up decision:

  • docs/routing.md now carries the break as an explicit callout: for a pre-upgrade alias, GET stops listing it, DELETE 404s, and re-POST 400s. It also notes the dashboard is unaffected because it reads both stores, and that a code-only rollback leaves the moved rows unreadable.
  • Decide whether /v1/aliases should write routing policies instead of model_aliases #503 tracks whether /v1/aliases should be rerouted at routing_policies, deprecated, or documented as a permanent second store, with the tradeoffs written out. Recommendation there is deprecate then reroute, so an existing endpoint does not silently change what it writes mid-release.

Worth noting for anyone reading this thread: the two write paths already refuse each other's names (aliases.py rejects a name that is a policy, and the policy route rejects a name that is an alias), so the stores cannot shadow each other today. The cost is conceptual plus the upgrade break, not a live resolution bug.

Comment thread web/src/pages/ActivityPage.tsx Outdated

@khaledosman khaledosman left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the full diff (backend, migrations, dashboard, docs). The routing design holds up and the usage-accounting side is unusually careful: _row_status, _request_count_expr and _breakdown's residual_requests all agree, so the other row still reconciles with the tiles once a request can write more than one row. That is the part of an "extra rows per request" change that normally breaks silently, and it didn't.

One confirmed correctness bug (attribution on a refused reservation top-up), one wrong status/message pairing, and a test gap on the only code path that stops a fallover from overshooting a budget cap. Details inline, most substantive first.

Also worth adding to the PR description and the release notes: migration b5d7f9a1c3e6 is a breaking change to /v1/aliases for existing rows. It is documented in docs/routing.md:16, but the PR body doesn't mention it at all.

🤖 Generated with Claude Code

Comment thread src/gateway/api/routes/_pipeline.py
Comment thread src/gateway/services/routing/compiler.py Outdated
Comment thread tests/integration/test_routing_policies.py
Comment thread src/gateway/api/routes/usage.py Outdated
Comment thread src/gateway/api/routes/models.py
Comment thread src/gateway/cli.py Outdated
Comment thread src/gateway/api/routes/_attempts.py Outdated
…tempts

Four defects from review.

A gateway-side refusal for a candidate (a refused reservation top-up, an
unpriced fallback under `require_pricing`) and the tool-iteration cap both
raised out of the attempt walker without reporting which candidate they
happened on, so the exhausted-plan log fell back to the last attempt in the
plan and blamed a provider that was never called.

The `budget_used_pct` reachability check rejected any threshold at 100 or
above, including `lt`/`lte`. "Still under the cap" is a usable rule, and the
check runs on load, so a stored policy using it failed startup.

The Overview page collapsed every non-error status to "ok". That preview is
unfiltered, so an attempt a policy recovered from read as a served request.

The Routing page kept the table mounted while the create form was open, so
Edit stayed reachable from it and two forms could stack with neither closing
except on cancel.

Also corrects stale text: a `limits` field the policy spec never had, the
`models.md` pointer to the deleted Aliases page, and the `gte`/`gt` wording
in config.example.yml. Adds a guardrails doc section on fail-closed behavior.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 10:56 — with GitHub Actions Inactive
@coderabbitai
coderabbitai Bot requested a review from tbille August 5, 2026 10:58

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
tests/integration/test_routing_policies.py (1)

670-687: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add regression coverage for lte: 100.

tests/integration/test_routing_policies.py now covers the gte: 100 unreachable branch and lt: 100 acceptance, but no test covers lte: 100; the PR objective includes lte as a usable boundary. Add a small policy creation test for {"lte": 100} so future changes cannot regress this case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_routing_policies.py` around lines 670 - 687, Add a
regression test alongside test_still_under_the_cap_is_a_usable_threshold that
creates a routing policy using the budget_used_pct condition {"lte": 100} and
asserts the policy creation succeeds with HTTP 200. Reuse the existing client,
HEADERS, policy structure, and target values, changing only the operator needed
to cover the lte boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/integration/test_routing_policies.py`:
- Around line 670-687: Add a regression test alongside
test_still_under_the_cap_is_a_usable_threshold that creates a routing policy
using the budget_used_pct condition {"lte": 100} and asserts the policy creation
succeeds with HTTP 200. Reuse the existing client, HEADERS, policy structure,
and target values, changing only the operator needed to cover the lte boundary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f204ae6-427d-4588-9712-0d832fd739fe

📥 Commits

Reviewing files that changed from the base of the PR and between 47300a0 and cf13d18.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (32)
  • config.example.yml
  • docs/guardrails.md
  • docs/models.md
  • docs/routing.md
  • scripts/seed_routing_demo.py
  • src/gateway/api/routes/_attempts.py
  • src/gateway/api/routes/_helpers.py
  • src/gateway/api/routes/routing.py
  • src/gateway/models/entities.py
  • src/gateway/models/routing.py
  • src/gateway/services/guardrails.py
  • src/gateway/static/dashboard/assets/ActivityPage-DFRQg69h.js
  • src/gateway/static/dashboard/assets/BudgetsPage-C89NkGTn.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-DW0UT0p7.js
  • src/gateway/static/dashboard/assets/DocsPage-DcNuuBLg.js
  • src/gateway/static/dashboard/assets/KeysPage-x5gg-uxE.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-9Jv41cUf.js
  • src/gateway/static/dashboard/assets/ModelsPage-Cz-0AzRW.js
  • src/gateway/static/dashboard/assets/OverviewPage-D-7Ey9G1.js
  • src/gateway/static/dashboard/assets/ProvidersPage-p62THQ55.js
  • src/gateway/static/dashboard/assets/RoutingPage-BYyTue4O.js
  • src/gateway/static/dashboard/assets/SettingsPage-Bg4TDn9K.js
  • src/gateway/static/dashboard/assets/TablePagination-BqqBwgF3.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-BjHVZO3Y.js
  • src/gateway/static/dashboard/assets/UsagePage-D3-FUt4w.js
  • src/gateway/static/dashboard/assets/UsersPage-BQKXSoJI.js
  • src/gateway/static/dashboard/assets/index-C0Mahcvr.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_routing_policies.py
  • tests/unit/test_attempt_walker.py
  • web/src/pages/OverviewPage.tsx
  • web/src/pages/RoutingPage.tsx
🚧 Files skipped from review as they are similar to previous changes (10)
  • src/gateway/static/dashboard/index.html
  • config.example.yml
  • src/gateway/services/guardrails.py
  • docs/models.md
  • web/src/pages/RoutingPage.tsx
  • src/gateway/models/routing.py
  • src/gateway/api/routes/_attempts.py
  • docs/routing.md
  • src/gateway/models/entities.py
  • src/gateway/api/routes/routing.py

Four fixes from the CodeRabbit pass on #492.

A cancelled request no longer becomes a provider failure. The broad
`BaseException` catch in the attempt walker exists so a provider client
raising outside the `Exception` hierarchy still falls through to the next
candidate, but it also caught `asyncio.CancelledError` from a disconnected
caller: that recorded an abandoned attempt against a provider which had
answered fine, and folded the cancellation into a 502 the server was waiting
to unwind. Cancellation is now re-raised ahead of the catch.

The alias move refuses a name that exists in both stores instead of picking
a winner. Alias resolution runs first today, so completing the move would
have deleted the alias and quietly handed the name to a policy that may
serve a different model. The downgrade had the sharper version of the same
bug: on a clash it deleted the policy without writing the alias, dropping
the target entirely. Both directions now abort naming the conflict, which is
a database that already needs a human.

The routing docs claimed a budget condition never matches for the master
key. It does: a master-key request has to name the billed user, and
conditions are evaluated against that user's budget, so a master-key request
can take a tier-down branch.

The e2e fallback assertion was page-wide, so a `chained` policy saved
without its fallback would still have passed. It is scoped to its own row.

Adds tests/unit/test_alias_to_policy_migration.py, which drives the
migration against SQLite so the data behavior (move, scope, timestamps,
both clash refusals, and the chained-policy downgrade skip) is pinned
without a Postgres fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 12:02 — with GitHub Actions Inactive
Addresses the review findings from @khaledosman and Copilot on #492.

`/v1/usage/summary?status=absorbed` reported `request_count: 0` beside
non-zero cost and tokens. `request_count` excludes absorbed rows so a
recovered chain counts as one request; filtering *to* absorbed made every row
in scope an excluded one. Filtering to the attempts makes them the unit being
asked about, so those queries count rows. The Activity page offers that
filter, so an operator arriving at Usage through it saw tiles that looked
broken.

An empty compiled plan no longer blames model access for every cause.
`NoEligibleCandidatesError` derived its message from nothing and its status
from a hardcoded 403, so an operator who had deleted a provider instance was
told to audit their allow-lists. The status is now derived: 403 when access
rules filtered the candidates, 502 when none of them resolve to a configured
provider, with wording to match. Targets stay off the caller-facing string.

An unavailable router warns once per policy and router rather than once per
request. The condition is static config compiled on every request through the
policy, so the unconditional warning was a log line per request forever. The
message now also names the policy.

The Activity routing column no longer renders `attempt 1/2 · ` with a
dangling separator when a row carries a position but no selection reason.

`otari routing explain` says that it reads config only, so an operator whose
policies were all created through the dashboard is pointed at
`POST /v1/routing/policies/explain` instead of being told, on a gateway with
several policies, that none are configured.

Documents two things the code did without saying so: the migration is a
breaking change for `/v1/aliases` callers (pre-existing rows stop listing,
`DELETE` 404s, re-`POST` 400s), and a routing policy withholds every one of
its targets from `GET /v1/models`, so a directly callable model can vanish
from the catalogue because an unrelated policy lists it as a fallback. The
downgrade docstring no longer implies a multi-target policy survives a full
downgrade; the next revision drops the table either way.

Adds tests/unit/test_pipeline_failover_topup.py, covering the mid-failover
reservation top-up that had no test at all despite being the only thing
stopping a fallover to a pricier candidate from spending past an approved
cap, and tests/unit/test_routing_compiler.py for the empty-plan statuses and
the warn-once behavior.

Readability: the absorbed-attempt guard reads `attempt.position <
len(attempts)` rather than an identity comparison against the tail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 5, 2026 12:33 — with GitHub Actions Inactive
@njbrake
njbrake merged commit e4ed919 into main Aug 5, 2026
11 checks passed
@njbrake
njbrake deleted the alias branch August 5, 2026 12:40
njbrake added a commit that referenced this pull request Aug 6, 2026
A disconnected caller raises asyncio.CancelledError, which derives from
BaseException, so the broad catch meant to let non-Exception provider
clients fall through to the next candidate instead classified the
cancellation as an "unknown" upstream error, recorded an abandoned
attempt against a provider that answered fine, and converted it into a
provider-failure HTTP exception that suppressed the cancellation.

Guard the broad catch with a re-raise, mirroring the standalone walker
fix in #492. Add a unit test asserting the cancellation propagates and
no abandoned-attempt row is recorded.

Fixes #501
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.

Feature: advanced alias features (routing, fallbacks, budget based fallbacks, etc)

3 participants