Skip to content

feat(router): per-tenant kNN model router with preference collection - #188

Open
njbrake wants to merge 11 commits into
mainfrom
feat/router-knn
Open

feat(router): per-tenant kNN model router with preference collection#188
njbrake wants to merge 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

Adds an optional, learn-from-your-own-data model router to Otari (standalone mode) so easy prompts can be served by a cheaper model while hard prompts stay on the strong one. Off by default; when disabled the chat path is byte-for-byte unchanged.

The work lands in layers:

  1. Seam (default off). A pluggable RouterBackend protocol (RoutingContext / RoutingDecision) shaped like SandboxBackend / WebSearchBackend, plus a no-op backend and the OTARI_ROUTER_BACKEND flag. With none (default) the chat handler behaves as it did before.
  2. kNN routing memory (OTARI_ROUTER_BACKEND=knn). KnnRoutingMemory embeds the task signal via any_llm and runs per-tenant cosine kNN over one record per scored example (the prompt embedding plus a {model: quality} map), scoring each candidate as mean_quality(m|neighbors) - alpha * normalized_cost(m). Cold start, a sparse neighborhood, sub-floor confidence, tool requests, and an unpriced candidate pool all fall back to the requested model, so the safe default is never worse than no routing.
  3. Conversation stickiness. trace_sticky granularity (default) decides once per conversation and reuses that decision on later turns. The trace identity is a client-supplied Otari-Conversation-Id header, or a per-tenant hash of the conversation's opening messages when absent. A client can skip routing for one request with Otari-Router: off.
  4. Per-task partitions. A request carrying Otari-Router-Task votes only over that task's records and warms independently of other tasks; preferences are filed into a partition with an optional task_id on /rank. Without a task, everything shares one default pool.
  5. Preference collection (standalone only). POST /v1/router/preferences/compare fans a prompt out to N models; /rank records per-model quality scores (0 to 1) as one routing-memory record per example; GET /v1/router/status reports the per-pool overview (the default pool plus each task partition, with record counts and warmth).
  6. Pricing. The router scores by cost, so every model in OTARI_ROUTER_CANDIDATES must have configured pricing or the gateway fails fast at startup.
  7. Storage. RoutingMemory (one record per example, embedding_model invalidation tag, per-tenant cap, oldest-first eviction) and RouterPreference (preference audit) plus an Alembic migration.

Config: OTARI_ROUTER_BACKEND, OTARI_ROUTER_CANDIDATES, OTARI_ROUTER_ALPHA, OTARI_ROUTER_K, OTARI_ROUTER_EMBEDDING_MODEL, OTARI_ROUTER_CONFIDENCE_FLOOR, OTARI_ROUTER_SEED_COUNT, OTARI_ROUTER_GRANULARITY, OTARI_ROUTER_MAX_VECTORS_PER_TENANT.

Try it

A self-contained, fully offline demo lives in demo/router/ (no gateway, no API key, nothing called at runtime):

