Skip to content

feat(routing): rename policies in place, and stop them hiding candidate prices - #560

Merged
njbrake merged 2 commits into
mozilla-ai:mainfrom
pocketsizesun:feat/routing-policy-rename
Aug 12, 2026
Merged

feat(routing): rename policies in place, and stop them hiding candidate prices#560
njbrake merged 2 commits into
mozilla-ai:mainfrom
pocketsizesun:feat/routing-policy-rename

Conversation

@pss-julien

@pss-julien pss-julien commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

Two changes to the routing surface that were in flight together.

Rename: POST /v1/routing/policies takes rename_from, so an edit that both
renames a policy and re-targets it cannot land half applied, leaving the old
name serving the new spec. The new name is validated exactly as a fresh one is,
because a rename can walk a policy into every collision a create can. The
dashboard's Edit form exposes it, and the row moves rather than being copied.

Pricing: a policy no longer withholds its candidates from GET /v1/models. Every
selector of every policy in force was hidden, on_failure chains included, so
one failover policy could empty most of a catalogue, and a candidate priced by
the genai-prices fallback then disappeared from the dashboard together with its
rate. GET /v1/models/{key} never withheld them, so nothing was really being kept
off the wire. Alias targets are still withheld: an alias exists to stand in for
a target, a policy does not.

POST /v1/pricing now refuses a policy name the way it already refuses an alias
name, naming the candidates to price instead. Pricing, budgets, and usage key on
the model a request resolves to, so a row stored under the policy name was
written and never read.

The dashboard rebuilds rows the catalogue withheld from the discovery endpoint.
Those now report "rate unknown" instead of "not priced" when default pricing is
on, since the gateway does meter such a model; with the fallback off, unpriced
remains the truth.

PR Type

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

Relevant issues

None.

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).

make lint, make typecheck and the full pytest tests/unit tests/integration run are green (2718 passed, 10 skipped). The API contract changed, so docs/public/openapi.json and the Postman collection were both regenerated; make openapi-check and make postman-check pass. Dashboard side: tsc -b --noEmit clean and the full Vitest suite green (585 passed), plus an e2e spec for the rename flow.

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:

The change was written and validated with Claude Code. It was previously part of a
longer branch of mine; that branch has been rebased onto current main and split so
each PR carries one self-contained change, reviewable on its own.

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

Summary

  • Added in-place renaming for routing policies through the API and dashboard.
  • Preserved policy scope and usage history during renames.
  • Kept aliases read-only and protected against rename conflicts.
  • Made routing-policy candidates visible in model discovery, including failure chains.
  • Prevented pricing policies directly and identified their candidate models instead.
  • Improved dashboard pricing labels for unknown and unpriced models.
  • Updated documentation, API examples, integration tests, and dashboard coverage.

…te prices

Two changes to the routing surface that were in flight together.

Rename: POST /v1/routing/policies takes `rename_from`, so an edit that both
renames a policy and re-targets it cannot land half applied, leaving the old
name serving the new spec. The new name is validated exactly as a fresh one is,
because a rename can walk a policy into every collision a create can. The
dashboard's Edit form exposes it, and the row moves rather than being copied.

Pricing: a policy no longer withholds its candidates from GET /v1/models. Every
selector of every policy in force was hidden, `on_failure` chains included, so
one failover policy could empty most of a catalogue, and a candidate priced by
the genai-prices fallback then disappeared from the dashboard together with its
rate. GET /v1/models/{key} never withheld them, so nothing was really being kept
off the wire. Alias targets are still withheld: an alias exists to stand in for
a target, a policy does not.

POST /v1/pricing now refuses a policy name the way it already refuses an alias
name, naming the candidates to price instead. Pricing, budgets, and usage key on
the model a request resolves to, so a row stored under the policy name was
written and never read.

The dashboard rebuilds rows the catalogue withheld from the discovery endpoint.
Those now report "rate unknown" instead of "not priced" when default pricing is
on, since the gateway does meter such a model; with the fallback off, unpriced
remains the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pss-julien
pss-julien deployed to integration-tests August 12, 2026 08:38 — with GitHub Actions Active
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

The PR adds scoped routing-policy renaming through the API and dashboard. It preserves policy rows and scopes, exposes policy candidates in model discovery, rejects policy names for pricing, and updates pricing displays, tests, and documentation.

