Skip to content

feat(auto-routing): serve custom routers on the CLI and gateway paths - #7791

Merged
iscekic merged 17 commits into
mainfrom
feat/auto-custom-routers-serve
Oct 19, 2026
Merged

iscekic merged 17 commits into
mainfrom
feat/auto-custom-routers-serve

Conversation

@iscekic

@iscekic iscekic commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Implements the serving side of ADR 0005. Stacked on #7784.

What changes

Auto-routing worker

  • Custom router definitions are read from auto_routing_custom_routers through the existing Hyperdrive binding, with a short isolate cache.
  • POST /v1/decide (public, Kilo token):
    • Resolves the router by scope. An organization router needs the organization context (X-KiloCode-OrganizationId or the token's signed organization). The worker checks membership.
    • Checks the paying owner's balance before classifying. The organization pays in an organization context, within the member allowance.
    • Decides inside the pool and returns a signed decision ID (signAutoDecision).
  • POST /v1/eval/{plan,case,result} (public):
    • Require a router editor: the owning user, or an organization owner, admin, or billing manager.
    • Check the owner's balance.
    • Forward the router context (and grader billing for results) to the benchmark worker over BENCHMARK_SERVICE. The benchmark worker's status and body pass through.
  • Internal /decide accepts the optional customRouter and clientEntries and returns entry, stale, and fallback. Requests without customRouter take the same path as before.
  • Decision engine for a custom router:
    • Uses the router's custom table from the benchmark worker.
    • Drops candidates that are outside the pool, client entries the caller did not list, and denied models.
    • Takes the mode from the router and every other setting from the base router's table.
    • Keeps sticky state in the decision cache under custom:<routerId>.
  • Null decisions use the default entry, then the first entry the client can serve. Otherwise the request fails with custom_router_unavailable. The worker never routes outside the pool.
  • Classifier usage is billed with the custom router model ID as requested_model.

Shared access rules: new module @kilocode/worker-utils/account-access, used by both the worker and the gateway. It holds:

  • the organization context rule (the signed claim wins over the header)
  • the token pepper check
  • organization membership and member role
  • the organization member balance query and calculation
  • the paid-usage balance gate
  • blocked email domain parsing and matching

The gateway auth, balance, and blocked-domain code and the web callers of isOrganizationMember now use it. Gateway routing behavior is unchanged.

Organization model policy on the CLI path: the full policy (group-policy grants, provider limits, data-collection rule, direct-BYOK exemptions) depends on the gateway's provider runtime: provider definitions, model snapshots, and direct-BYOK model lists. Instead of copying it, a new internal gateway route POST /api/v1/internal/auto-routing/denied-models (internal secret) runs the gateway's own evaluation for named candidates. The CLI decide path sends it the router's gateway entries in an organization context, caches the answer for a minute, and denies every gateway entry when the gateway cannot answer. Every public worker route also rejects users in a blocked email domain.

Deploy notes

  • Secrets Store: NEXTAUTH_SECRET_PROD (already exists; now bound to this worker), AUTO_ROUTING_DECISION_SECRET_PROD (new; the gateway gets the same value in the next stacked PR), and AUTO_ROUTING_UPSTASH_REDIS_REST_URL_PROD / AUTO_ROUTING_UPSTASH_REDIS_REST_READONLY_TOKEN_PROD (new; the web app's Upstash database, with a read-only token).
  • Var KILO_AI_GATEWAY_BASE_URL points at the production gateway. Deploy the gateway route before the worker, because the worker fails closed on gateway entries in organization contexts until the route exists.

Known limits

  • Credit expiry: the worker reads balances without running lazy credit expiry, so a balance can look slightly high until the gateway processes that expiry. The gateway stays the final balance authority on gateway picks.
  • Blocked domains: the worker reads the Redis list only. The gateway's BLACKLIST_DOMAINS env fallback, which applies when Redis holds no list, is a Vercel env var that the worker does not have.

Verification

  • services/auto-routing: typecheck, full vitest suite.
  • packages/worker-utils: typecheck, full vitest suite.
  • packages/auto-routing-contracts: vitest.
  • packages/web-shared and apps/web: typecheck.
  • Jest suites for the denied-models evaluation (group policy, data-collection rule), the route facades, the refactored gateway auth, balance, and handlers, and for the migrated isOrganizationMember callers, including the DB-backed organization usage tests.
  • oxlint and oxfmt on the changed paths.

Adds ADR 0005 shared contracts: custom router model IDs, pool entry
schemas, the public decide API, client eval protocol schemas, web REST
schemas, and the signed auto-decision token. AutoRoutingModeSchema moves
next to AutoRouterSchema so the new module does not import the package
entry.
Inside the accuracy band a candidate whose estimated request cost is
exactly 0 outranks every paid candidate and drops the cost term
(ADR 0005), ordered by effectiveLatency^1.5 / wilson^15. Paid ordering
is unchanged. Bumps RANKING_POLICY_VERSION so tables republish.
Stores ADR 0005 custom router definitions with an XOR owner check,
one unique slug per owner, and entries typed by the contracts pool
entry union. Soft delete removes a user's own routers.
Consumers that typecheck packages/db source would otherwise pull in the
contracts source, which needs a newer TypeScript lib than some of them
set (services/ai-attribution failed on toSorted). The jsonb entry union
mirrors the contracts shape, so call sites that pass contracts values
still fail to compile if the shape drifts.
…ical

An entries update must now send defaultEntryKey so the stored default
cannot fall outside the new pool, and gateway model IDs must not start
with the reserved client: prefix so a candidate model string maps to
exactly one entry.
ADR 0005. The auto-routing worker reads custom router definitions from
PostgreSQL through Hyperdrive with an isolate cache and decides within the
router's pool:

- POST /v1/decide authenticates the caller's Kilo token, resolves the router
  by scope or organization context, checks membership and the paying owner's
  balance, decides, and returns a signed decision ID.
- POST /v1/eval/{plan,case,result} check router editor rights and the owner's
  balance, then forward the router context and grader billing to the
  benchmark worker.
- The internal /decide accepts an optional customRouter reference and
  returns entry, stale, and fallback; platform requests are unchanged.
- Null decisions use the default entry, then the first servable entry, and
  fail with custom_router_unavailable otherwise.

Token pepper, organization context, membership, member balance, the paid
usage balance gate, and the enterprise deny list move to
@kilocode/worker-utils/account-access; the gateway and web callers use it.
@iscekic iscekic self-assigned this Oct 16, 2026
@kilo-code-bot

kilo-code-bot Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

The follow-up commit narrows the denied-models retry to transient failures by carrying the gateway status on a dedicated error, retries only network errors, timeouts, 429, and 5xx, and adds a test that a 404 fails closed after a single gateway call; both changed files were reviewed and no new issues were found on the changed lines.

Files Reviewed (2 files)
  • services/auto-routing/src/organization-policy.ts
  • services/auto-routing/src/custom-router.test.ts
Previous Review Summaries (4 snapshots, latest commit a7d0ca2)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit a7d0ca2)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The follow-up commit adds retryable policy failures, an Upstash command-form read, local dev wiring, and a client-entry fallback; all fail-closed behavior is preserved, and the only finding is a suggestion that the new retry also re-issues requests that can never succeed.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
services/auto-routing/src/organization-policy.ts 54 fetchDeniedModelIdsWithRetry retries every failure because fetchDeniedModelIds throws on any non-ok status, so deterministic 4xx (401/400/404) trigger a second doomed request before failing closed instead of failing fast.

Verification of prior findings

  • The blocked-domain cold-start gap remains fixed: getBlacklistedDomains returns null only when no list has ever loaded, and services/auto-routing/src/caller-auth.ts:128 fails closed with a retryable internal_error, with tests for both the cold and last-good paths.
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.ts still omits the unreachable deny-list branch; unchanged by this commit.
  • New fail-closed paths verified: a null policy result denies every gateway entry (policyDeniedModelIds ?? gatewayModelIds), and public-routes.ts:121 throws a retryable internal_error when no listed client entry exists. The guard's provider/model match is equivalent to customRouterEntryKey for client entries, so it exactly matches the fallback servability check in customRouterDecision.

Notes

  • Module-level caches stay bounded: deniedModelsCache (2,000) and the single-slot blacklistedDomains cache; no new unbounded growth or leak introduced.
  • The Upstash GET-path → POST command-form switch returns the same { result } shape, so UpstashGetResponseSchema and the empty-list semantics are unchanged.
  • Not run: repository typecheck/lint/test suites were out of scope for this read-only review; findings are from code inspection and diff analysis.
Files Reviewed (8 files)
  • .env.local.example
  • ENVIRONMENT.md
  • dev/local/env-sync/plan.test.ts
  • services/auto-routing/.dev.vars.example
  • services/auto-routing/src/caller-auth.ts
  • services/auto-routing/src/custom-router.test.ts
  • services/auto-routing/src/organization-policy.ts - 1 issue
  • services/auto-routing/src/public-routes.ts

Fix these issues in Kilo Cloud

Previous review (commit e688f22)

Status: No Issues Found | Recommendation: Merge

Executive Summary

The incremental commit addresses both prior suggestions correctly — the blocked-domain check now fails closed on a cold isolate and the unreachable deny-list branch is removed with behavior preserved — with no new correctness, auth, or fail-closed regressions found.

Verification of prior findings

  • services/auto-routing/src/organization-policy.ts now returns null only when no list has ever loaded and Redis fails; services/auto-routing/src/caller-auth.ts:115 fails closed with internal_error (500, retryable), while a previously loaded list keeps being enforced after later Redis failures. Both paths have tests.
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.ts no longer contains the branch that could never apply the organization deny list. This is behavior-preserving: policyNeedsCandidateEvaluation is false only for memberGrant.mode === 'unrestricted', and model-access.server.ts reaches that mode only when organization restrictions are disabled, which also forces organizationModelDenyList to be empty.

Notes

  • Module-level caches remain bounded: deniedModelsCache (2,000) and the single-slot blacklistedDomains cache; no new unbounded growth.
  • Not run: repository typecheck/lint/test suites were out of scope for this read-only review; findings are from code inspection.
Files Reviewed (5 files)
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.ts
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.test.ts
  • services/auto-routing/src/caller-auth.ts
  • services/auto-routing/src/organization-policy.ts
  • services/auto-routing/src/custom-router.test.ts

Previous review (commit 4c78788)

Status: 2 Issues Found | Recommendation: Address before merge

Executive Summary

The incremental changes are behavior-preserving refactors plus two best-effort additions (the organization model policy and blocked-domain checks on the CLI path); the only findings are two low-severity suggestions, with no correctness, auth, or fail-closed regressions.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 2
Issue Details (click to expand)

SUGGESTION

File Line Issue
services/auto-routing/src/organization-policy.ts 106 Blocked-domain check returns blacklistedDomains?.value ?? [], so a cold isolate with a Redis/secret read failure admits blocked-domain users; consider a fallback or fail closed.
packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.ts 101 The !policyNeedsCandidateEvaluation branch can never deny anything: deniedModelIdsForCandidates only reads organizationModelDenyList for organization_baseline mode, which always requires candidate evaluation.
Files Reviewed (23 files)
  • apps/ai-gateway/src/app/api/v1/internal/auto-routing/denied-models/route.ts - new gateway route facade
  • apps/ai-gateway/src/app/api/v1/route-facades.test.ts - test
  • apps/web/src/routers/admin/blacklist-domains-router.ts - import migration
  • apps/web/src/scripts/d2025-08-29-backfill-domainblocked-users.ts - import migration
  • packages/auto-routing-contracts/src/custom-router.test.ts - test
  • packages/auto-routing-contracts/src/custom-router.ts - new internal API contracts (quota_exceeded, denied-models schemas)
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.test.ts - test
  • packages/web-shared/src/lib/ai-gateway/auto-routing-denied-models.ts - 1 issue
  • packages/web-shared/src/lib/ai-gateway/handlers/auto-routing-denied-models.ts - new internal handler
  • packages/web-shared/src/lib/blacklist-domains-config.ts - helper migration
  • packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts - inline deny-list mapping
  • packages/web-shared/src/lib/redis-keys.ts - key import
  • packages/web-shared/src/lib/user/server.test.ts - test
  • packages/web-shared/src/lib/user/server.ts - helper migration
  • packages/worker-utils/src/account-access.test.ts - test
  • packages/worker-utils/src/account-access.ts - shared blocked-domain helpers
  • services/auto-routing/src/caller-auth.ts - blocked-domain check
  • services/auto-routing/src/custom-router-store.ts
  • services/auto-routing/src/custom-router.test.ts - test
  • services/auto-routing/src/organization-policy.ts - 1 issue
  • services/auto-routing/src/public-routes.ts - org policy on CLI path
  • services/auto-routing/worker-configuration.d.ts - bindings
  • services/auto-routing/wrangler.jsonc - bindings

Notes

  • Verified the account-access extraction is behavior-preserving: isEmailBlacklistedByDomain semantics (lowercase, @domain/.domain) and blacklistedDomainsFromConfig fallback rules match the removed implementations; every former enterpriseModelDenyList / web-shared isEmailBlacklistedByDomain / BlacklistDomainsConfigSchema consumer was migrated.
  • Verified authorization: a caller resolves only their own user router or a membership-checked org router, and every served decision passes the pool check plus the denied-model set (customRouterDecision/isServable); the served denied ids are exact candidate ids (including variant suffixes), so a denied gateway entry cannot be served.
  • Verified fail-closed behavior for models: a gateway policy failure denies every candidate, and the internal route's bearer check is constant-time and fails closed when INTERNAL_API_SECRET is unset.
  • No unbounded module-level growth: the new deniedModelsCache (2,000) and single-slot domain cache are bounded, alongside the existing bounded caches.
  • Not run: repository typecheck/lint/test suites were out of scope for this read-only review; findings are from code inspection.

Fix these issues in Kilo Cloud

Previous review (commit 5b20174)

Status: 1 Issue Found | Recommendation: Address before merge

Executive Summary

The custom-router serving path and the shared account-access refactor are behavior-preserving and correctly enforce router ownership, organization membership/roles, and the paying-owner balance; the only finding is a low-severity cache-cleanup race in custom-router-store.ts.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1

Fix these issues in Kilo Cloud

Issue Details (click to expand)

SUGGESTION

File Line Issue
services/auto-routing/src/custom-router-store.ts 69 value.catch(() => cache.delete(key)) removes whatever entry is currently cached under key, so a superseded rejected load can evict a newer valid definition; the sibling ttlCached guards this with an identity check.
Files Reviewed (40 files)
  • apps/web/src/app/api/chat/link-account/route.test.ts - test-only mock migration
  • apps/web/src/app/api/cloud-agent-next/balance/route.auth.test.ts - test-only assertion update
  • apps/web/src/app/api/integrations/github/callback/route.test.ts - test-only mock migration
  • apps/web/src/app/api/integrations/github/callback/route.ts - isOrganizationMember(db, ...) migration
  • apps/web/src/app/github/link/route.test.ts - test-only mock migration
  • apps/web/src/app/github/link/route.ts - isOrganizationMember(db, ...) migration
  • apps/web/src/lib/bot/platform-helpers.test.ts - test-only mock migration
  • apps/web/src/lib/bot/platform-helpers.ts - isOrganizationMember(db, ...) migration
  • apps/web/src/lib/organizations/organization-seats.ts - isOrganizationMember(db, ...) migration
  • apps/web/src/routers/mcp-gateway-authorizations-router.ts - isOrganizationMember(db, ...) migration
  • packages/web-shared/src/lib/ai-gateway/handlers/audio-transcriptions.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/ai-gateway/handlers/edit-completions.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/ai-gateway/handlers/embeddings.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/ai-gateway/handlers/fim-completions.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/ai-gateway/handlers/llm-proxy.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/ai-gateway/typesafe/handler.ts - canSpendPaidUsage swap
  • packages/web-shared/src/lib/organizations/group-policies/model-access/model-access.server.ts - enterpriseModelDenyList swap
  • packages/web-shared/src/lib/organizations/organization-usage.ts - balance query/calc moved to shared module
  • packages/web-shared/src/lib/organizations/organizations.ts - removed isOrganizationMember
  • packages/web-shared/src/lib/user/balance.ts - accountBalanceMicrodollars
  • packages/web-shared/src/lib/user/server.ts - shared pepper/org-context/membership helpers
  • packages/worker-utils/package.json - ./account-access export
  • packages/worker-utils/src/account-access.test.ts - new
  • packages/worker-utils/src/account-access.ts - new shared access rules
  • services/auto-routing/src/benchmark-origin.ts - custom-table/eval forwarding
  • services/auto-routing/src/caller-auth.ts - new caller auth
  • services/auto-routing/src/classifier-usage.test.ts - identity updates
  • services/auto-routing/src/classifier-usage.ts - scope/requestedModel identity
  • services/auto-routing/src/custom-router-decision.ts - new decision logic - 1 issue
  • services/auto-routing/src/custom-router-store.ts - new router store - 1 issue
  • services/auto-routing/src/custom-router.test.ts - new tests
  • services/auto-routing/src/decide.ts - decision target/scope refactor
  • services/auto-routing/src/decision-cache.test.ts - identity updates
  • services/auto-routing/src/decision-cache.ts - DecisionScope
  • services/auto-routing/src/decision-engine.ts - stale propagation
  • services/auto-routing/src/index.test.ts - identity updates
  • services/auto-routing/src/index.ts - public route registration
  • services/auto-routing/src/public-routes.ts - new public handlers
  • services/auto-routing/worker-configuration.d.ts - new secret bindings
  • services/auto-routing/wrangler.jsonc - new secret bindings

Notes

  • Verified the account-access extraction is behavior-preserving: isOrganizationMember arg order, isApiTokenPepperCurrent, requestOrganizationId, organizationMemberBalance, enterpriseModelDenyList, canSpendPaidUsage, and the lazy-credit-expiry path all match the pre-change code at every migrated call site.
  • Verified auth/authorization: a caller can only resolve/evaluate their own user router or a router in their membership-checked organization context with an editor role; balance gates use the member allowance in an org context and the org balance for org-owned evals.
  • Verified invariant 2: the servable table filter plus the customRouterDecision re-check mean a decision (including the default-then-first-servable fallback) never routes outside the pool.
  • No unbounded module-level growth was found: tableLoaders (1,000), the router-store cache (2,000), the classification hot cache (5,000), and the secret caches are all size-bounded.

Reviewed by deepseek-v4.1-flash · Input: 35.9K · Output: 8.8K · Cached: 346.1K

Review guidance: REVIEW.md from base branch feat/auto-custom-routers

…domains on the CLI path

The public decide route now asks the AI gateway which of a router's gateway
entries the organization model policy denies the member. A new internal
gateway route runs the gateway's own evaluation (group policy grants,
provider limits, and the data-collection rule) for named candidates, so the
CLI path and the gateway share one implementation. The worker caches the
answer for a minute and denies every gateway entry when the gateway cannot
answer.

Blocked email domain parsing and matching move to
@kilocode/worker-utils/account-access. Every public worker route rejects a
user in a blocked domain, read from the same Upstash key the web app uses.
…ryable policy failures

- .dev.vars.example points KILO_AI_GATEWAY_BASE_URL at the local AI gateway and
  seeds the Upstash secrets from the dev redis-http container; .env.local.example
  and ENVIRONMENT.md document AUTO_ROUTING_DECISION_SECRET and the worker's
  Upstash secrets.
- The blocked-domain read uses the POST ["GET", key] command form that Upstash
  and serverless-redis-http both serve.
- A failed denied-models lookup is retried once. If it still fails, listed
  client entries can serve; otherwise the request gets a retryable
  internal_error with Retry-After instead of custom_router_unavailable.
Base automatically changed from feat/auto-custom-routers to main October 19, 2026 01:42
…ve-ready

# Conflicts:
#	docs/adr/0005-custom-auto-routers.md
#	packages/auto-routing-contracts/src/custom-router.ts
@iscekic
iscekic added this pull request to the merge queue Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants