Skip to content

feat(router): learned kNN router as a routing-policy backend - #188

Merged
njbrake merged 11 commits into
mainfrom
feat/router-knn
Aug 7, 2026
Merged

feat(router): learned kNN router as a routing-policy backend#188
njbrake merged 11 commits into
mainfrom
feat/router-knn

Conversation

@njbrake

@njbrake njbrake commented Jun 19, 2026

Copy link
Copy Markdown
Member

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

Description

Fills the router: seam PolicySpec already reserved. A policy entry names a backend that ranks candidates per request, and the ranking becomes the plan the attempt walker already executes, so a learned policy inherits failover, absorbed usage rows, allow-list filtering, and guardrail mandates rather than being a second routing system beside them.

routing:
  policies:
    smart:
      select:
        - router: knn
          candidates: [openai:gpt-5-nano, openai:gpt-5]
        - default: openai:gpt-5        # serves whenever the router declines
      on_failure: [anthropic:claude-haiku-4-5]

The knn backend embeds the prompt, votes over the nearest scored examples, and scores each candidate as predicted_quality - alpha * normalized_cost. Every uncertain case declines and default serves: a cold pool, a sparse neighborhood, sub-floor confidence, tools present, an unpriced candidate, a failed embedding, an unknown backend, Otari-Router: off. So a learned policy is never worse than the failover policy it was written from, and a routed request that fails falls over to the router's second choice before reaching on_failure. selection_reason on the usage row is router:knn when it chose, default when it declined.

compile_policy stays pure and synchronous; ranking is async, so it runs in the pipeline and arrives as a value, which is why explain shows the decline path.

Teaching is POST /v1/routing/preferences/rank, batched to 100 because a pool needs 20 examples before it routes. GET /v1/routing/status reports warmth per pool. Both master-key gated, with user_id naming whose memory it is: memory is per user even for a global policy, since the records hold that user's prompts. The dashboard authors these policies and shows whether one can act yet. Standalone only, like every policy.

Not in this PR, deliberately

  • No teaching UI. A hand-scoring panel is the wrong shape for where learning is going (passive learning, judge-assisted scoring, bulk import), so it is not shipped and then redone.
  • No compare endpoint. Seeing what each candidate answers is what POST /v1/chat/completions already does, on the path that reserves budget and logs usage.
  • No route to list or delete examples. Which is why rank refuses a score key no learned policy could route to: such records are unmatchable, and the pool would report warm while never routing.

What a reviewer should push on

  • The decline reason reaches only an INFO log; the usage row says whether the router chose, never why it declined.
  • Cost is list price, so a routed agent trace can lose a warm prompt cache. trace_sticky limits the damage.
  • Stickiness is per process, so another replica or a restart re-decides. Safe, not sticky. See docs/routing-scaling.md.

PR Type

  • New Feature
  • Documentation

Relevant issues

A v1 of #187, which stays open: cache-aware cost, passive learning, latency tracking, an ANN index past a few thousand records per user, a capability registry, a list/delete route for examples, and hybrid-mode routing memory all remain there.

Checklist

  • I understand the code I am submitting.
  • I have added or updated tests that cover my change.
  • I ran the Definition of Done checks locally (make lint, make typecheck, make test, npm test, make openapi-check, make postman-check).
  • Documentation was updated where necessary.
  • If the API contract changed, I regenerated the OpenAPI spec and the Postman collection.

Two integration tests fail in the author's sandbox on any branch, main included, because they make a real outbound provider call: test_error_detail_leakage and test_streaming_error_event. Everything else passes.

AI Usage

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

AI Model/Tool used: Claude Code (Opus 5)

Any additional AI details you'd like to share: Code, tests, docs, and the dashboard work were generated by Claude through iterative back-and-forth with @njbrake, who directed the design decisions and reviewed the output.

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

Summary

  • Added standalone learned knn routing with confidence-based fallback and failover support.
  • Added per-user routing memory, preference-ranking and status APIs, database tables, and configuration options.
  • Added routing support to chat, messages, and responses pipelines.
  • Updated policy authoring, explanations, usage tracking, CLI output, documentation, demo tooling, and dashboard readiness views.
  • Added unit and integration test coverage.

Technical notes

  • Router decisions preserve allow-lists, guardrails, usage attribution, and default fallback behavior.
  • The feature declines safely for cold pools, low confidence, tools, missing pricing, embedding failures, and router opt-out requests.
  • The feature is standalone-only.
  • Two integration tests may fail in environments that cannot provide real outbound provider calls.

@njbrake
njbrake temporarily deployed to integration-tests June 19, 2026 17:29 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@njbrake, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8500181-e6fc-4480-a3e7-77e38929b8c4

📥 Commits

Reviewing files that changed from the base of the PR and between f23b4b0 and 98e4d60.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (44)
  • alembic/versions/f0a1b2c3d4e5_add_router_tables.py
  • docs/api-reference.md
  • docs/configuration.md
  • docs/dashboard.md
  • docs/public/otari.postman_collection.json
  • scripts/sdk_codegen/sdk-endpoints.txt
  • src/gateway/AGENTS.md
  • src/gateway/api/main.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/chat.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/routing_memory.py
  • src/gateway/core/config.py
  • src/gateway/models/entities.py
  • src/gateway/services/routing/__init__.py
  • src/gateway/services/routing/compiler.py
  • src/gateway/services/routing/decide.py
  • src/gateway/services/routing/knn.py
  • src/gateway/static/dashboard/assets/ActivityPage-fH63Z1Im.js
  • src/gateway/static/dashboard/assets/BudgetsPage-C3eMHXLY.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-lRO7CIis.js
  • src/gateway/static/dashboard/assets/DocsPage-omWiBiUs.js
  • src/gateway/static/dashboard/assets/KeysPage-DvXgAgzE.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-CYPgEOWk.js
  • src/gateway/static/dashboard/assets/ModelsPage-SJrMcme1.js
  • src/gateway/static/dashboard/assets/OverviewPage-W9tjAThu.js
  • src/gateway/static/dashboard/assets/ProvidersPage-CkpZWNPU.js
  • src/gateway/static/dashboard/assets/RoutingPage-DELPbpkQ.js
  • src/gateway/static/dashboard/assets/SettingsPage-CDU9M0qn.js
  • src/gateway/static/dashboard/assets/TablePagination-BpT-8wzM.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-oZnn27P0.js
  • src/gateway/static/dashboard/assets/UsagePage-Bt6OQ6El.js
  • src/gateway/static/dashboard/assets/UsersPage-CWfTsLqm.js
  • src/gateway/static/dashboard/assets/index-DAnS9oY2.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_routing_learned.py
  • tests/unit/test_knn_router.py
  • tests/unit/test_routing_compiler.py
  • tests/unit/test_routing_decide.py
  • tests/unit/test_routing_signal.py
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/pages/RoutingPage.tsx

Walkthrough

Adds learned kNN routing with configurable candidate pools, per-user routing memory, backend decision orchestration, management APIs, dashboard controls, persistence, tests, and documentation. Generated dashboard assets are refreshed to include the routing and documentation pages.

Changes

Learned kNN Router

Layer / File(s) Summary
Routing contracts and persistence
src/gateway/core/config.py, src/gateway/models/routing.py, src/gateway/models/entities.py, alembic/versions/...
Adds router configuration, candidate-pool validation, routing entities, backend contracts, compiler metadata, and database tables.
kNN decision engine
src/gateway/services/routing/*, src/gateway/services/pricing_init_service.py
Adds backend resolution, embedding-based ranking, confidence checks, trace stickiness, fallback ordering, preference recording, retention limits, and pricing validation.
Gateway request and policy integration
src/gateway/api/routes/*, src/gateway/api/main.py, src/gateway/api/deps.py, src/gateway/main.py, src/gateway/cli.py
Builds routing signals, invokes asynchronous router decisions, registers routing-memory endpoints, exposes router metadata, validates candidates, and updates policy reporting.
Routing management dashboard
web/src/api/*, web/src/components/RouterReadiness.tsx, web/src/pages/RoutingPage.tsx, web/src/pages/RoutingPage.test.tsx
Adds learned-policy editing, candidate and fallback controls, readiness reporting, scored-example guidance, and related UI tests.
Routing validation and test coverage
tests/unit/*routing*, tests/unit/test_knn_router.py, tests/integration/test_routing_learned.py, tests/integration/test_config_env_loading.py
Covers configuration, routing signals, backend resolution, kNN ranking, fallback paths, authorization, isolation, stickiness, and static-policy behavior.
Documentation, demo tooling, and dashboard assets
docs/*, scripts/seed_routing_demo.py, src/gateway/static/dashboard/*
Documents learned routing and scaling behavior, updates the demo workflow, and refreshes generated dashboard bundles and styles.

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

Possibly related issues

Possibly related PRs

  • mozilla-ai/otari#130 — Introduces the shared request-context and compilation flow extended here for learned-routing signals and ordering.
  • mozilla-ai/otari#492 — Modifies shared routing models, pipeline code, routing APIs, and the routing dashboard used by this learned-routing implementation.
  • mozilla-ai/otari#507 — Extends routed-request activity and usage grouping around candidate ordering and fallback outcomes.

Suggested reviewers: tbille

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title uses a valid scoped feature prefix and stays under 70 characters, but it is descriptive rather than imperative. Rewrite the title in imperative mood, such as "feat(router): add learned kNN routing backend".
Docstring Coverage ⚠️ Warning Docstring coverage is 17.14% 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 the required sections, explains the feature and scope, records testing and known failures, and completes the checklist and AI disclosure.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/router-knn
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feat/router-knn

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.

@dpoulopoulos
dpoulopoulos self-requested a review June 22, 2026 14:54
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 18:36 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 18:52 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 18:58 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 19:04 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 20:38 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 21:04 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 21:25 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 21:25 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 22:15 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 23:01 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 22, 2026 23:36 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 01:45 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 10:47 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 10:53 — with GitHub Actions Inactive
@njbrake
njbrake force-pushed the feat/router-knn branch 3 times, most recently from 11e226c to 90a3def Compare June 23, 2026 13:38
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 15:20 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 15:30 — with GitHub Actions Inactive
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 17:02 — with GitHub Actions Inactive
@njbrake
njbrake marked this pull request as ready for review June 23, 2026 17:27
Comment thread demo/router/README.md Outdated
@njbrake
njbrake requested review from besaleli and dni138 June 23, 2026 17:28
@coderabbitai
coderabbitai Bot requested review from khaledosman and tbille June 23, 2026 17:29

@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: 13

🧹 Nitpick comments (2)
docs/routing.md (1)

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

Add language markers to fenced code blocks showing example headers.

Lines 138 and 168 have code fences without a language specifier. To satisfy markdown linting (and to make the intent clear to readers), add a language marker. Since these are example HTTP headers, use `text` or `bash`:

# Line 138:
- `Otari-Router: off` serves...

Instead of:
+Otari-Conversation-Id: 4f9c2b10-...

Use:
+ ``` text
+Otari-Conversation-Id: 4f9c2b10-...
+```

This keeps the visual clarity of code blocks while satisfying the linter and readers.

Also applies to: 168-168

🤖 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/routing.md` at line 138, The fenced code blocks at lines 138 and 168 in
the routing.md file are missing language specifiers on their opening code fence
markers. Add a language marker to each opening fence by changing the bare triple
backticks (```) to include a language identifier such as `text` or `bash` (for
example, ```text or ```bash). This satisfies markdown linting requirements and
provides clarity to readers about the content type of the code blocks containing
example HTTP headers.

Source: Linters/SAST tools

demo/router/generate_demo_dataset.py (1)

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

Use the repository logger instead of print for progress output.

These progress/status lines should use the project logger pattern so output is structured and consistent with repo logging conventions.

As per coding guidelines, **/*.py: use module logger from gateway.log_config with structured/contextual log messages using %s formatting placeholders.