Routing policy behavior

Layer / File(s) Summary
Scoped policy rename API
src/gateway/api/routes/routing.py, tests/integration/test_routing_policies.py, docs/...
The API supports atomic rename_from updates with scoped lookup, validation, conflict handling, and row preservation. Tests and documentation cover rename semantics and errors.
Policy catalog and pricing validation
src/gateway/api/routes/models.py, src/gateway/api/routes/pricing.py, tests/integration/test_routing_policies.py, docs/...
Policy candidates remain visible in model listings. Pricing rejects routing-policy names and reports applicable targets or selectors.
Dashboard policy editing
web/src/api/types.ts, web/src/pages/RoutingPage.tsx, web/src/pages/RoutingPage.test.tsx, web/e2e/dashboard.spec.ts
The dashboard submits rename_from for renamed policies, keeps aliases immutable, preserves fallback chains, and makes scope read-only during editing.
Discovered model pricing display
web/src/pages/ModelsPage.tsx, web/src/pages/ModelsPage.test.tsx
Discovered models show rate unknown when fallback pricing cannot be determined and not priced when default pricing is disabled.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

  • mozilla-ai/otari#492: Introduced routing-policy CRUD and dashboard behavior extended by this change.
  • mozilla-ai/otari#188: Also modifies routing policy handling and the Routing UI, but addresses learned kNN routing.

Suggested labels: area/backend, area/dashboard

Suggested reviewers: njbrake

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title describes the changes but does not use the required exact prefix format and exceeds the approximate 70-character limit. Change the title to start with an allowed prefix such as feat: and shorten it to about 70 characters or fewer.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed The description includes all required sections, explains both changes, records validation results, and identifies AI usage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

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.

@codecov-commenter

codecov-commenter commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/gateway/api/routes/routing.py 96.29% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/gateway/api/routes/models.py 97.36% <100.00%> (ø)
src/gateway/api/routes/pricing.py 86.34% <100.00%> (ø)
src/gateway/api/routes/routing.py 90.05% <96.29%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@njbrake njbrake self-assigned this Aug 12, 2026
…me race

Review follow-ups on the rename path, plus two dashboard notes.

`rename_from` now keys the 404 on the field being sent rather than on the name
having changed. Sending it asserts that policy is stored, so `rename_from` equal
to `name` with no row behind it is answered instead of quietly becoming a create:
an edit form whose row was deleted underneath it would otherwise resurrect the
policy rather than report it gone. An unchanged name whose row does exist stays a
plain update, which is what a form that always sends the field needs.

The name check and the commit are not one atomic step, so a concurrent writer can
take the target name in between and leave the unique constraint to catch it. That
surfaced as "Database error". It now re-reads the name and answers with the same
409 the pre-check would have given, falling through to the 500 otherwise, because
the same constraint class also covers the user foreign key and reporting a deleted
user as a name clash would send the operator after the wrong thing.

Dashboard: the unpriced label for a discovery-only row now waits for the settings
answer instead of reading "not priced" while the flag is still undefined, and its
comment covers the second way such a row arises, `model_discovery` being off, not
only a withheld alias target.

Also covers the pricing refusal for a user-scoped policy name, the one branch
that cannot name the candidates, and frees the e2e rename target so a re-run
against a warm database does not fail on its own leftovers.

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

@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 (2)
src/gateway/api/routes/routing.py (1)

311-318: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Add limit(1) to the existence check.

_name_is_taken only needs to know whether a row exists. The coding guidelines ask for limit(1) on existence checks. It also removes any theoretical MultipleResultsFound risk if a future migration relaxes the unique constraint.

♻️ Proposed refactor
     existing = (
         await db.execute(
-            select(RoutingPolicy.id).where(RoutingPolicy.name == name, RoutingPolicy.user_id == user_id)
+            select(RoutingPolicy.id)
+            .where(RoutingPolicy.name == name, RoutingPolicy.user_id == user_id)
+            .limit(1)
         )
     ).scalar_one_or_none()
     return existing is not None

As per coding guidelines: "Push filtering, sorting, counting, and aggregation into SQL; use func.count() instead of loading rows and calling len(), and use limit(1) for existence checks."

🤖 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 311 - 318, Update the query
in _name_is_taken to apply limit(1) before execution, while preserving the
existing name and user_id filters and scalar existence result.