cd demo/router && npm install && npm run dev   # http://localhost:5180
  • How it learns: a step-by-step walkthrough of the preference API the operator drives, an empty-store status check, /preferences/compare, scoring each answer (the gpt-5.4 judge's 0 to 1 scores, editable), /preferences/rank and the routing-memory record it writes, and watching each task partition warm independently.
  • See it route: a click-to-advance walkthrough of the kNN decision, request and headers, safety gates, embedding, the partition-scoped kNN neighbors, predicted per-model quality, quality-minus-cost scoring with a live cost dial, and the resolved pick with cost saved.

The bundled data (demo/router/src/demo_prompts.json) is a four-tier ladder (gpt-5.4 / gpt-4o / gpt-4o-mini / gpt-3.5-turbo) over general-knowledge, hard-code, and hard-math prompts, built by demo/router/generate_demo_dataset.py. See demo/router/README.md.

The kNN scoring design was validated offline against the RouterBench dataset during development: the router beat a cost-matched blend of cheap and strong models, and a clear gap to the oracle confirmed the signal is region-level rather than per-prompt, which motivates the task-metadata fast-follow. That exploratory harness is not part of this PR.

Deviations from the v1 plan in #187

This PR ships a v1 of #187 but does not match the original v1 checklist exactly:

  • Deferred to fast-follow: the cache-aware effective_cost term and switch penalty (with OTARI_ROUTER_SWITCH_PENALTY), and the RoutingOutcome / record_outcome passive-learning hook. Scoring is currently on normalized list price, and only the preference flow writes memory.
  • Added beyond the original v1: hard per-task partitions (Otari-Router-Task), the Otari-Conversation-Id identity for trace stickiness, the per-pool /status overview, require-pricing for candidates, and a routing-at-scale design note (docs/routing-scaling.md).

What remains for #187 (fast-follow)

  • Cache-aware effective cost plus a switch penalty (prompt-cache economics), so routing is never net-negative on cached agent traces.
  • Standalone multi-attempt execution so routing can cascade through the ordered candidate list instead of serving a single pick.
  • Passive learning from live traffic with an implicit or judge-assisted quality signal (re-introducing the record_outcome seam).
  • Latency tracking, needed for a fastest strategy and for latency reporting.
  • Strategy backends (cheapest / fastest).
  • Step-level (per-call) routing using the cache-aware cost model (step granularity exists but scores on list price).
  • Savings-analysis command over UsageLog ("a cheaper model would have sufficed on X%").
  • Cost-quality eval harness (RouterBench style) and the held-out cost-quality curve acceptance criterion.
  • pgvector or ANN backend for scale past a few thousand vectors per tenant.
  • A vision / context-window capability registry (only minimal tool-gating today).
  • Platform/hybrid-mode routing memory (the router is standalone-only).
  • Trained-router plugin (RouteLLM or NotDiamond style).

PR Type

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

Relevant issues

Implements a v1 of #187, with the deviations noted above. Fast-follow items remain, so this does not close the issue.

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

Note on test scope: ruff and mypy are clean (187 files); the full unit suite (532) passes; the router unit and integration suites pass. The Docker-based testcontainers path was not available in my environment, so the router integration suite was run against SQLite via TEST_DATABASE_URL rather than testcontainers PostgreSQL.

AI Usage

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

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

Any additional AI details you'd like to share: Code, tests, docs, and the demo were generated by Claude through iterative back-and-forth with @njbrake, who directed the design decisions (standalone-first, kNN default, preference-collection flow) and reviewed the output.

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

What Changed

This PR adds a model routing system that intelligently selects which AI model to use for each request, optimizing for cost while maintaining quality. The router learns from user feedback and automatically routes straightforward requests to cheaper models while keeping complex ones on more capable (expensive) models.

Core Components Added

Backend Service: A new kNN-based routing engine that:

  • Learns from operator-provided model quality preferences
  • Selects candidate models based on similarity to past requests
  • Balances cost savings against quality using a tunable dial
  • Falls back safely to the requested model when uncertain

API Endpoints (standalone mode only):

  • POST /v1/router/preferences/compare — collect responses from multiple candidate models
  • POST /v1/router/preferences/rank — record quality scores for model responses
  • GET /v1/router/status — check router warmup progress per tenant

Configuration: Nine environment variables control backend selection, candidate models, routing aggressiveness, and safety thresholds

Database: Two new tables (routing_memory, router_preferences) with Alembic migration to store embeddings, quality scores, and preference audit trails

Request Control:

  • Otari-Conversation-Id header enables "sticky" routing (same model within a conversation)
  • Otari-Router: off header opts a single request out of routing
  • Otari-Router-Task header partitions routing by task type, maintaining independent learning per partition

Demo Application

A fully offline React/Vite application (demo/router/) with:

  • Preference collection walkthrough showing how to teach the router
  • Routing visualization walkthrough showing how kNN selection works
  • Bundled dataset with pre-embedded prompts and judge-scored model answers
  • Python script to generate datasets with real models (requires OpenAI API key)

Safety Guards

Routing only occurs when conditions are met:

  • Router has seen enough preference examples (configurable seed count)
  • Neighborhood support for the chosen model is sufficient (confidence floor)
  • No safety-critical tools are present in the request
  • All required cost data is available

Benefits

  • Cost optimization without application changes (feature flag to enable)
  • Byte-for-byte compatible when disabled
  • Per-tenant learning (isolated routing decisions per customer)
  • Composable design (pluggable backend interface for future implementations)
  • Offline demo for understanding routing behavior without running servers

Documentation

  • Setup guide with environment variable reference
  • Scaling design note explaining current v1 limitations and target durable architecture for multi-replica deployments
  • API reference additions for new endpoints

@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, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 12 minutes and 28 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

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

🚦 How do rate limits work?

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

For paid Pro and Pro+ PR reviews, CodeRabbit uses rolling per-developer review limits. Reviews become available again as older review attempts age out of the rolling limit window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 68ff6bf9-9623-42b9-ae7e-729be69859fe

📥 Commits

Reviewing files that changed from the base of the PR and between 6418830 and 9cbab5d.

⛔ Files ignored due to path filters (1)
  • docs/public/openapi.json is excluded by !docs/public/openapi.json
📒 Files selected for processing (7)
  • demo/router/src/components/StepWalkthrough.tsx
  • demo/router/src/main.tsx
  • src/gateway/api/routes/router.py
  • src/gateway/models/entities.py
  • src/gateway/services/knn_router.py
  • tests/integration/test_config_env_loading.py
  • tests/integration/test_router_preferences.py

Walkthrough

Adds a cost-optimizing kNN model router to the Otari gateway. The backend selects cheaper candidate models using per-tenant embedding-based nearest-neighbor voting, with preference collection endpoints, chat integration, trace-sticky conversation pinning, and task partitioning. Includes a standalone offline React demo app and full docs.

Changes

Gateway kNN Router Feature

Layer / File(s) Summary
Config fields, ORM entities, and routing protocol
src/gateway/core/config.py, src/gateway/services/router_backend.py, src/gateway/models/entities.py, alembic/versions/f0a1b2c3d4e5_add_router_tables.py
Adds nine GatewayConfig router fields with Pydantic validators, three HTTP header constants, RoutingContext/RoutingDecision/RouterBackend protocol types, NoOpRouterBackend, a per-config-signature backend cache with get_router_backend(), RoutingMemory and RouterPreference SQLAlchemy models, and the Alembic migration creating both tables with supporting indexes.
KnnRoutingMemory algorithm
src/gateway/services/knn_router.py
Implements the full kNN routing backend: route() control flow with tools gate, trace-sticky in-process LRU, embedding signal selection, cold-start/sparse-neighborhood fallbacks; _score() with cost-normalized neighbor voting and confidence floor; record_preference() write path; tenant eviction; pricing and embedding helpers; trace-identity hashing; and validate_router_pricing() startup check.
Router API endpoints and chat reroute
src/gateway/api/routes/router.py, src/gateway/api/routes/chat.py, src/gateway/api/main.py, src/gateway/api/deps.py, src/gateway/main.py
Adds POST /v1/router/preferences/compare, POST /v1/router/preferences/rank, and GET /v1/router/status endpoints with Pydantic schemas and kNN-only enforcement; integrates rerouting into standalone streaming and non-streaming chat paths via routing header helpers and _resolve_standalone_model; registers the router in main.py; clears backend cache on reset_config; and awaits validate_router_pricing at hybrid-mode startup.
Unit and integration tests
tests/unit/test_router_header.py, tests/unit/test_router_backend.py, tests/unit/test_knn_router.py, tests/integration/test_config_env_loading.py, tests/integration/test_router_preferences.py, tests/integration/test_router_seam_e2e.py
Unit tests for header parsing, backend seam behavior, and kNN routing internals (passthrough guards, cost-biased voting, confidence floor, trace stickiness, embedding signal modes). Integration tests for preference endpoints, end-to-end chat routing (cold/warm, tenant isolation, task partitions, trace-sticky), chat seam reroute/streaming/platform-mode edge cases, and all new config env-loading knobs.

React Router Demo App

Layer / File(s) Summary
Demo data types, router simulation, and dataset generator
demo/router/src/types.ts, demo/router/src/router-sim.ts, demo/router/src/demo-config.ts, demo/router/src/format.ts, demo/router/generate_demo_dataset.py
Defines TypeScript interfaces for the demo data model (DemoModel, DemoResponse, DemoItem, DemoData), implements the client-side explainOne() leave-one-out kNN simulator with full explanation types, exports SEED_COUNT and qualClass, and provides the Python script that calls OpenAI to generate the bundled demo_prompts.json dataset.
React components and app shell
demo/router/src/App.tsx, demo/router/src/components/*, demo/router/src/main.tsx, demo/router/src/globals.css, demo/router/src/styles/brand-tokens.css
Adds the App shell with teach/showcase tab switching, StepWalkthrough/StepGrid reusable step primitives, TeachWalkthrough (6-step preference-collection walkthrough), Walkthrough (8-step interactive kNN routing explainer with alpha dial), KnnGraph (force-directed SVG visualization), CodeJson (syntax-highlighted JSON renderer), and Button/Panel UI primitives.
Project scaffolding and docs
demo/router/package.json, demo/router/tsconfig.json, demo/router/vite.config.ts, demo/router/index.html, demo/router/.gitignore, demo/router/README.md, docs/routing.md, docs/routing-scaling.md, docs/configuration.md, docs/api-reference.md, docs/index.md
Adds Vite/TypeScript/Tailwind build setup, the HTML entry point, and the demo README. Also adds docs/routing.md (algorithm, setup, preference collection, headers, task partitions, limits), docs/routing-scaling.md (v1 in-process design and durable target architecture), and updates the configuration and API reference docs.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • mozilla-ai/otari#130: Both PRs modify the chat_completions dispatch flow in src/gateway/api/routes/chat.py; #130 refactored the pipeline structure that this PR extends with standalone model rerouting via routing header helpers and _resolve_standalone_model.

Suggested reviewers

  • tbille
  • khaledosman
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title follows Conventional Commits format with 'feat:' prefix, uses imperative mood, is under 70 characters, and clearly describes the main change: adding a kNN router with preference collection.
Description check ✅ Passed Description is comprehensive with detailed technical explanation, layers of implementation, configuration details, demo instructions, deviations from plan, and all required checklist items completed.
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.

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

✨ Finishing Touches
🧪 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 and others added 3 commits June 23, 2026 12:39
Optional, learn-from-your-data model router for standalone mode, so easy prompts
can be served by a cheaper model while hard prompts stay on the strong one. Off
by default (OTARI_ROUTER_BACKEND); when disabled the chat path is unchanged.

- Pluggable RouterBackend seam (RoutingContext / RoutingDecision) plus a no-op
  backend, shaped like the sandbox / web-search backends.
- KnnRoutingMemory: per-tenant cosine kNN over (embedding, model, quality, cost)
  records, scoring mean_quality - alpha * normalized_cost. Cold start, sparse
  neighborhoods, sub-floor confidence, and tool requests fall back to the
  requested model, so routing is never worse than off.
- trace_sticky granularity reuses a conversation's first decision, keyed on an
  Otari-Conversation-Id header; hard per-task partitions are selectable per
  request via Otari-Router-Task and warm independently; Otari-Router opts a
  single request out.
- Preference API (standalone only): /v1/router/preferences/compare and /rank,
  and a per-pool /v1/router/status overview. Storage is RoutingMemory plus a
  RouterPreference audit row with an Alembic migration; the new config fields are
  validated at load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unit tests for the scoring, confidence, trace-key, and granularity logic, the
default-off seam, and the request headers. Integration tests drive the FastAPI
app over SQLite/Postgres: the preference endpoints, end-to-end routing through
the chat seam, per-tenant and per-task isolation, and the status overview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add docs/routing.md (setup, the Otari-Router / Otari-Conversation-Id /
Otari-Router-Task headers, task partitions, tuning) and docs/routing-scaling.md
(how trace stickiness behaves across replicas and the target shared-store
design). Update the configuration and API references and regenerate the OpenAPI
spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A self-contained, offline demo (demo/router/) with two guided walkthroughs:
"How it learns" steps through the preference API (the per-tenant/per-task model,
compare, scoring, rank, and per-partition warm-up), and "See it route" animates
the kNN decision (gates, embedding, neighbors, quality-vs-cost scoring, and the
resolved pick). No gateway or provider calls at runtime; the prompts, answers,
and embeddings are bundled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The demo and guide threaded task_id through the canonical rank and chat
requests, which made it read as required. Drop it from the demo's default rank
payload and 200 OK, and introduce it only as an opt-in: the isolation callout
now shows how to add task_id plus the Otari-Router-Task header, and both that
header and Otari-Router are marked optional in "See it route". Reword the warm
step and the routing guide so per-task partitioning is clearly the optional
layer over the per-tenant default pool, and fix a stale
GET /v1/router/status?task_id reference (status now returns the full overview).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
njbrake and others added 3 commits June 23, 2026 13:57
The "See it route" request carries Otari-Router-Task: <task>, but the demo's
kNN sim searched every task, so the neighbor list and graph mixed partitions,
inconsistent with the filter the request advertises. Route the sim within the
query's task partition (only same-task prompts are candidate neighbors), draw
just the searched partition in the graph (other tasks dimmed, no edges), and
drop the caveat that said the demo searches one shared store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
record_preference wrote one row per scored model, all sharing the prompt's
embedding, so the kNN treated N rows of one example as N independent neighbors.
That conflated examples with rows: the seed gate counted rows (~N x examples),
the top-k neighborhood was dominated by the single nearest example's N rows, and
the confidence metric was structurally capped near 1/N. /rank also reported
recorded = number of models (e.g. 4 for one ranked prompt), which is what
surfaced in the demo as "one example, four samples".

Store one record per example: the prompt embedding plus a {model: quality} map
(RoutingMemory.qualities replaces model/quality/cost; migration updated). The
kNN now votes over distinct prompts, the seed gate and /status counts are in
examples, /rank returns recorded = 1, and confidence is the genuine share of the
k neighbor prompts whose own best-scoring model is the chosen one.

Demo: the rank step shows one record with the per-model score map and
recorded: 1; the warm step counts examples (shared SEED_COUNT lowered to 3).
Docs and tests updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts:
- chat.py: keep both the router header imports (CONVERSATION_HEADER,
  ROUTER_HEADER, ROUTER_TASK_HEADER) and main's new GatewayUsage import.
- config.py: keep the router_* fields and adopt main's updated mode/platform
  descriptions ("standalone or hybrid", "otari.ai connection settings").
- Alembic: main added a migration that reused revision id d4e6f8a0b2c4, the same
  id as the router tables migration. Re-chain the router migration to a new id
  (f0a1b2c3d4e5) descending from main's d4e6f8a0b2c4, giving a single linear head.
- Fix a warn_return_any mypy error in test_router_seam_e2e._post_chat surfaced by
  main's updated test deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 15:20 — with GitHub Actions Inactive
The guide and API reference drifted from the code after later branch changes:

- routing.md step 3 used a "ranking" list and showed {recorded: 2, tenant_warm}.
  The rank API takes a required `scores` map ({model: 0..1}); there is no
  `ranking` field, so the example 422s. The response is {recorded, task_id,
  warm} with recorded = 1 (one record per example). Reword the score-don't-rank
  flow to match.
- "How it works" described records as (embedding, model, quality, cost); they
  are now one per example: (embedding, {model: quality}).
- api-reference.md: rank writes one record of per-model scores (not a
  rank-to-scalar ranking); /status returns the default pool plus each task
  partition, not a single record count.
- Note the low-confidence safety net only applies when a confidence floor is set
  (off by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@njbrake
njbrake temporarily deployed to integration-tests June 23, 2026 15:30 — with GitHub Actions Inactive
…ndidate pool

Two router correctness fixes (both in knn_router.py, so kept together):

- Require pricing for every OTARI_ROUTER_CANDIDATES model. The router scores by
  cost, so an unpriced candidate has nothing to weigh. Drop the _UNKNOWN_PRICE
  fallback (now dead): validate at startup (fail fast with RouterPricingError)
  and raise again at request time if a candidate is unpriced.
- Confidence counts only candidate models. A neighbor's favorite model that is
  not in the routing pool no longer collapses confidence to 0, which, with a
  confidence floor set, would have vetoed a correct cheaper pick back to the
  pricier requested model.

Adds a startup-validation integration test (the harness now configures the
provider + pricing for its candidates) and a confidence unit test. Docs note
that candidates must be priced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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

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.

I built this demo as a nice way to walkthrough how the knn routing is working but I'm also open to deleting this if it feel unnecessary to commit to git

@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
Comment thread demo/router/src/components/TeachWalkthrough.tsx
Comment thread demo/router/src/main.tsx Outdated
Comment thread demo/router/src/router-sim.ts
Comment thread src/gateway/api/routes/router.py Outdated
Comment thread src/gateway/services/knn_router.py
Comment thread src/gateway/services/knn_router.py
Comment thread src/gateway/services/knn_router.py
Comment thread tests/integration/test_config_env_loading.py
Comment thread tests/integration/test_router_preferences.py
- /preferences/compare: cap the model fan-out (max 8) so one request cannot
  launch unbounded concurrent provider calls, and return a generic per-model
  error instead of the raw provider exception text.
- knn_router: log the embedding-failure exception class only (not raw text,
  which can carry request content), and wrap the routing-memory and eviction
  commits in try / SQLAlchemyError rollback per the project's DB-write convention.
- /preferences/rank: write the routing-memory record before the audit row, so a
  failure never leaves an orphan RouterPreference row without its RoutingMemory.
- Refresh stale "one record per scored model" docstrings (one record per example
  now) on the router module and RouterPreference.
- Tests: use the real OPENAI_API_KEY in the live integration client (the pricing
  fix had hardcoded a placeholder), and clear the legacy GATEWAY_ROUTER_* env
  namespace in the router-defaults test.
- Demo: arrow-key navigation ignores interactive controls (score sliders), and
  main.tsx uses the named createRoot import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an optional, standalone-only model routing system to the gateway, including a pluggable router backend seam and a kNN-based routing-memory implementation that can be trained via preference collection endpoints.

Changes:

  • Introduces a RouterBackend protocol with none (default), noop, and knn backends, plus request headers to control routing and conversation stickiness.
  • Implements kNN routing memory (embedding, per-tenant/task partitions, confidence gating, trace-sticky caching) and persists training/audit data via new DB tables and migration.
  • Adds standalone router preference and status endpoints, documentation, OpenAPI updates, and an offline demo app.

Reviewed changes

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

Show a summary per file
File Description
src/gateway/core/config.py Adds router config knobs and header constants, plus validation for backend and granularity.
src/gateway/services/router_backend.py Defines the router backend seam, no-op backend, and cached knn backend factory.
src/gateway/services/knn_router.py Implements the kNN routing-memory algorithm, persistence, trace stickiness, and pricing validation.
src/gateway/api/routes/chat.py Wires routing into the standalone chat path with opt-out and conversation/task headers.
src/gateway/api/routes/router.py Adds standalone preference collection endpoints (compare, rank) and routing-memory status.
src/gateway/api/main.py Registers the router endpoints in standalone mode only.
src/gateway/api/deps.py Clears the router backend cache during test config resets.
src/gateway/models/entities.py Adds RoutingMemory and RouterPreference ORM entities.
src/gateway/main.py Validates router candidate pricing at startup when router_backend=knn.
alembic/versions/f0a1b2c3d4e5_add_router_tables.py Creates the routing_memory and router_preferences tables and indexes.
tests/unit/test_router_backend.py Unit tests for router backend selection and noop behavior.
tests/unit/test_router_header.py Unit tests for Otari-Router, Otari-Conversation-Id, and Otari-Router-Task parsing.
tests/unit/test_knn_router.py Unit tests for kNN scoring, confidence, tool gating, and stickiness logic.
tests/integration/test_router_seam_e2e.py End-to-end tests proving routing is inert by default and applied on standalone (including streaming).
tests/integration/test_router_preferences.py Integration tests for preference endpoints, warming, task partitions, and startup pricing enforcement.
tests/integration/test_config_env_loading.py Tests config defaults and OTARI env overrides for router settings.
docs/routing.md User documentation for enabling, training, and operating routing.
docs/routing-scaling.md Design note for stickiness behavior under scale and planned durable design.
docs/configuration.md Adds configuration reference for router env vars.
docs/api-reference.md Documents new router endpoints in the API reference table.
docs/index.md Links to routing docs from the docs index.
docs/public/openapi.json Updates generated OpenAPI spec with new schemas and endpoints.
demo/router/package.json Adds an offline demo app package manifest and deps.
demo/router/vite.config.ts Vite config for the router demo.
demo/router/tsconfig.json TypeScript config for the demo app.
demo/router/index.html Demo app HTML entry point.
demo/router/.gitignore Demo-specific ignore rules.
demo/router/README.md Demo instructions and explanation.
demo/router/src/main.tsx Demo React entry.
demo/router/src/App.tsx Demo app view switch and top-level layout.
demo/router/src/types.ts Demo data types.
demo/router/src/router-sim.ts Client-side reproduction of kNN routing for the offline demo.
demo/router/src/demo-config.ts Shared demo seed gate constant.
demo/router/src/format.ts Shared score formatting helper.
demo/router/src/globals.css Demo global styles and imports.
demo/router/src/styles/brand-tokens.css Brand token CSS variables for theming.
demo/router/src/components/StepWalkthrough.tsx Shared step walkthrough component with keyboard nav.
demo/router/src/components/TeachWalkthrough.tsx Demo walkthrough for preference collection and warming.
demo/router/src/components/Walkthrough.tsx Demo walkthrough for explaining routing decisions.
demo/router/src/components/KnnGraph.tsx Demo visualization of prompt neighborhood graph.
demo/router/src/components/CodeJson.tsx Demo JSON pretty-print block with simple highlighting.
demo/router/src/components/ui/button.tsx Demo UI button wrapper.
demo/router/src/components/ui/card.tsx Demo UI panel wrapper.
demo/router/src/components/ui/index.ts Demo UI exports.
demo/router/generate_demo_dataset.py Script to generate the bundled demo dataset using live models.

Comment on lines +208 to +209
prices = await self._candidate_prices(pool)
decision = self._score(ctx, pool, neighbors, prices)
Comment on lines +128 to +155
@router.post("/preferences/compare", response_model=CompareResponse)
async def compare_models(
request: CompareRequest,
api_key: Annotated[APIKey, Depends(verify_api_key)],
config: Annotated[GatewayConfig, Depends(get_config)],
) -> CompareResponse:
"""Fan the prompt out to every candidate model concurrently."""
messages: list[dict[str, Any]] = []
if request.system:
messages.append({"role": "system", "content": request.system})
messages.append({"role": "user", "content": request.prompt})

async def _one(model: str) -> ModelResponse:
try:
provider, bare = AnyLLM.split_model_provider(model)
kwargs = get_provider_kwargs(config, provider)
if request.max_tokens is not None:
kwargs["max_tokens"] = request.max_tokens
result = await acompletion(model=bare, provider=provider, messages=cast(Any, messages), **kwargs)
choices = getattr(result, "choices", None)
content = choices[0].message.content if choices else None
return ModelResponse(model=model, content=content)
except Exception as exc: # one model failing must not sink the comparison
logger.warning("compare: model %s failed (%s)", model, type(exc).__name__)
return ModelResponse(model=model, content=None, error="model invocation failed")

responses = await asyncio.gather(*[_one(m) for m in request.models])
return CompareResponse(prompt=request.prompt, responses=list(responses))
Comment thread docs/api-reference.md
Comment on lines +98 to +102
| Method | Path | Description | Auth |
|--------|------|-------------|------|
| `POST` | `/v1/router/preferences/compare` | Fan a prompt out to several candidate models and return their responses side by side. | API key or master key |
| `POST` | `/v1/router/preferences/rank` | Record per-model quality scores (0–1) for a prompt; writes one routing-memory record. Requires the `knn` backend. | API key or master key |
| `GET` | `/v1/router/status` | Report routing-memory progress: the default pool plus each task partition, with record counts and warmth. | API key or master key |

@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 (gateway source, migration, tests, docs, demo) against AGENTS.md and .github/instructions/security-review.instructions.md. Solid, well-layered work: the seam is genuinely inert by default, the kNN logic is clean and well-tested, tenant isolation is done right, and billing correctly attributes the routed model (tested). Four inline comments below, two of them budget/settlement issues per the repo's own security checklist.

Two notes that don't fit a line:

  • The branch is 56 commits behind main, including #275 (completion routes consolidated onto the shared pipeline), so the chat.py changes will conflict on rebase. Main also now requires scripts/generate_postman.py regeneration (make postman-check) for API changes; that script doesn't exist on this branch, so the new /v1/router/* endpoints will need it post-rebase.
  • +1 to Copilot's still-unaddressed point that docs/api-reference.md lists the router endpoints as "API key or master key" while verify_api_key rejects the master key (it isn't in the api_keys table). Docs should say "API key", and docs/routing.md could note the master key can't be used to teach (it has no tenant).

This review was created by Claude Code.


provider, model = AnyLLM.split_model_provider(request.model)
call_kwargs = {**get_provider_kwargs(config, provider), **request_fields}
routed_model = await _resolve_standalone_model(

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.

Budget-reservation leak: _resolve_standalone_model runs after resolve_request_context has taken the reservation but outside run_standalone_non_stream's settlement, and nothing here releases it on error (same at the streaming call site, line 485). backend.route() can raise: RouterPricingError when the requested model has no pricing (require_pricing=false, or a pricing row deleted after startup; startup validation only covers configured candidates while the requested model joins the pool per request), or SQLAlchemyError from _load_records/_candidate_prices. Result: a 500 plus a permanently leaked reservation (spend + reserved creeps toward max_budget), which is the exact §0.3 pattern the security instructions call a finding, and it contradicts the knn_router module docstring's promise that an unpriced candidate pool falls back to the requested model.

Fix: extend the best-effort guard that already covers _embed to the whole decision, e.g. wrap the backend.route(...) call (in _resolve_standalone_model or in KnnRoutingMemory.route) in try/except Exception that logs the exception type and returns the requested model. That also makes the docstring true and adds a unit test slot next to test_embedding_failure_passes_through.

logger.warning("compare: model %s failed (%s)", model, type(exc).__name__)
return ModelResponse(model=model, content=None, error="model invocation failed")

responses = await asyncio.gather(*[_one(m) for m in request.models])

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.

Building on Copilot's budget-bypass point with the concrete exposure: any active user API key, including one whose budget is exhausted, can fan a request out to 8 arbitrary provider:model pairs per call, not just the configured router_candidates, with no budget reservation, no usage log, no check_rate_limit, and no require_pricing gate. Per the repo's security instructions (§0.3/0.4) a billable route without the reserve→settle lifecycle is a finding. The mitigation docs/routing.md suggests ("gate who holds keys that can reach it") has no mechanism: every chat-capable key can reach this endpoint, and the master key actually cannot (verify_api_key 401s it).

Suggested hardening, in order of value: (1) restrict request.models to the configured OTARI_ROUTER_CANDIDATES pool (they're the only models worth scoring anyway), (2) apply check_rate_limit, (3) meter the fan-out through the normal reserve/reconcile + usage-log path, or at minimum write usage rows so the spend is visible.

trace_key=conversation_id,
)
decision = await backend.route(routing_ctx)
return decision.ordered_models[0]

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.

Routing decisions are invisible in production: decision.confidence and decision.rationale are computed and well-tested but never logged or exposed, so an operator can only detect a reroute by diffing usage logs against requests. A logger.debug here with tenant, requested → chosen model, confidence, and rationale (all static strings, no user content) would make the first rollout debuggable and give the fast-follow eval work something to grep.

Comment thread demo/router/README.md

## Notes

- **Blind ranking** (Settings, on by default) hides which model wrote each answer

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.

Stale: the shipped demo has no Settings, no blind-ranking mode, and no localStorage use (nothing in src/ references any of these; it became a read-only walkthrough). Drop this bullet and the "Settings persist in localStorage" line.

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