🤖 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 `@demo/router/generate_demo_dataset.py` around lines 251 - 269, Replace all
print statements in the main function with structured logging using the
project's logger from gateway.log_config. The three print statements that
display progress output (showing candidate index and spread information,
divergence summary, and file write confirmation) should be converted to logger
calls using percent-style formatting placeholders (%s) instead of f-strings.
Import the logger module at the top of the file and use appropriate log levels
(info or debug) for these status messages to maintain consistency with
repository logging conventions.

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 `@demo/router/src/components/StepWalkthrough.tsx`:
- Around line 33-40: The global keyboard event listener in the useEffect hook
unconditionally triggers step navigation for ArrowLeft and ArrowRight keys,
which interferes with interactive controls like sliders that also use arrow
keys. Inside the onKey handler function, add a check to verify that the event
target is not an interactive element (such as an input, slider, or other form
control) before calling next() or prev(). You can check the event target's type
or role to determine if it is an interactive control, and only proceed with
navigation if the event originated from a non-interactive element.

In `@demo/router/src/components/TeachWalkthrough.tsx`:
- Around line 24-38: The code assumes demo.items is non-empty, causing crashes
when it has zero items (accessing demo.items[0] on line 24 and reducing an empty
array on line 38). Add an early empty-state guard clause at the beginning of the
TeachWalkthrough component that checks if demo.items.length is zero and handles
this case before deriving the item variable and initializing the useState hooks
that depend on having data. This ensures the component gracefully handles empty
datasets before attempting to access array indices or perform reduce operations.

In `@demo/router/src/main.tsx`:
- Around line 2-7: The import statement in main.tsx is using a default import
for ReactDOM from react-dom/client, but React 19 exports createRoot as a named
export only. Change the import statement from `import ReactDOM from
"react-dom/client"` to `import { createRoot } from "react-dom/client"`, then
update the ReactDOM.createRoot call to use createRoot directly, replacing
`ReactDOM.createRoot(document.getElementById("root") as
HTMLElement).render(...)` with `createRoot(document.getElementById("root") as
HTMLElement).render(...)`.

In `@demo/router/src/router-sim.ts`:
- Around line 63-91: When no eligible neighbors exist in a task partition, the
neighborItems array becomes empty, causing the meanQuality calculation to
default to 0 for all candidates, which then selects the cheapest model instead
of preserving the fallback behavior. After the neighborItems assignment and
before the pool.map call that creates the candidates array, add an explicit
check: if neighborItems.length is 0, set winner to the fallback value
(strongest) and either skip the candidate scoring loop or handle it as a special
case to preserve the documented fallback behavior.

In `@src/gateway/api/routes/router.py`:
- Around line 8-10: The module docstring for the POST
/v1/router/preferences/rank endpoint currently describes the old behavior of
writing one routing-memory record per scored model, but the implementation now
writes a single routing-memory example with a score map instead. Update the
docstring (lines 8-10) to accurately reflect the current storage model where one
submission creates one routing-memory example containing a score map for all
models, plus one audit row, rather than describing multiple records per model.
- Around line 51-52: The `models` field in the schema has no maximum length
constraint, allowing users to submit arbitrarily large arrays that trigger
unbounded concurrent provider calls at line 145, creating excessive upstream
load. Add a `max_length` parameter to the Field definition for the `models` list
to enforce a schema-level hard cap on the number of models allowed.
Additionally, implement a semaphore or similar concurrency limiting mechanism
around line 145 where the provider calls are launched asynchronously to ensure
that only a small, bounded number of concurrent calls execute regardless of the
input array size.
- Around line 141-143: The ModelResponse being returned in the exception handler
is exposing raw exception details via str(exc) directly to clients, which can
leak sensitive upstream provider information. Replace the str(exc) value passed
to the error parameter in the ModelResponse with a generic, user-friendly error
message that does not reveal internal details. The actual error details are
already being captured server-side in the logger.warning call, so keep those
specifics there and only return a safe generic message to the client.

In `@src/gateway/models/entities.py`:
- Around line 310-313: The docstring for the RouterPreference class incorrectly
describes the storage model by stating that rank submissions write one
RoutingMemory row per scored model. Update this docstring to accurately reflect
the new storage model where one row is written per example with per-model
qualities stored together, rather than separate rows per model. This ensures the
documentation matches the actual implementation and prevents confusion during
future maintenance.

In `@src/gateway/services/knn_router.py`:
- Around line 235-247: The database write operation in the RoutingMemory
creation block does not have proper error handling for commit failures. Wrap the
await db.commit() call in a try/except block that catches SQLAlchemyError, calls
db.rollback() in the except clause on any database error, and re-raises an
appropriate mapped API or domain error. This same transaction handling pattern
must also be applied to the other database write operation mentioned at lines
374-399 to ensure consistent error handling across all router DB writes and
prevent sessions from remaining in failed transaction states.
- Around line 216-248: The record_preference() method opens and commits its own
database session independently, causing a race condition where the
RouterPreference row can be persisted before this method is called, but if the
embedding or insert fails here, the audit row exists without the corresponding
routing-memory example. Modify record_preference() to accept an optional
AsyncSession parameter and use that session directly instead of creating a new
one with async with create_session(). This allows the calling code in the route
handler to manage both the RouterPreference and RoutingMemory writes as a single
atomic transaction, ensuring both succeed or fail together.
- Around line 193-195: In the exception handler block where embedding fails (in
the except Exception clause with logger.warning), change the logging to avoid
logging the raw exception text which may contain user-derived content or
sensitive data from provider exceptions. Instead, log only the exception class
name or type using type(exc).__name__ rather than logging the full exc object
with the %s placeholder. This prevents privacy leaks from user payloads or
provider-specific error messages in logs while still providing debugging
context.

