Files
openclaude/web
e2bbb0295a feat: smart auto-routing (per-turn simple-vs-strong model selection) (#1734)
* feat(smart-routing): add smartRouting settings schema and reader

* feat(smart-routing): resolve role keys to a SmartRoutingConfig

* feat(smart-routing): wire per-user-turn routing into the query loop

Classify once per user turn (transition===undefined), pin the decision in a
loop-local, and apply the model-only route before the blocking-limit math.
Enforce the org allowlist by calling isModelAllowed directly (coerce disallowed
to strong; disable for the session if strong is also disallowed). Strip thinking
history on a model change only under the provider gate (preserve-reasoning
providers are left untouched). Export stripThinkingBlocksIfProviderAllows.

* feat(smart-routing): add routed-error fallback to the strong model

A simple-routed turn whose model call hits a retryable error retries once on
the strong model, reusing the existing attemptWithFallback retry loop. Aborts
and 4xx client errors propagate. Adds a session routing tally (simple/strong
counts and simple->strong escalations) for the observability surface.

* feat(smart-routing): add /smartroute command and env defaults

/smartroute shows status and sets/toggles the simple and strong roles from
agentModels keys, warning when the simple model is not first-party-cheaper than
the strong one. OPENCLAUDE_SMART_ROUTING(_SIMPLE/_STRONG) provide startup
defaults; an explicit settings block overrides env.

* feat(smart-routing): show routing summary in /cost

Appends a session routing summary (turns simple/strong, simple->strong
escalations) to /cost, with an estimated-savings line gated on first-party
pricing and annotated unavailable for unknown third-party pricing. Per-turn
cost is already attributed to the routed model via the existing per-model
breakdown.

* fix(smart-routing): re-pin to strong after a routed-error fallback

Without this, a turn's later continuation passes re-applied the pinned simple
model after a fallback, re-triggering the same failure each pass. Re-pinning to
strong keeps the rest of the turn on the recovered model.

* fix(review): provider-swap guard, tally reset, notice-storm, env docs

- Add the KTD6 provider-swap guard: drop the per-turn routing pin when a
  mid-turn provider-fallback swap changes the active provider, so the old
  provider's model id is not replayed at the new endpoint (adversarial P1).
- Reset the routing tally in resetCostState() so /cost does not show stale
  cross-session counts.
- Don't emit the disabled-for-session notice on every turn when no sessionId
  is available (suppress instead of storm).
- Document OPENCLAUDE_SMART_ROUTING* in the openaiShim env-var header.
- Add tests: provider-swap-safe pin, undefined-session silence, /smartroute
  strong arm and no-value guard.

* docs(smart-routing): document /smartroute, settings, and env vars

Register /smartroute in the web command catalog, add the smartRouting setting
and OPENCLAUDE_SMART_ROUTING* env vars to the configuration reference, add a
docs/smart-routing.md usage guide, and link it from the README.

* fix(review): clear tally on /login, extract+test swap predicate, cap disabled set

- /login used the raw bootstrap resetCostState, leaking the routing tally
  across an account switch; switch it to the cost-tracker wrapper.
- Extract the provider-swap drop check as a pure, tested
  shouldDropPinForProviderSwap() and use it in the query loop.
- Cap the disabledSessions set so a long-lived host can't grow it unbounded.
- Document the 404/429 retry-by-design rationale; add tests for it.
- Clarify the routedFallbackUsed per-turn scope and the apply-after-guard
  comment; document cross-provider role rejection and the re-enable path.

* test(smart-routing): make allowlist tests robust to cross-file module mocks

The decideTurnModel allowlist tests spied the global settings singleton, which
let another file's leaked mock.module of modelAllowlist (agent.test.ts) flip
isModelAllowed out from under them in the full suite. Spy isModelAllowed
directly and restore it in afterEach so the tests are deterministic regardless
of suite ordering.

* fix(smart-routing): address CodeRabbit review and green CI

- index.test.ts: pin the allowlist in the three happy-path decideTurnModel
  tests so they no longer inherit a leaked cross-file isModelAllowed mock
  (the CI test failure)
- smartroute/index.test.ts: narrow the LocalCommandResult union via an
  expectText helper instead of reading .value off the union (the CI
  typecheck failure)
- conversationRecovery.ts: route deserialize's thinking-strip gate through
  stripThinkingBlocksIfProviderAllows, removing the duplicated provider
  detection
- conversationRecovery.test.ts: replace the two as-any fixtures with a
  shared typed factory

* fix(smart-routing): scope cost claims to first-party reference pricing

Smart routing's savings estimate and "simple isn't cheaper" warning were
derived from the static first-party MODEL_COSTS table via getKnownInputCost,
with no knowledge of the active provider, gateway, or account pricing. For a
multi-provider user whose model ids happen to exist in that table but bill
differently, the /cost summary and /smartroute warning stated a savings figure
as if it reflected what they are actually charged.

Narrow the copy instead of inventing provider-aware pricing the code cannot
verify: the /cost line, the /smartroute warning, and docs/smart-routing.md now
label the numbers as first-party reference pricing and note the active provider
may bill differently. Tests assert the qualifier on every reworded branch so it
cannot silently regress. No routing logic changed.

* fix(smart-routing): clarify simple role wording

* Fix smart routing review findings

* fix(smart-routing): honor env roles and non-text turns

* test(smart-routing): cover non-text skip path

---------

Co-authored-by: jatmn <the@jat.mn>
2026-07-07 10:48:52 +08:00
..