Files
openclaude/src/services/api/smartRouting/index.test.ts
T
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

396 lines
14 KiB
TypeScript

import { afterEach, describe, expect, spyOn, test } from 'bun:test'
import {
clearSmartRoutingSessionDisable,
decideTurnModel,
deriveUserTurnNumber,
extractLatestUserText,
formatRoutingSummary,
getRoutingSummaryForDisplay,
getRoutingTally,
isRetryableRoutedModelError,
isSmartRoutingDisabledForSession,
latestUserMessageHasNonTextContent,
recordRoutingDecision,
recordRoutingEscalation,
resetRoutingTally,
shouldDropPinForProviderSwap,
type TurnRoutingDecision,
} from './index.js'
import * as modelAllowlistModule from '../../../utils/model/modelAllowlist.js'
import type { SettingsJson } from '../../../utils/settings/types.js'
// Control isModelAllowed directly rather than the global settings singleton it
// reads. This keeps these tests deterministic even when another test file leaks
// a mock.module of modelAllowlist (e.g. agent.test.ts), and the afterEach
// restore guarantees we never leak our own spy if an assertion throws first.
let activeAllowlistSpy: ReturnType<typeof spyOn> | undefined
afterEach(() => {
activeAllowlistSpy?.mockRestore()
activeAllowlistSpy = undefined
})
/** Force isModelAllowed to allow only the given models (exact membership). */
function mockGlobalAllowlist(availableModels: string[] | undefined) {
activeAllowlistSpy = spyOn(modelAllowlistModule, 'isModelAllowed').mockImplementation(
(model: string) => (availableModels ? availableModels.includes(model) : true),
)
return activeAllowlistSpy
}
const PARENT = 'gpt-5'
function settings(overrides: Record<string, unknown>): SettingsJson {
return overrides as unknown as SettingsJson
}
// Two model-only agentModels keys + an opt-in smartRouting block. No availableModels
// allowlist, so isModelAllowed returns true for everything.
function enabledSettings(extra: Record<string, unknown> = {}): SettingsJson {
return settings({
agentModels: { mini: { model: 'gpt-5-mini' }, main: { model: 'gpt-5' } },
smartRouting: { enabled: true, simpleModel: 'mini', strongModel: 'main' },
...extra,
})
}
const userMsg = (text: string, isMeta = false) => ({
type: 'user',
isMeta,
message: { role: 'user', content: text },
})
const toolResultMsg = () => ({
type: 'user',
message: { role: 'user', content: [{ type: 'tool_result', content: 'ok' }] },
})
const imageMsg = () => ({
type: 'user',
message: {
role: 'user',
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc' } }],
},
})
const assistantMsg = () => ({ type: 'assistant', message: { role: 'assistant', content: 'hi' } })
describe('deriveUserTurnNumber', () => {
test('counts only real user messages (not isMeta, not tool-results)', () => {
const msgs = [
userMsg('first real turn'),
assistantMsg(),
toolResultMsg(),
userMsg('continue', true), // isMeta nudge
userMsg('second real turn'),
]
expect(deriveUserTurnNumber(msgs)).toBe(2)
})
test('empty conversation is zero', () => {
expect(deriveUserTurnNumber([])).toBe(0)
})
})
describe('extractLatestUserText', () => {
test('returns the most recent real user message text', () => {
const msgs = [userMsg('old'), assistantMsg(), userMsg('newest')]
expect(extractLatestUserText(msgs)).toBe('newest')
})
test('skips isMeta and tool-result messages', () => {
const msgs = [userMsg('the real one'), assistantMsg(), toolResultMsg(), userMsg('nudge', true)]
expect(extractLatestUserText(msgs)).toBe('the real one')
})
test('joins text blocks of array content', () => {
const msgs = [{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } }]
expect(extractLatestUserText(msgs)).toBe('a\nb')
})
})
describe('latestUserMessageHasNonTextContent', () => {
test('detects image/document-style blocks on the latest real user turn', () => {
expect(latestUserMessageHasNonTextContent([userMsg('old'), imageMsg()])).toBe(true)
expect(
latestUserMessageHasNonTextContent([
imageMsg(),
{ type: 'user', message: { content: [{ type: 'text', text: 'plain follow-up' }] } },
]),
).toBe(false)
})
test('skips meta and tool-result carriers', () => {
const msgs = [userMsg('plain'), imageMsg(), toolResultMsg(), userMsg('nudge', true)]
expect(latestUserMessageHasNonTextContent(msgs)).toBe(true)
})
})
describe('decideTurnModel', () => {
afterEach(() => {
clearSmartRoutingSessionDisable('sess-1')
clearSmartRoutingSessionDisable('sess-2')
})
test('disabled settings → routed:false', () => {
const d = decideTurnModel({
settings: settings({}),
parentModel: PARENT,
input: { userText: 'hi', turnNumber: 2 },
})
expect(d.routed).toBe(false)
})
test('short non-first turn routes simple', () => {
mockGlobalAllowlist(undefined) // allow all; immune to a leaked cross-file allowlist mock
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok thanks', turnNumber: 3 },
})
expect(d).toMatchObject({ routed: true, complexity: 'simple', model: 'gpt-5-mini', strongModel: 'gpt-5' })
})
test('first turn routes strong (routeModel turnNumber===1 guard)', () => {
mockGlobalAllowlist(undefined)
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok', turnNumber: 1 },
})
expect(d).toMatchObject({ routed: true, complexity: 'strong', model: 'gpt-5' })
})
test('strong-signal prompt routes strong', () => {
mockGlobalAllowlist(undefined)
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'refactor the auth module please', turnNumber: 4 },
})
expect(d).toMatchObject({ routed: true, complexity: 'strong', model: 'gpt-5' })
})
test('non-text user content routes strong even when the text extractor is empty', () => {
mockGlobalAllowlist(undefined)
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: '', hasNonTextContent: true, turnNumber: 4 },
})
expect(d).toMatchObject({ routed: true, complexity: 'strong', model: 'gpt-5' })
})
test('disallowed simple model coerces to strong', () => {
// Distinct, non-prefix-colliding model ids so the allowlist genuinely blocks
// simple while permitting strong.
const s = settings({
agentModels: { mini: { model: 'alpha-mini' }, main: { model: 'beta-big' } },
smartRouting: { enabled: true, simpleModel: 'mini', strongModel: 'main' },
})
const spy = mockGlobalAllowlist(['beta-big'])
const d = decideTurnModel({
settings: s,
parentModel: PARENT,
input: { userText: 'ok thanks', turnNumber: 3 },
})
expect(d).toMatchObject({ routed: true, model: 'beta-big', complexity: 'strong' })
spy.mockRestore()
})
test('both models disallowed → routing disabled for session, fires once', () => {
const spy = mockGlobalAllowlist(['some-other-model'])
const cfg = {
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok thanks', turnNumber: 3 },
sessionId: 'sess-1',
}
const first = decideTurnModel(cfg)
expect(first).toEqual({ routed: false, justDisabledForSession: true })
expect(isSmartRoutingDisabledForSession('sess-1')).toBe(true)
// Second call: still disabled, but the one-time flag is not re-raised.
const second = decideTurnModel(cfg)
expect(second).toEqual({ routed: false })
spy.mockRestore()
})
test('both disallowed with no sessionId stays silent (no per-turn notice storm)', () => {
const spy = mockGlobalAllowlist(['some-other-model'])
const d = decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok thanks', turnNumber: 3 },
// no sessionId
})
expect(d).toEqual({ routed: false })
spy.mockRestore()
})
test('a session disable does not leak into another session', () => {
const spy = mockGlobalAllowlist(['x'])
decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok', turnNumber: 3 },
sessionId: 'sess-1',
})
expect(isSmartRoutingDisabledForSession('sess-1')).toBe(true)
expect(isSmartRoutingDisabledForSession('sess-2')).toBe(false)
spy.mockRestore()
})
test('clearSmartRoutingSessionDisable re-enables a disabled session (the /smartroute on path)', () => {
const spy = mockGlobalAllowlist(['x'])
decideTurnModel({
settings: enabledSettings(),
parentModel: PARENT,
input: { userText: 'ok', turnNumber: 3 },
sessionId: 'sess-1',
})
expect(isSmartRoutingDisabledForSession('sess-1')).toBe(true)
clearSmartRoutingSessionDisable('sess-1')
expect(isSmartRoutingDisabledForSession('sess-1')).toBe(false)
spy.mockRestore()
})
})
describe('isRetryableRoutedModelError', () => {
test('4xx client errors (bad request / auth / permission) are not retryable', () => {
expect(isRetryableRoutedModelError({ status: 400 })).toBe(false)
expect(isRetryableRoutedModelError({ status: 401 })).toBe(false)
expect(isRetryableRoutedModelError({ statusCode: 403 })).toBe(false)
})
test('404 and 429 are retryable by design (the fallback switches to a different model)', () => {
expect(isRetryableRoutedModelError({ status: 404 })).toBe(true)
expect(isRetryableRoutedModelError({ status: 429 })).toBe(true)
})
test('5xx, network, and unclassified errors are retryable', () => {
expect(isRetryableRoutedModelError({ status: 500 })).toBe(true)
expect(isRetryableRoutedModelError({ status: 529 })).toBe(true)
expect(isRetryableRoutedModelError(new Error('socket hang up'))).toBe(true)
expect(isRetryableRoutedModelError(undefined)).toBe(true)
})
})
describe('shouldDropPinForProviderSwap', () => {
const routed: TurnRoutingDecision = {
routed: true,
model: 'mini',
complexity: 'simple',
reason: 'x',
strongModel: 'main',
}
test('no pin -> never drop', () => {
expect(shouldDropPinForProviderSwap(undefined, 'p1', 'p2')).toBe(false)
})
test('routed pin, same provider -> keep', () => {
expect(shouldDropPinForProviderSwap(routed, 'p1', 'p1')).toBe(false)
})
test('routed pin, provider changed -> drop', () => {
expect(shouldDropPinForProviderSwap(routed, 'p1', 'p2')).toBe(true)
})
test('no provider profiles (both undefined) -> keep', () => {
expect(shouldDropPinForProviderSwap(routed, undefined, undefined)).toBe(false)
})
test('non-routed pin -> never drop', () => {
expect(shouldDropPinForProviderSwap({ routed: false }, 'p1', 'p2')).toBe(false)
})
})
describe('routing tally', () => {
afterEach(() => resetRoutingTally())
test('records decisions and escalations', () => {
resetRoutingTally()
recordRoutingDecision('simple')
recordRoutingDecision('simple')
recordRoutingDecision('strong')
recordRoutingEscalation()
expect(getRoutingTally()).toEqual({ simple: 2, strong: 1, escalations: 1 })
})
test('reset clears the tally', () => {
recordRoutingDecision('simple')
resetRoutingTally()
expect(getRoutingTally()).toEqual({ simple: 0, strong: 0, escalations: 0 })
})
})
describe('formatRoutingSummary', () => {
test('null when nothing routed', () => {
expect(formatRoutingSummary({ simple: 0, strong: 0, escalations: 0 })).toBeNull()
})
test('shows split and escalations', () => {
const out = formatRoutingSummary({ simple: 5, strong: 2, escalations: 1 })
expect(out).toContain('5 simple, 2 strong')
expect(out).toContain('1 escalated to strong')
})
test('estimated savings line when both models priced and simple cheaper', () => {
const out = formatRoutingSummary({ simple: 3, strong: 1, escalations: 0 }, {
simpleInputCost: 1,
strongInputCost: 5,
})
expect(out).toContain('~80% lower')
// The estimate must disclose it is first-party reference pricing, not the
// active provider's billed rate (jatmn P2).
expect(out).toContain('first-party reference pricing')
expect(out).toContain('may bill differently')
})
test('savings unavailable when a price is unknown', () => {
const out = formatRoutingSummary({ simple: 3, strong: 1, escalations: 0 }, { strongInputCost: 5 })
expect(out).toContain('Estimated savings unavailable')
// The unavailable line must name first-party reference pricing as the source.
expect(out).toContain('no first-party reference pricing')
})
test('notes no savings when simple is not cheaper', () => {
const out = formatRoutingSummary({ simple: 3, strong: 1, escalations: 0 }, {
simpleInputCost: 5,
strongInputCost: 5,
})
expect(out).toContain('not cheaper')
// The not-cheaper branch must carry the same first-party reference hedge.
expect(out).toContain('first-party reference pricing')
expect(out).toContain('may bill differently')
})
})
describe('getRoutingSummaryForDisplay', () => {
afterEach(() => resetRoutingTally())
test('uses env-backed smart-routing roles for pricing display', () => {
const previous = {
OPENCLAUDE_SMART_ROUTING: process.env.OPENCLAUDE_SMART_ROUTING,
OPENCLAUDE_SMART_ROUTING_SIMPLE: process.env.OPENCLAUDE_SMART_ROUTING_SIMPLE,
OPENCLAUDE_SMART_ROUTING_STRONG: process.env.OPENCLAUDE_SMART_ROUTING_STRONG,
}
try {
process.env.OPENCLAUDE_SMART_ROUTING = '1'
process.env.OPENCLAUDE_SMART_ROUTING_SIMPLE = 'mini'
process.env.OPENCLAUDE_SMART_ROUTING_STRONG = 'main'
recordRoutingDecision('simple')
const out = getRoutingSummaryForDisplay(
settings({
agentModels: { mini: { model: 'claude-haiku-4-5' }, main: { model: 'claude-opus-4-5' } },
}),
)
expect(out).toContain('Smart routing: 1 simple, 0 strong')
expect(out).toContain('first-party reference pricing')
expect(out).not.toContain('Estimated savings unavailable')
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value == null) delete process.env[key]
else process.env[key] = value
}
}
})
})