In `@tests/integration/test_config_env_loading.py`:
- Around line 150-166: The test_router_knob_defaults function only clears
OTARI_ROUTER_* environment variables but GatewayConfig also accepts legacy
GATEWAY_ROUTER_* fallback environment variables, making the assertions
environment-dependent. Add the legacy GATEWAY_ROUTER_* environment variables
(such as GATEWAY_ROUTER_ALPHA, GATEWAY_ROUTER_K, GATEWAY_ROUTER_SEED_COUNT,
GATEWAY_ROUTER_GRANULARITY, GATEWAY_ROUTER_CANDIDATES, and
GATEWAY_ROUTER_EMBEDDING_MODEL) to the loop in monkeypatch.delenv calls so both
namespaces are cleared before instantiating GatewayConfig().

In `@tests/integration/test_router_preferences.py`:
- Around line 87-105: The _build_client function hardcodes "test-key" as the
OpenAI API key in the providers configuration, which causes issues when running
in live mode where the OPENAI_API_KEY environment variable is available. Modify
the _build_client function to check if the OPENAI_API_KEY environment variable
exists and use it instead of the hardcoded "test-key" value in the providers
dict. Apply this same fix to all other occurrences of this pattern in the file
(at lines 131-135 and 463-467 as indicated) to ensure consistency across all
test client builders.

---

Nitpick comments:
In `@demo/router/generate_demo_dataset.py`:
- Around line 251-269: Replace all print statements in the main function with
structured logging using the project's logger from gateway.log_config. The three
print statements that display progress output (showing candidate index and
spread information, divergence summary, and file write confirmation) should be
converted to logger calls using percent-style formatting placeholders (%s)
instead of f-strings. Import the logger module at the top of the file and use
appropriate log levels (info or debug) for these status messages to maintain
consistency with repository logging conventions.