Source: Coding guidelines

web/src/pages/RoutingPage.tsx (1)

393-397: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider always sending rename_from when editing a policy.

The form omits rename_from when the name is unchanged. The colocated test explains the reason: a plain spec edit should not read as a rename. That reasoning is sound, so this is a judgment call rather than a defect.

The tradeoff is on the other side. The backend treats rename_from as an assertion that the row exists, including when it equals name (see set_policy in src/gateway/api/routes/routing.py, and the test test_rename_from_never_falls_back_to_creating_the_policy). If another operator deletes the policy while this edit form is open, the current payload re-creates it instead of reporting a 404. Sending rename_from: previousName for every policy edit would surface the deletion.

If you keep the current behavior, no change is needed. The server log already distinguishes the two cases, because it records renamed_from=- when the name did not change.

♻️ Optional change
-    save.mutate(
-      { name: name.trim(), spec, user_id: scope, ...(renaming ? { rename_from: previousName } : {}) },
-      { onSuccess: onClose },
-    );
+    save.mutate(
+      { name: name.trim(), spec, user_id: scope, ...(editing ? { rename_from: previousName } : {}) },
+      { onSuccess: onClose },
+    );

Also applies to: 495-498

🤖 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 393 - 397, Optional: update the
policy edit payload construction around the renaming logic and its corresponding
use near the second occurrence so every non-alias edit sends rename_from using
previousName, including when the name is unchanged. Preserve aliases without
rename_from and ensure policy edits assert the original row rather than falling
back to creation.
🤖 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 `@src/gateway/api/routes/routing.py`:
- Around line 311-318: Update the query in _name_is_taken to apply limit(1)
before execution, while preserving the existing name and user_id filters and
scalar existence result.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 393-397: Optional: update the policy edit payload construction
around the renaming logic and its corresponding use near the second occurrence
so every non-alias edit sends rename_from using previousName, including when the
name is unchanged. Preserve aliases without rename_from and ensure policy edits
assert the original row rather than falling back to creation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 96d699bd-f189-40b5-a21f-f861cf7c703d

📥 Commits

Reviewing files that changed from the base of the PR and between ee2aadd and 7204cdd.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (15)
  • docs/api-reference.md
  • docs/dashboard.md
  • docs/models.md
  • docs/public/otari.postman_collection.json
  • docs/routing.md
  • src/gateway/api/routes/models.py
  • src/gateway/api/routes/pricing.py
  • src/gateway/api/routes/routing.py
  • tests/integration/test_routing_policies.py
  • web/e2e/dashboard.spec.ts
  • web/src/api/types.ts
  • web/src/pages/ModelsPage.test.tsx
  • web/src/pages/ModelsPage.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx

@njbrake njbrake 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.

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

Approving. Heads up that I pushed a second commit to your branch (7204cdd9) instead of sending it back for round trips on small things. Take a look and say so if you disagree with any of it.

Two of them were real, both on the rename path:

  • rename_from equal to name with no stored row created the policy instead of 404ing, because the 404 sat behind renaming. An edit form whose row was deleted underneath it would resurrect the policy rather than report it gone. The 404 now keys on the field being sent at all, and the unchanged-name upsert your test pins still works.
  • A rename that lost the race between _name_is_taken and the commit surfaced as Database error. It now catches IntegrityError and re-reads the name to answer with the same 409. Re-reading rather than mapping the exception straight to 409, because the user_id foreign key raises the same class and calling a deleted user a name clash sends the operator after the wrong thing.

Both carry regression tests that fail without the fix. The rest is minor: a coverage test for the one pricing branch that cannot name candidates, the unpriced label waiting for the settings fetch instead of claiming "not priced" while the flag is still undefined, a comment, and freeing the e2e rename target so a re-run against a warm database does not trip on its own leftovers.

On the half that stops policies withholding candidates: I checked that GET /v1/models/{key} only ever consulted effective_aliases, so those candidates were served with their prices all along and the listing was withholding from the dashboard without protecting anything. The key allowlist still filters every listed row. Good change, and the docs carry the reasoning rather than just the new behavior.

@njbrake
njbrake merged commit e0db77d into mozilla-ai:main Aug 12, 2026
16 checks passed
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.

4 participants