In `@docs/routing.md`:
- Line 138: The fenced code blocks at lines 138 and 168 in the routing.md file
are missing language specifiers on their opening code fence markers. Add a
language marker to each opening fence by changing the bare triple backticks
(```) to include a language identifier such as `text` or `bash` (for example,
```text or ```bash). This satisfies markdown linting requirements and provides
clarity to readers about the content type of the code blocks containing example
HTTP headers.
🪄 Autofix (Beta)

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

Run ID: a240f8a6-be5d-4911-8049-619de44f244b

📥 Commits

Reviewing files that changed from the base of the PR and between 1b6a80e and 6418830.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (45)
  • alembic/versions/f0a1b2c3d4e5_add_router_tables.py
  • demo/router/.gitignore
  • demo/router/README.md
  • demo/router/generate_demo_dataset.py
  • demo/router/index.html
  • demo/router/package.json
  • demo/router/src/App.tsx
  • demo/router/src/components/CodeJson.tsx
  • demo/router/src/components/KnnGraph.tsx
  • demo/router/src/components/StepWalkthrough.tsx
  • demo/router/src/components/TeachWalkthrough.tsx
  • demo/router/src/components/Walkthrough.tsx
  • demo/router/src/components/ui/button.tsx
  • demo/router/src/components/ui/card.tsx
  • demo/router/src/components/ui/index.ts
  • demo/router/src/demo-config.ts
  • demo/router/src/demo_prompts.json
  • demo/router/src/format.ts
  • demo/router/src/globals.css
  • demo/router/src/main.tsx
  • demo/router/src/router-sim.ts
  • demo/router/src/styles/brand-tokens.css
  • demo/router/src/types.ts
  • demo/router/tsconfig.json
  • demo/router/vite.config.ts
  • docs/api-reference.md
  • docs/configuration.md
  • docs/index.md
  • docs/routing-scaling.md
  • docs/routing.md
  • src/gateway/api/deps.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/chat.py
  • src/gateway/api/routes/router.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/services/knn_router.py
  • src/gateway/services/router_backend.py
  • tests/integration/test_config_env_loading.py
  • tests/integration/test_router_preferences.py
  • tests/integration/test_router_seam_e2e.py
  • tests/unit/test_knn_router.py
  • tests/unit/test_router_backend.py
  • tests/unit/test_router_header.py

Comment thread demo/router/src/components/StepWalkthrough.tsx Outdated
Comment thread demo/router/src/components/TeachWalkthrough.tsx Outdated
Comment thread demo/router/src/main.tsx Outdated
Comment thread demo/router/src/router-sim.ts Outdated
Comment thread src/gateway/api/routes/router.py Outdated
Comment thread src/gateway/services/knn_router.py Outdated
Comment thread src/gateway/services/knn_router.py Outdated
Comment thread src/gateway/services/knn_router.py Outdated
Comment thread tests/integration/test_config_env_loading.py Outdated
Comment thread tests/integration/test_router_preferences.py Outdated
@njbrake njbrake changed the title feat(router): per-tenant kNN model router with preference collection feat(router): learned kNN router as a routing-policy backend Aug 6, 2026
@njbrake
njbrake marked this pull request as ready for review August 6, 2026 14:46
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@njbrake
njbrake temporarily deployed to integration-tests August 6, 2026 14:46 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@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: 10

🧹 Nitpick comments (6)
web/src/components/RouterReadiness.tsx (2)

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

Add a colocated test file for this component.

RoutingPage.test.tsx exercises this panel through the page, which is good integration coverage. The repository standard also asks for a colocated test next to the component, so web/src/components/RouterReadiness.test.tsx would cover the parts that are hard to reach from the page: seed === 0 in Warmth, records above seed, and the error branch on line 108.

As per coding guidelines: "Add colocated Vitest tests for changed behavior (Foo.tsxFoo.test.tsx)".

🤖 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/components/RouterReadiness.tsx` at line 46, Add a colocated Vitest
test file for the RouterReadiness component, targeting the exported
RouterReadiness flow and its internal Warmth behavior. Cover Warmth with seed
=== 0, records greater than seed, and the error branch near the component’s
error handling, while following existing repository test conventions.

Source: Coding guidelines


116-131: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Hoist status.data before reading it in the map callback.

status.data narrows outside the callback, but TypeScript 5.6 resets that narrowing inside the nested status.data.tasks.map(...), so status.data.seed_count is still possibly undefined there. Capture a const data = status.data before the JSX and use data everywhere inside the branch.

🤖 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/components/RouterReadiness.tsx` around lines 116 - 131, Capture
status.data in a local data constant at the start of the truthy branch, then use
data for the default pool, tasks map, and seed_count accesses, including inside
the map callback, so TypeScript preserves the narrowing.

Source: Coding guidelines

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

577-585: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The last assertion in this test cannot fail.

Line 584 checks that no POST happened, but the test never presses "Create policy", and the fifth candidate row is still empty, so canSubmit is already false. The two assertions that carry the intent are on lines 582 and 583. Consider dropping line 584, or press "Create policy" first so the assertion actually proves that submission is blocked.

🤖 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 577 - 585, The final POST
assertion in the candidate-cap test is not meaningful because submission is
never attempted while the fifth row is empty. Update the test around the “+
Another model” loop to either remove the calls.some POST assertion or complete
the fifth candidate and click “Create policy” so it verifies submission is
blocked; preserve the existing cap and explanatory-text assertions.
web/src/pages/RoutingPage.tsx (1)

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

Give each fallback radio a distinct accessible name.

Every radio in the pool exposes the same accessible name, "Serves when unsure". A sighted operator reads the name against the adjacent Model N field, but a screen-reader user hears the identical name two to five times with no indication of which model each radio marks. Adding the candidate to the name, and wrapping the group in a fieldset, keeps the visible copy short while making the choice unambiguous.

♿ Proposed refactor
                   <label className="flex items-center gap-2 pb-2 text-xs text-[var(--otari-ink)]">
                     <input
                       type="radio"
                       name="router-safe-choice"
+                      aria-label={`Serves when unsure: ${entry.trim() === "" ? `model ${index + 1}` : entry}`}
                       checked={safeIndex === index}
                       onChange={() => setSafeIndex(index)}
                     />
                     Serves when unsure
                   </label>

Note that RoutingPage.test.tsx queries these radios by the name /serves when unsure/i (lines 404 and 417), so the suggested prefix keeps those queries matching.

🤖 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 522 - 530, Update the fallback
radio group around the safe-choice controls in RoutingPage to use a fieldset
with an accessible group label, and give each radio a distinct accessible name
by prefixing “Serves when unsure” with its associated candidate/model
identifier. Preserve the existing visible label text and ensure the accessible
name still matches queries for /serves when unsure/i.
tests/unit/test_routing_decide.py (1)

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

This test proves the opt-out path, not the cold-pool path its comment describes.

The recorder fixture replaces the backend with one that always ranks the pool, and opted_out=True makes decide_ordering return before any backend call. So the "cold pool" case in the comment is never reached, and Line 271 already covers the opt-out. A backend that returns an empty RoutingDecision would make the test match its own description.

Small thing, and the intent is good: the silence-on-decline guarantee is worth locking down, because a warning per request on a cold router is exactly the log flood you are avoiding.

♻️ One way to cover the decline case
 `@pytest.mark.asyncio`
 async def test_a_decline_is_not_warned_about(
-    config: GatewayConfig, recorder: _Recorder, router_warnings: Callable[[], list[str]]
+    config: GatewayConfig, monkeypatch: pytest.MonkeyPatch, router_warnings: Callable[[], list[str]]
 ) -> None:
     # A cold pool or an opted-out caller is normal operation, and warning on it
     # would log a line for every request a cold router serves, which is all of them
     # until someone teaches it.
+    class _ColdBackend:
+        async def rank(self, ctx: RoutingContext) -> RoutingDecision:
+            return RoutingDecision.decline("cold pool: 0/20 records for this user")
+
+    monkeypatch.setattr(
+        "gateway.services.routing.decide.get_router_backend", lambda config, name: _ColdBackend()
+    )
     await decide_ordering(
-        config, _spec(), policy_name="smart", user_id="u", allowlist=None, signal=_signal(opted_out=True)
+        config, _spec(), policy_name="smart", user_id="u", allowlist=None, signal=_signal()
     )
     assert router_warnings() == []
🤖 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_routing_decide.py` around lines 207 - 217, Update
test_a_decline_is_not_warned_about to exercise the cold-pool decline path
instead of the opted-out early return: configure the recorder/backend to return
an empty RoutingDecision, invoke decide_ordering with an eligible signal, and
retain the assertion that router_warnings() is empty. Keep the existing opt-out
coverage separate and preserve the test’s silence-on-decline guarantee.
tests/unit/test_knn_router.py (1)

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

total is never passed by any test, and its comment describes behavior nothing exercises.

No call site in this file uses total=. The seed gate and the sparse gate are separated instead by tuning router_seed_count and router_k (Lines 128, 148). The comment therefore points a future reader at a mechanism that is not in use. Either drop the parameter or add the test that needed it.

♻️ Proposed cleanup
 def _wire(
     backend: KnnRoutingMemory,
     records: list[RoutingMemory],
     *,
     prices: dict[str, float] | None = None,
     query: tuple[float, ...] = (1.0, 0.0),
-    total: int | None = None,
 ) -> None:
     async def _embed(text: str) -> list[float]:
         return list(query)
 
     async def _load(user_id: str, task_id: str | None) -> list[RoutingMemory]:
-        # `total` pads the record count without inventing neighbors, which is how
-        # the seed gate and the sparse-neighborhood gate are tested separately.
-        padding = [] if total is None else [_both_good()] * max(0, total - len(records))
-        return [*records, *padding]
+        return list(records)
🤖 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_knn_router.py` around lines 86 - 95, Remove the unused total
parameter and its padding logic from the test helper, along with the comment
describing unexercised behavior. Keep the existing seed and sparse-neighborhood
test coverage driven by router_seed_count and router_k.
🤖 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/f0a1b2c3d4e5_add_router_tables.py`:
- Around line 40-41: Update the upgrade migration near the existing
routing_memory indexes to add a composite index on user_id, embedding_model, and
task_id, and remove the corresponding index in downgrade().

In `@src/gateway/api/routes/responses.py`:
- Around line 428-430: Update the routing_signal construction to include
request_body.instructions before _responses_input_text(request_body.input),
preserving provider prompt order so routing decisions and sticky trace
identities reflect both instructions and input.

In `@src/gateway/api/routes/routing_memory.py`:
- Around line 65-80: Normalize inputs in the request model or handler before
persistence: reject whitespace-only values for prompt despite its current
min_length constraint, and trim task_id while converting blank results to None.
Update the flow around record_preference() and RouterPreference creation so
invalid prompts are rejected before any audit row is written and normalized task
IDs match request-header handling.
- Around line 304-355: Replace the per-example write/commit flow around
record_preference and the RouterPreference db.add calls with a batch service
method that embeds all examples, writes both routing-memory and audit records
using one session, and commits once after successful processing. Run eviction
once after the batch transaction commits, and preserve an explicit
partial-failure contract so examples completed before an embedding failure
remain correctly reported and handled.

In `@src/gateway/core/config.py`:
- Around line 367-374: Update the description for router_max_records_per_user to
explicitly document that 0 disables eviction and allows unlimited stored
routing-memory records; retain the existing cap and oldest-record eviction
behavior for positive values.

In `@src/gateway/services/routing/decide.py`:
- Around line 118-131: The backend ranking call in decide_ordering must be
bounded and fail open to the policy default. Add asyncio and a positive
router_decision_timeout_seconds setting (or an equivalent module constant), wrap
backend.rank in asyncio.wait_for, and catch timeout or ranking exceptions so the
decline path returns the existing default decision instead of propagating an
error.

In `@src/gateway/services/routing/knn.py`:
- Around line 350-352: Update _candidate_prices to avoid awaiting _input_price
once per pool member; batch the pricing lookup for all candidate models in a
single query keyed by the resolved instance and model values. Reuse or add a
batched pricing helper that returns the existing dict[str, float] shape,
preserving the current behavior for the supplied pool.
- Around line 290-305: Bound _load_records independently of eviction by ordering
matching RoutingMemory rows newest-first and limiting the query to
self.scan_limit. Initialize self.scan_limit in __init__ as self.max_records or
_DEFAULT_SCAN_LIMIT so max_records=0 still applies a finite cap, while
preserving the existing user, embedding-model, and task_id filters.
- Around line 312-346: Update record_preference() so _evict_if_needed() runs
within the same database session and transaction as the preference insert,
committing only after both succeed. Refactor _evict_if_needed() to delete
records using a NOT EXISTS or subquery-based condition that retains the newest
max_records rows, avoiding materializing keep_ids or binding one parameter per
retained id. Preserve rollback and error propagation on transaction failure.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 770-776: Update the hint text in the candidates guidance near the
routing page results to refer to the existing “Examples” row action instead of
“Router,” while preserving the surrounding instructions and formatting.

---

Nitpick comments:
In `@tests/unit/test_knn_router.py`:
- Around line 86-95: Remove the unused total parameter and its padding logic
from the test helper, along with the comment describing unexercised behavior.
Keep the existing seed and sparse-neighborhood test coverage driven by
router_seed_count and router_k.

In `@tests/unit/test_routing_decide.py`:
- Around line 207-217: Update test_a_decline_is_not_warned_about to exercise the
cold-pool decline path instead of the opted-out early return: configure the
recorder/backend to return an empty RoutingDecision, invoke decide_ordering with
an eligible signal, and retain the assertion that router_warnings() is empty.
Keep the existing opt-out coverage separate and preserve the test’s
silence-on-decline guarantee.

In `@web/src/components/RouterReadiness.tsx`:
- Line 46: Add a colocated Vitest test file for the RouterReadiness component,
targeting the exported RouterReadiness flow and its internal Warmth behavior.
Cover Warmth with seed === 0, records greater than seed, and the error branch
near the component’s error handling, while following existing repository test
conventions.
- Around line 116-131: Capture status.data in a local data constant at the start
of the truthy branch, then use data for the default pool, tasks map, and
seed_count accesses, including inside the map callback, so TypeScript preserves
the narrowing.

In `@web/src/pages/RoutingPage.test.tsx`:
- Around line 577-585: The final POST assertion in the candidate-cap test is not
meaningful because submission is never attempted while the fifth row is empty.
Update the test around the “+ Another model” loop to either remove the
calls.some POST assertion or complete the fifth candidate and click “Create
policy” so it verifies submission is blocked; preserve the existing cap and
explanatory-text assertions.

In `@web/src/pages/RoutingPage.tsx`:
- Around line 522-530: Update the fallback radio group around the safe-choice
controls in RoutingPage to use a fieldset with an accessible group label, and
give each radio a distinct accessible name by prefixing “Serves when unsure”
with its associated candidate/model identifier. Preserve the existing visible
label text and ensure the accessible name still matches queries for /serves when
unsure/i.
🪄 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: f52fc820-74e9-4e71-9e2c-0ae534daf23e

📥 Commits

Reviewing files that changed from the base of the PR and between 65ada5f and f23b4b0.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (62)
  • alembic/versions/f0a1b2c3d4e5_add_router_tables.py
  • docs/api-reference.md
  • docs/configuration.md
  • docs/dashboard.md
  • docs/index.md
  • docs/public/otari.postman_collection.json
  • docs/routing-scaling.md
  • docs/routing.md
  • scripts/seed_routing_demo.py
  • src/gateway/AGENTS.md
  • src/gateway/api/deps.py
  • src/gateway/api/main.py
  • src/gateway/api/routes/_helpers.py
  • src/gateway/api/routes/_pipeline.py
  • src/gateway/api/routes/chat.py
  • src/gateway/api/routes/messages.py
  • src/gateway/api/routes/responses.py
  • src/gateway/api/routes/routing.py
  • src/gateway/api/routes/routing_memory.py
  • src/gateway/cli.py
  • src/gateway/core/config.py
  • src/gateway/main.py
  • src/gateway/models/entities.py
  • src/gateway/models/routing.py
  • src/gateway/services/pricing_init_service.py
  • src/gateway/services/routing/__init__.py
  • src/gateway/services/routing/backends.py
  • src/gateway/services/routing/compiler.py
  • src/gateway/services/routing/decide.py
  • src/gateway/services/routing/knn.py
  • src/gateway/static/dashboard/assets/ActivityPage-C5ofX9qk.js
  • src/gateway/static/dashboard/assets/BudgetsPage--eVatwDr.js
  • src/gateway/static/dashboard/assets/ConfirmDialog-CXWJ7SUm.js
  • src/gateway/static/dashboard/assets/DocsPage-s_xvezWK.js
  • src/gateway/static/dashboard/assets/KeysPage-BG-GJfIc.js
  • src/gateway/static/dashboard/assets/ModelScopeControl-DyHkbW6e.js
  • src/gateway/static/dashboard/assets/ModelsPage-DNqcJtqR.js
  • src/gateway/static/dashboard/assets/ModelsPage-uwVSUUQm.js
  • src/gateway/static/dashboard/assets/OverviewPage-Dl956LT-.js
  • src/gateway/static/dashboard/assets/ProvidersPage-TstjvtEF.js
  • src/gateway/static/dashboard/assets/RoutingPage-CZeNudr_.js
  • src/gateway/static/dashboard/assets/RoutingPage-D1os8M2m.js
  • src/gateway/static/dashboard/assets/SettingsPage-DnyaGTEO.js
  • src/gateway/static/dashboard/assets/TablePagination-B55cx-co.js
  • src/gateway/static/dashboard/assets/ToolsGuardrailsPage-B0d257Kp.js
  • src/gateway/static/dashboard/assets/UsagePage-3rqO5_Gv.js
  • src/gateway/static/dashboard/assets/UsersPage-ij9p_J3R.js
  • src/gateway/static/dashboard/assets/index-2gdkqFtD.js
  • src/gateway/static/dashboard/assets/index-B9YHHu7Y.css
  • src/gateway/static/dashboard/assets/index-D-R1nuKP.js
  • src/gateway/static/dashboard/index.html
  • tests/integration/test_config_env_loading.py
  • tests/integration/test_routing_learned.py
  • tests/unit/test_knn_router.py
  • tests/unit/test_routing_compiler.py
  • tests/unit/test_routing_decide.py
  • tests/unit/test_routing_signal.py
  • web/src/api/hooks.ts
  • web/src/api/types.ts
  • web/src/components/RouterReadiness.tsx
  • web/src/pages/RoutingPage.test.tsx
  • web/src/pages/RoutingPage.tsx
💤 Files with no reviewable changes (3)
  • src/gateway/static/dashboard/assets/RoutingPage-D1os8M2m.js
  • src/gateway/static/dashboard/assets/index-D-R1nuKP.js
  • src/gateway/static/dashboard/assets/ModelsPage-uwVSUUQm.js
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/index.md
  • src/gateway/api/deps.py
  • src/gateway/models/entities.py
  • docs/routing-scaling.md

Comment thread alembic/versions/f0a1b2c3d4e5_add_router_tables.py
Comment thread src/gateway/api/routes/responses.py
Comment thread src/gateway/api/routes/routing_memory.py
Comment thread src/gateway/api/routes/routing_memory.py
Comment thread src/gateway/core/config.py
Comment thread src/gateway/services/routing/decide.py Outdated
Comment thread src/gateway/services/routing/knn.py
Comment thread src/gateway/services/routing/knn.py
Comment thread src/gateway/services/routing/knn.py
Comment thread web/src/pages/RoutingPage.tsx
A router is an optimization, so it must never be the reason a request cannot be
served. Backends already decline on the failures they can name (a cold pool, an
embedding error, a candidate with no pricing), but ranking also reads the
database, so a failure there surfaced as a 500 for a request the policy's default
target could have served.

decide_ordering now wraps the backend call: any exception warns and declines, so
the default target serves. One guard at the seam covers every backend and every
failure rather than the ones each backend anticipated.

Found in review of #188 by @khaledosman, whose budget-leak finding this is the
tail of. The leak itself is structurally gone (the router runs before the
reservation is taken, not after), but the 500 was still reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 6, 2026 15:03 — with GitHub Actions Inactive
njbrake and others added 7 commits August 6, 2026 15:08
Validation canonicalized a score key to decide whether to accept it, while
record_preference stored the raw string and knn._score matched candidates by
exact string. So `openai/gpt-3.5-turbo` against a policy naming
`openai:gpt-3.5-turbo` was accepted with a 200 and then never matched: the
candidate vanished from the score map and the *expensive* model won unopposed at
confidence 1.0, with a rationale that reads like the router working. The pool
reported warm, no route could delete the records, and docs/routing.md promises a
400 for keys that do not name candidates, so a 200 reads as "usable".

Validation now returns the mapping from the key as sent to the spelling its policy
uses, and rank rewrites each example before storing. Two spellings of one
candidate in one example is a 400 rather than one score silently discarded. With
no learned policy for the user, keys are stored as sent, as before.

Known residual: two learned policies for one user that spell the same model
differently normalize to one spelling, so the other policy's lookup still misses.
Storing the canonical form and canonicalizing the pool at lookup would close the
class; tracked on #187.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate was `spec.router_backend is not None`, which is true for the whole
policy rather than for this request. Select entries are evaluated in order, so a
`when` entry ahead of the router wins outright and the ranking is discarded, after
the request has already paid for an embedding call and a scan of the user's stored
examples. It also emitted the router's decision log line naming a model that did
not serve, which is worse than the wasted work: it makes the log lie.

`selection_consults_router` walks `select` exactly as `_select_head` does and
lives beside it in the compiler, so the two cannot disagree about which entry
wins. Pure and synchronous like the rest of that module.

The dashboard emits `[conditions, router, default]` for a policy with both, so
this is the shape the form produces, not a hypothetical one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three problems in one area, all raised by CodeRabbit against the current code.

Eviction deleted with `id NOT IN (<kept ids>)`, binding one host parameter per
kept row. That is up to `router_max_records_per_user` parameters (5000 by default)
in a single statement, and SQLite caps them at 999 on builds before 3.32, so the
query that keeps the store bounded would itself fail on the default configuration.
It now deletes rows strictly older than the oldest row being kept. Rows sharing
that timestamp survive, so a batch written in one tick is never half evicted and
the count can sit slightly above the cap until the next write.

The per-request read had no limit and no ordering. Eviction is enforced lazily on
write and only over a user's whole set, so nothing stopped a task partition from
growing past the cap, and one decision would load and cosine-score every row it
found. It now takes the newest `router_max_records_per_user` rows, which is the
bound the operator already configured, with the same newest-first rule eviction
uses. `0` means eviction is off, which is not a licence for an unbounded select,
so the read falls back to the default bound. The field's description and
docs/configuration.md now say that, since `0` otherwise reads as a hard cap.

The migration and the ORM both gain `(user_id, embedding_model, task_id)`. A
task-scoped read and the seed gate that counts the same partition both filter on
all three; without it they walk every record the user has for that embedding model.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`task_id` was stored exactly as submitted while the request side trims
`Otari-Router-Task` and treats blank as absent, so a label written as " support "
created a partition that `Otari-Router-Task: support` could never reach, and
`/status` listed it as a real pool with real records.

A whitespace-only prompt passed `min_length=1`, and `record_preference` then
returned 0 for it: an audit row was written, no routing-memory row was, and the
`recorded` count in the response agreed with neither.

Both are now field validators on the example, so they apply per example in a batch
and the error names the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guard added in b3473ef covers a backend that raises, not one that hangs. The
work behind a ranking is an outbound embedding call plus a read of the stored
examples, neither with a deadline of its own, so a hung embedding provider held the
request open for as long as its own client allowed. That breaks the same premise
the guard was for: a router is an optimization, so it can be neither the reason a
request fails nor the reason it hangs.

Five seconds, then decline to the policy's default target. Expiring costs the
caller the cheaper model, not the request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`instructions` is part of the task, not decoration: the same `input` under "answer
in one word" and "write a rigorous proof" are different jobs with different quality
bars. The signal embedded only `input`, so both got the same kNN decision and, under
trace-sticky granularity, the same conversation identity.

Guardrails keep reading `input` alone, which is correct for them: they screen what
the user sent. Routing has to read what the model was actually asked to do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The policy form told the operator to open **Router** on the row, which is what the
row action was called before it became **Examples**. Regenerated bundle and specs,
the latter because the rank endpoint's docstrings changed in this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests August 6, 2026 15:23 — with GitHub Actions Inactive
Only generated artifacts conflicted: docs/public/openapi.json, and 33 files in
the committed dashboard bundle where both sides had rebuilt it under different
content hashes (rename/rename and modify/delete churn). Neither is hand-authored,
so both were regenerated from the merged source rather than hand-merged: the
bundle by deleting it and running the build (emptyOutDir prunes stale hashes),
the spec and Postman collection by regenerating from the merged app.

No source file conflicted. main's work landed in streaming keepalives, usage
filters, SDK codegen, and the dashboard's usage views, none of which touch where
the routing code sits.

One semantic conflict the merge could not show: main added
tests/unit/test_sdk_endpoint_coverage.py, which requires every endpoint in the
spec to be classified in scripts/sdk_codegen/sdk-endpoints.txt. This branch adds
two, so they are now listed as not yet wrapped, alongside the routing-policy
routes they belong with.

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

njbrake commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

@besaleli and I chatted and aligned on project direction, agreeing that this PR was the appropriate first step to set the groundwork for future routing work and behavior 🚀

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.

3 participants