diff --git a/changelog.d/features/13448-adaptive-reasoning-effort.md b/changelog.d/features/13448-adaptive-reasoning-effort.md new file mode 100644 index 0000000000..04d464b619 --- /dev/null +++ b/changelog.d/features/13448-adaptive-reasoning-effort.md @@ -0,0 +1 @@ +- **feat(reasoning):** adaptive reasoning effort (`auto`) — the gateway resolves the thinking budget per user turn from deterministic request-shape signals (stateless per-turn pin) instead of forwarding a literal `auto`, applied at the gateway pre-translation for any harness (Claude Code, Cursor, Codex, opencode, Hermes) whose request dispatches to an OpenAI Chat-Completions-shaped upstream (`targetFormat === FORMATS.OPENAI` — `reasoning_effort` is an OpenAI-shaped field, so a Claude- or Gemini-targeted request is unaffected). Opt in via `X-OmniRoute-Effort: auto` or a model's `defaultReasoningEffort: "auto"` (now a valid `ModelSpec` value); any explicit client reasoning field always wins ([#13448](https://github.com/diegosouzapw/OmniRoute/pull/13448)) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 307648fe89..31dd14b671 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -451,6 +451,7 @@ "_rebaseline_2026_09_15_13440_daily_reset_tz": "#13440 rework: open-sse/services/accountFallback.ts 2469->2493 (+24): +6 for the operator-clock-first branch in checkFallbackError non-TPD daily quota (nextConfiguredResetMs leaf lives in dailyQuotaReset.ts, under cap) and +18 from the mandatory lint-staged Prettier pass over pre-existing unformatted lines of the touched file (no logic). executeTargetAttempt.ts 1212->1215 and roundRobinCombo.ts 1205->1208 (+3 each): one import plus the rotation/dailyReset arguments at the existing checkFallbackError call site; the lookup itself is the new comboDailyResetClock.ts leaf (under cap). Covered by tests/unit/daily-reset-tz-threading.test.ts.", "_rebaseline_2026_09_15_13672_retry_after_provenance": "#13672 rework (opt-in RETRY_AFTER_PROVENANCE_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1220 (+8) and roundRobinCombo.ts 1205->1210 (+5) at the existing drain-path clone/parse block: capture the already-read body text, log an unreadable hint (debug for a non-JSON page, warn for a failed clone) instead of an empty catch, and one flag-gated prose fallback line; the import grows by the two helpers. Parsing, flag read and the Retry-After/provenance logic live in open-sse/utils/error.ts (under cap). Covered by tests/unit/retry-after-provenance.test.ts (flag off and on).", "_rebaseline_2026_09_15_13439_protected_priority_stop_status": "#13439 rework (opt-in PROTECTED_PRIORITY_INFRA_502_ENABLED): open-sse/services/combo/executeTargetAttempt.ts 1212->1217 (+5): two import lines for the new protectedPriorityStopStatus.ts leaf (where the provably-non-quota cause list and the flag read live) and the predictive_ttft cause argument at the existing stopProtectedPriorityTarget call, which Prettier splits over three lines. Covered by tests/unit/combo/protected-priority-stop-status-13439.test.ts (every stop cause, flag off and on).", + "_rebaseline_2026_09_16_13448_adaptive_effort_targetformat_gate": "#13448 rework: open-sse/handlers/chatCore.ts 6142->6156 (+14, PR's own growth: the X-OmniRoute-Effort header capture near THINKING_MARKER_HEADER and the wireAdaptiveEffort(translatedBody, {...}) call site right after applyDefaultReasoningEffort, plus this rework's +1 targetFormat argument at that same call). The targetFormat gate itself (ctx.targetFormat !== FORMATS.OPENAI short-circuit) lives in the non-frozen open-sse/handlers/chatCore/adaptiveEffortWiring.ts leaf, not here -- irreducible call-site wiring at the existing post-translation reasoning-normalization chokepoint. Covered by tests/unit/adaptive-effort-wiring.test.ts (targetFormat gate, red-on-tip) and tests/unit/adaptive-effort-model-default-13448.test.ts.", "_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).", "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "open-sse/executors/antigravity.ts": 1665, @@ -459,7 +460,7 @@ "open-sse/executors/codex.ts": 1505, "open-sse/executors/cursor.ts": 1808, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 6146, + "open-sse/handlers/chatCore.ts": 6156, "open-sse/handlers/imageGeneration.ts": 3293, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 2b406a7b2c..e397088640 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -252,11 +252,12 @@ combo's stored config. These apply only to the `auto` strategy and only for the that carries them; the combo's saved `modePack`/`budgetCap`/`budgetFallback` are used when the header is absent. -| Header | Accepts | Effect | -| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | -| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. | -| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. | +| Header | Accepts | Effect | +| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | +| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. | +| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. | +| `X-OmniRoute-Effort` | `auto` (other values reserved) | Adaptive thinking budget: when the request carries **no** reasoning field of any shape (`reasoning_effort`, `reasoning`, `thinking`), the gateway resolves `auto` to `low`/`medium`/`high` from deterministic request-shape signals (last-user-message length, context size up to the last user message, prior tool results, tool-loop depth). Signals are scoped to the current turn — everything after the last user message is ignored — so every request in a tool loop resolves to the same level (stateless per-turn pin, no session state, no mid-loop escalation that would break upstream prompt-cache prefixes). An explicit client reasoning field always wins. Scoped to requests whose upstream dispatch resolves to the OpenAI Chat Completions shape (`targetFormat === FORMATS.OPENAI`) — `reasoning_effort` is an OpenAI-shaped field, so the header is a no-op on a Claude- or Gemini-targeted request (see `open-sse/handlers/chatCore/adaptiveEffortWiring.ts`). | ```bash # Force the fastest profile, cap this request at $0.05, and hard-block instead of overspending diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 7ef9ef5541..45c0c134db 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -170,6 +170,7 @@ import { import { shouldUseMidConversationSystem } from "../executors/claudeIdentity.ts"; import { normalizeClaudeHaikuConstraints } from "../services/claudeHaikuConstraints.ts"; import { applyDefaultReasoningEffort } from "../services/defaultReasoningEffort.ts"; +import { wireAdaptiveEffort } from "./chatCore/adaptiveEffortWiring.ts"; import { echoModelInObject } from "../services/responseModelEcho.ts"; import { stripGpt5SamplingWhenReasoning, @@ -2718,6 +2719,11 @@ export async function handleChatCore({ (modelInfo as { defaultThinkingEffort?: string })?.defaultThinkingEffort ); } + translatedBody = wireAdaptiveEffort(translatedBody, { + rawBody: body, + clientRawRequest, + targetFormat, + }); } // Xiaomi MiMo controls reasoning ONLY via `thinking:{type:"enabled"|"disabled"}` and diff --git a/open-sse/handlers/chatCore/adaptiveEffortWiring.ts b/open-sse/handlers/chatCore/adaptiveEffortWiring.ts new file mode 100644 index 0000000000..c891f7b15a --- /dev/null +++ b/open-sse/handlers/chatCore/adaptiveEffortWiring.ts @@ -0,0 +1,72 @@ +// Adaptive reasoning-effort wiring (#13448), extracted from chatCore.ts so the +// frozen main file does not grow (file-size gate: chatCore.ts cannot grow). +// Semantics live in open-sse/services/adaptiveEffort.ts; this module only +// adapts the chatCore call-site context (headers, raw body, translated body). +// +// Runs AFTER applyDefaultReasoningEffort so its explicit-value precedence and +// alias-suffix priority are preserved; operates on the pre-translation body so +// source-format differences are handled by the existing translators. +import { + applyAdaptiveEffort, + hasExplicitReasoningField, + isAdaptiveEffort, + type ChatMessageLike, +} from "../../services/adaptiveEffort.ts"; +import { FORMATS } from "../../translator/formats.ts"; +import { getHeaderValueCaseInsensitive } from "./headers.ts"; + +export interface AdaptiveEffortContext { + /** Raw (pre-translation) request body, for turn-scoped request-shape signals. */ + rawBody: { messages?: ChatMessageLike[] | undefined } | undefined; + /** Incoming client request, used to read the x-omniroute-effort header. */ + clientRawRequest?: { headers?: unknown } | undefined; + /** Explicit header value, if already extracted by the caller. */ + headerEffort?: string | null | undefined; + /** + * Resolved upstream dispatch format (chatCore.ts's `targetFormat`). `reasoning_effort` + * is an OpenAI Chat-Completions-shaped field: on any other target it either does + * nothing (Claude/Gemini executors read `thinking`/`reasoning.effort` instead and + * never look at it) or, worse, reaches an upstream that rejects unrecognized + * top-level parameters (e.g. Anthropic's Messages API 400s on one). Every other + * reasoning-shape normalization in chatCore.ts (applyDefaultReasoningEffort, + * promoteStrayReasoningEffort for the Responses same-format lane) is scoped the + * same way — wiring must match, or an operator's `X-OmniRoute-Effort: auto` header + * on a Claude/Gemini-targeted request would silently no-op or break the request. + */ + targetFormat: string | undefined; +} + +/** + * Resolve "auto" reasoning effort to a concrete level when the request opted in + * (header or ModelSpec.defaultReasoningEffort === "auto") and carries no explicit + * reasoning field. Returns `body` unchanged (same reference) otherwise. + * + * Scoped to `FORMATS.OPENAI` dispatch — see {@link AdaptiveEffortContext.targetFormat}. + */ +export function wireAdaptiveEffort>( + body: T, + ctx: AdaptiveEffortContext +): T { + if (ctx.targetFormat !== FORMATS.OPENAI) return body; + // Lever: applyDefaultReasoningEffort may have just injected the literal + // "auto" from ModelSpec.defaultReasoningEffort — that is an opt-in marker, + // not a wire value, so it must NOT count as an explicit client field (it + // would otherwise short-circuit the guard below and ship "auto" upstream). + const modelDefaultAuto = isAdaptiveEffort(body.reasoning_effort); + if (!modelDefaultAuto && hasExplicitReasoningField(body)) return body; + const headerEffort = + ctx.headerEffort !== undefined + ? ctx.headerEffort + : getHeaderValueCaseInsensitive( + ctx.clientRawRequest?.headers as Record | Headers | null | undefined, + "x-omniroute-effort" + ); + if (!modelDefaultAuto && !isAdaptiveEffort(headerEffort)) return body; + const stripped = modelDefaultAuto ? { ...body } : body; + if (modelDefaultAuto) delete (stripped as Record).reasoning_effort; + return applyAdaptiveEffort(stripped, { + messages: ctx.rawBody?.messages, + headerEffort: headerEffort ?? null, + modelDefaultEffort: modelDefaultAuto ? "auto" : null, + }) as T; +} diff --git a/open-sse/services/adaptiveEffort.ts b/open-sse/services/adaptiveEffort.ts new file mode 100644 index 0000000000..2daf2d2a8c --- /dev/null +++ b/open-sse/services/adaptiveEffort.ts @@ -0,0 +1,137 @@ +// Adaptive reasoning effort — the OmniRoute-side counterpart of Hermes' +// `effort: "auto"` (NousResearch/hermes-agent#109044). One implementation at +// the gateway covers every harness (Claude Code, Cursor, Codex, opencode, +// Hermes) because the full request body passes through here before any +// provider translation. +// +// Semantics: +// - Effort is resolved from deterministic request-shape signals only — no LLM +// call, no judgment gate. Three bands (low / medium / high) gate a thinking +// budget, not a model-routing decision. +// - STATELESS PER-TURN PIN: signals are computed ONLY from the last user +// message and everything BEFORE it. Tool results after the last user +// message are ignored, so every request of the same user turn — including +// mid-tool-loop requests — resolves to the SAME level. This reproduces +// Hermes' stateful per-turn pin deterministically, without stored state, +// and never escalates mid-loop (which would change the reasoning config +// between requests and cost a cold prompt-cache prefix write on +// cache-sensitive upstreams). +// +// Priority (highest first), all off-by-default: +// 1. Explicit client reasoning field of any shape — always wins; no-op. +// 2. `X-OmniRoute-Effort: auto` request header (per-request opt-in; mirrors +// the #6023/#6024/#6025 `X-OmniRoute-Mode`/`-Budget` controls pattern). +// 3. `ModelSpec.defaultReasoningEffort: "auto"` (per-model opt-in, #6879). +import { estimateMessageTokens } from "./specificityRules"; + +export const ADAPTIVE_EFFORT = "auto"; + +// Thresholds mirror the Hermes resolver (agent/reasoning_effort.py): three +// coarse deterministic bands. A near-miss costs a slightly over/under-thought +// answer, not a wrong route, so they stay coarse until call-log data says +// otherwise. +const TRIVIAL_USER_CHARS = 160; +const TRIVIAL_CTX_TOKENS = 4000; +const HEAVY_CTX_TOKENS = 60000; +const HEAVY_TOOL_RESULTS = 6; +const HEAVY_USER_CHARS = 4000; + +export type ChatMessageLike = { role?: unknown; content?: unknown }; +type EffortLevel = "low" | "medium" | "high"; + +function isString(v: unknown): v is string { + return typeof v === "string"; +} + +function lastUserMessageIndex(messages: ChatMessageLike[]): number { + let last = -1; + for (let i = 0; i < messages.length; i++) { + if (messages[i]?.role === "user") last = i; + } + return last; +} + +function messageTextChars(content: unknown): number { + if (isString(content)) return content.length; + if (Array.isArray(content)) { + let sum = 0; + for (const part of content) { + const text = (part as { text?: unknown })?.text; + if (isString(text)) sum += text.length; + } + return sum; + } + return 0; +} + +function countRole(messages: ChatMessageLike[], role: string, from: number, to: number): number { + let n = 0; + for (let i = from; i < to; i++) { + if (messages[i]?.role === role) n++; + } + return n; +} + +function toolLoopDepthAfter(messages: ChatMessageLike[], boundary: number): number { + let n = 0; + for (let i = boundary + 1; i < messages.length; i++) { + const msg = messages[i]; + if (msg?.role === "assistant" && (msg as { tool_calls?: unknown }).tool_calls) n++; + } + return n; +} + +export function resolveAdaptiveEffort(messages: ChatMessageLike[] | undefined | null): EffortLevel { + const msgs = Array.isArray(messages) ? messages : []; + if (msgs.length === 0) return "medium"; // no signals at all → balanced band, never cheap-by-default + const boundary = lastUserMessageIndex(msgs); + const upToTurn = boundary >= 0 ? msgs.slice(0, boundary + 1) : msgs; + const userChars = boundary >= 0 ? messageTextChars(msgs[boundary].content) : 0; + const estCtxTokens = estimateMessageTokens(upToTurn as Array<{ content?: unknown }>); + const toolResults = boundary >= 0 ? countRole(msgs, "tool", 0, boundary) : 0; + const turnDepth = boundary >= 0 ? toolLoopDepthAfter(msgs, boundary) + 1 : 1; + + const trivial = + userChars <= TRIVIAL_USER_CHARS && + estCtxTokens <= TRIVIAL_CTX_TOKENS && + toolResults === 0 && + turnDepth <= 1; + if (trivial) return "low"; + const heavy = + estCtxTokens >= HEAVY_CTX_TOKENS || + toolResults >= HEAVY_TOOL_RESULTS || + userChars >= HEAVY_USER_CHARS; + if (heavy) return "high"; + return "medium"; +} + +export function isAdaptiveEffort(value: unknown): boolean { + return isString(value) && value.trim().toLowerCase() === ADAPTIVE_EFFORT; +} + +export function hasExplicitReasoningField(body: Record): boolean { + return ( + body.reasoning_effort !== undefined || + body.reasoning !== undefined || + body.thinking !== undefined + ); +} + +export function applyAdaptiveEffort>( + body: T, + opts: { + messages?: ChatMessageLike[] | undefined | null; + headerEffort?: unknown; + modelDefaultEffort?: string | null; + } +): T { + if (!body || typeof body !== "object") return body; + if (hasExplicitReasoningField(body)) return body; + const headerAuto = isAdaptiveEffort(opts.headerEffort); + const defaultAuto = opts.modelDefaultEffort != null && isAdaptiveEffort(opts.modelDefaultEffort); + if (!headerAuto && !defaultAuto) return body; + const level = resolveAdaptiveEffort( + opts.messages ?? (body.messages as ChatMessageLike[] | undefined) + ); + return { ...body, reasoning_effort: level }; +} diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index f623bc048d..57c19428f6 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -48,7 +48,17 @@ export interface ModelSpec { // operator strip-by-default a thinks-by-default model (measured: gemini-flash-lite // burns ~277 reasoning tokens on a plain request; `reasoning_effort:"none"` → 0) // without patching every client. See open-sse/services/defaultReasoningEffort.ts. - defaultReasoningEffort?: "none" | "low" | "medium" | "high"; + // + // `"auto"` (#13448) is the per-model opt-in into adaptive reasoning effort: the + // literal value is injected here exactly like any other level, then + // chatCore/adaptiveEffortWiring.ts's wireAdaptiveEffort() recognizes it as an + // opt-in marker (never forwarded upstream verbatim) and resolves it to a + // concrete low/medium/high from the turn's request-shape signals. Without + // "auto" in this union, no operator could configure the per-model opt-in + // through the typed catalog at all -- open-sse/services/adaptiveEffort.ts's + // priority #3 and the wiring's modelDefaultAuto branch were unreachable + // except by a test constructing the body literal directly. + defaultReasoningEffort?: "none" | "low" | "medium" | "high" | "auto"; } const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ diff --git a/tests/unit/adaptive-effort-model-default-13448.test.ts b/tests/unit/adaptive-effort-model-default-13448.test.ts new file mode 100644 index 0000000000..b7c5e51151 --- /dev/null +++ b/tests/unit/adaptive-effort-model-default-13448.test.ts @@ -0,0 +1,97 @@ +/** + * #13448 rework — the per-model opt-in path (`ModelSpec.defaultReasoningEffort: + * "auto"`) was unreachable through the real, type-checked catalog: the field's + * type union was `"none" | "low" | "medium" | "high"`, so no operator config in + * providerRegistry.ts (or a fixture like this one) could ever assign `"auto"` + * without a type error. `open-sse/services/adaptiveEffort.ts`'s priority #3 and + * `adaptiveEffortWiring.ts`'s `modelDefaultAuto` branch were only ever exercised + * by tests that constructed the post-injection body literal directly + * (`{ reasoning_effort: "auto" }`), bypassing the type entirely. + * + * These tests exercise the REAL two-function pipeline end to end, starting + * from a typed `MODEL_SPECS` entry (no `as any`, no literal shortcut): + * MODEL_SPECS[id].defaultReasoningEffort === "auto" + * -> applyDefaultReasoningEffort() injects the literal "auto" + * -> wireAdaptiveEffort() recognizes it as an opt-in marker and resolves + * it to a concrete low/medium/high (OpenAI dispatch only, #13448 rework + * -- see adaptive-effort-wiring.test.ts for the targetFormat gate). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { applyDefaultReasoningEffort } from "../../open-sse/services/defaultReasoningEffort.ts"; +import { wireAdaptiveEffort } from "../../open-sse/handlers/chatCore/adaptiveEffortWiring.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { MODEL_SPECS, type ModelSpec } from "../../src/shared/constants/modelSpecs.ts"; + +const FIXTURE_MODEL_ID = "__test_13448_model_default_auto__"; + +// Typed assignment through the real ModelSpec union -- this line alone would +// be a TypeScript error on the pristine tip (`Type '"auto"' is not assignable +// to type '"none" | "low" | "medium" | "high"'`), which is exactly the +// unreachability defect: verify with `npm run -s typecheck:core`. +const fixtureSpec: ModelSpec = { defaultReasoningEffort: "auto" }; + +test.before(() => { + MODEL_SPECS[FIXTURE_MODEL_ID] = fixtureSpec; +}); + +test.after(() => { + delete MODEL_SPECS[FIXTURE_MODEL_ID]; +}); + +const HEAVY = "x".repeat(20000); +const trivialMsgs = [{ role: "user", content: "list the files" }]; +const heavyMsgs = [{ role: "user", content: HEAVY }]; + +test("a real ModelSpec.defaultReasoningEffort:'auto' entry is injected as the literal marker", () => { + const body = { model: FIXTURE_MODEL_ID, messages: [] }; + const result = applyDefaultReasoningEffort(body, FIXTURE_MODEL_ID); + assert.equal(result.reasoning_effort, "auto"); +}); + +test("end to end: the typed model-default 'auto' resolves to a concrete low level on OpenAI dispatch", () => { + const rawBody = { model: FIXTURE_MODEL_ID, messages: trivialMsgs }; + const afterDefault = applyDefaultReasoningEffort(rawBody, FIXTURE_MODEL_ID); + const wired = wireAdaptiveEffort(afterDefault, { + rawBody, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.notEqual(wired.reasoning_effort, "auto"); + assert.equal(wired.reasoning_effort, "low"); +}); + +test("end to end: the typed model-default 'auto' resolves to 'high' on a heavy turn", () => { + const rawBody = { model: FIXTURE_MODEL_ID, messages: heavyMsgs }; + const afterDefault = applyDefaultReasoningEffort(rawBody, FIXTURE_MODEL_ID); + const wired = wireAdaptiveEffort(afterDefault, { + rawBody, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(wired.reasoning_effort, "high"); +}); + +test("end to end: an explicit client reasoning_effort still wins over the typed model default", () => { + const rawBody = { model: FIXTURE_MODEL_ID, messages: heavyMsgs, reasoning_effort: "medium" }; + const afterDefault = applyDefaultReasoningEffort(rawBody, FIXTURE_MODEL_ID); + assert.equal(afterDefault.reasoning_effort, "medium", "no-op: explicit field already present"); + const wired = wireAdaptiveEffort(afterDefault, { + rawBody, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(wired.reasoning_effort, "medium"); +}); + +test("end to end: on a non-OpenAI target the typed model-default marker is left as literal 'auto' (never resolved, never forwarded resolved)", () => { + const rawBody = { model: FIXTURE_MODEL_ID, messages: heavyMsgs }; + const afterDefault = applyDefaultReasoningEffort(rawBody, FIXTURE_MODEL_ID); + const wired = wireAdaptiveEffort(afterDefault, { + rawBody, + headerEffort: null, + targetFormat: FORMATS.CLAUDE, + }); + assert.equal(wired, afterDefault, "same reference: wireAdaptiveEffort must no-op off OpenAI"); + assert.equal(wired.reasoning_effort, "auto"); +}); diff --git a/tests/unit/adaptive-effort-wiring.test.ts b/tests/unit/adaptive-effort-wiring.test.ts new file mode 100644 index 0000000000..fe7fa654b2 --- /dev/null +++ b/tests/unit/adaptive-effort-wiring.test.ts @@ -0,0 +1,225 @@ +// chatCore adaptive-effort wiring tests (#13448). +// +// The wiring module is the chatCore call-site adapter extracted from +// chatCore.ts (file-size gate: that file cannot grow). The service-level +// tests in adaptive-effort.test.ts cover resolution semantics; THESE tests +// cover the adapter's own decisions, which are invisible to the service: +// - explicit client reasoning fields are never overwritten (precedence), +// - the literal "auto" injected by ModelSpec.defaultReasoningEffort is an +// opt-in marker and must be stripped before resolution, not sent upstream, +// - the x-omniroute-effort header opts in independently (read inside the +// module from `clientRawRequest.headers`, or passed pre-extracted), +// - a non-opted-in body is returned untouched (same reference), +// - the whole wiring is scoped to OpenAI Chat-Completions dispatch. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { wireAdaptiveEffort } from "@omniroute/open-sse/handlers/chatCore/adaptiveEffortWiring.ts"; +import { FORMATS } from "@omniroute/open-sse/translator/formats.ts"; + +const HEAVY = "x".repeat(20000); +const trivialMsgs = [{ role: "user", content: "list the files" }]; +const heavyMsgs = [{ role: "user", content: HEAVY }]; + +test("explicit reasoning_effort is never overwritten by adaptive wiring", () => { + const body = { model: "m", reasoning_effort: "low" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out.reasoning_effort, "low"); +}); + +test("explicit reasoning object is never overwritten", () => { + const body = { model: "m", reasoning: { effort: "high" } }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: trivialMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.OPENAI, + }); + assert.deepEqual(out.reasoning, { effort: "high" }); + assert.equal(out.reasoning_effort, undefined); +}); + +test("explicit thinking field is never overwritten", () => { + const body = { model: "m", thinking: { type: "enabled" } }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.OPENAI, + }); + assert.deepEqual(out.thinking, { type: "enabled" }); + assert.equal(out.reasoning_effort, undefined); +}); + +test("model-default 'auto' marker is resolved, never sent upstream verbatim", () => { + const body = { model: "m", reasoning_effort: "auto" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: trivialMsgs }, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.notEqual(out.reasoning_effort, "auto"); + assert.equal(out.reasoning_effort, "low"); +}); + +test("model-default 'auto' resolves high on heavy turns", () => { + const body = { model: "m", reasoning_effort: "auto" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out.reasoning_effort, "high"); +}); + +test("header opt-in resolves from the raw (pre-translation) body messages", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out.reasoning_effort, "high"); +}); + +test("no opt-in leaves the body untouched (same reference)", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out, body); + assert.equal(out.reasoning_effort, undefined); +}); + +test("missing rawBody does not throw", () => { + const body = { model: "m", reasoning_effort: "auto" }; + const out = wireAdaptiveEffort(body, { + rawBody: undefined, + headerEffort: null, + targetFormat: FORMATS.OPENAI, + }); + assert.ok(["low", "medium", "high"].includes(out.reasoning_effort as string)); +}); + +// The x-omniroute-effort header is read INSIDE the module from the incoming +// client request (chatCore.ts passes `clientRawRequest` through untouched), so +// the call site does not need its own header extraction. +test("header is read from clientRawRequest.headers (plain record, case-insensitive)", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + clientRawRequest: { headers: { "X-OmniRoute-Effort": "auto" } }, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out.reasoning_effort, "high"); +}); + +test("header is read from clientRawRequest.headers (Headers instance)", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: trivialMsgs }, + clientRawRequest: { headers: new Headers({ "x-omniroute-effort": "auto" }) }, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out.reasoning_effort, "low"); +}); + +test("clientRawRequest without the header (or without headers at all) is not an opt-in", () => { + const body = { model: "m" }; + const noHeader = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + clientRawRequest: { headers: { "user-agent": "x" } }, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(noHeader, body); + const noHeaders = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + clientRawRequest: {}, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(noHeaders, body); + const noRequest = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(noRequest, body); +}); + +test("a pre-extracted headerEffort takes precedence over clientRawRequest.headers", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: null, + clientRawRequest: { headers: { "x-omniroute-effort": "auto" } }, + targetFormat: FORMATS.OPENAI, + }); + assert.equal(out, body, "explicit null means the caller already decided: no opt-in"); +}); + +// #13448 rework: the field wireAdaptiveEffort injects (`reasoning_effort`) is an +// OpenAI Chat-Completions-shaped field. On any other dispatch format it is either +// inert (Claude/Gemini read `thinking`/`reasoning.effort` instead) or actively +// harmful (Anthropic's Messages API 400s on an unrecognized top-level parameter). +// Every sibling reasoning-shape normalization in chatCore.ts is scoped to +// `FORMATS.OPENAI` the same way (applyDefaultReasoningEffort, +// promoteStrayReasoningEffort's same-format Responses lane) -- wiring must match. +test("header opt-in is a no-op on a Claude-targeted dispatch (body returned unchanged)", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.CLAUDE, + }); + assert.equal(out, body, "must be the exact same reference -- no reasoning_effort injected"); + assert.equal(out.reasoning_effort, undefined); +}); + +test("header opt-in is a no-op on a Gemini-targeted dispatch", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: FORMATS.GEMINI, + }); + assert.equal(out, body); + assert.equal(out.reasoning_effort, undefined); +}); + +test("header read from clientRawRequest is also a no-op on a non-OpenAI target", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + clientRawRequest: { headers: { "x-omniroute-effort": "auto" } }, + targetFormat: FORMATS.CLAUDE, + }); + assert.equal(out, body); + assert.equal(out.reasoning_effort, undefined); +}); + +test("model-default 'auto' marker is left untouched (not stripped, not resolved) on a non-OpenAI target", () => { + // Guards against a partial fix that strips the "auto" marker before the + // targetFormat check -- on a non-OpenAI target the body (including any stray + // literal "auto") must be untouched, since it was never OmniRoute's own + // injection to interpret on that dispatch shape. + const body = { model: "m", reasoning_effort: "auto" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: null, + targetFormat: FORMATS.CLAUDE, + }); + assert.equal(out, body); + assert.equal(out.reasoning_effort, "auto"); +}); + +test("targetFormat undefined (e.g. an uncovered call site) also no-ops -- fail closed", () => { + const body = { model: "m" }; + const out = wireAdaptiveEffort(body, { + rawBody: { messages: heavyMsgs }, + headerEffort: "auto", + targetFormat: undefined, + }); + assert.equal(out, body); +}); diff --git a/tests/unit/adaptive-effort.test.ts b/tests/unit/adaptive-effort.test.ts new file mode 100644 index 0000000000..a86b9eb0d9 --- /dev/null +++ b/tests/unit/adaptive-effort.test.ts @@ -0,0 +1,89 @@ +// Adaptive effort tests — mirrors the Hermes contract (hermes-agent#109044): +// trivial→low, heavy→high, mid→medium; explicit client effort wins; the +// stateless per-turn pin ignores post-last-user tool traffic. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + resolveAdaptiveEffort, + applyAdaptiveEffort, + isAdaptiveEffort, + hasExplicitReasoningField, +} from "@omniroute/open-sse/services/adaptiveEffort.ts"; + +function msg( + role: string, + content: string | unknown[] +): { role: string; content: string | unknown[] } { + return { role, content }; +} + +test("trivial ask resolves low", () => { + const level = resolveAdaptiveEffort([msg("user", "list the files")]); + assert.equal(level, "low"); +}); + +test("heavy context resolves high", () => { + const level = resolveAdaptiveEffort([ + msg("user", "long enough"), + msg("assistant", "x".repeat(40)), + msg("user", "continue " + "deep work ".repeat(400)), + ]); + assert.equal(level, "high"); +}); + +test("trivial ask inside big prior context is NOT low", () => { + const level = resolveAdaptiveEffort([ + msg("user", "first ask that is long enough to not be trivial itself " + "pad ".repeat(60)), + msg("assistant", "answer " + "y".repeat(30000)), + msg("user", "short follow-up"), + ]); + assert.equal(level, "medium"); +}); + +test("stateless pin: mid-tool-loop request resolves same as turn start", () => { + const userTurn = [msg("user", "fix the failing test")]; + const turnStart = resolveAdaptiveEffort(userTurn); + const midLoop = resolveAdaptiveEffort([ + ...userTurn, + msg("assistant", "checking"), + msg("tool", "z".repeat(5000)), + msg("assistant", "checking more"), + msg("tool", "z".repeat(5000)), + ]); + // Mid-loop: same turn → same level (pin), even though raw context grew. + assert.equal(midLoop, turnStart); +}); + +test("explicit effort wins over auto", () => { + const body = { messages: [msg("user", "hello there")], reasoning_effort: "high" }; + const out = applyAdaptiveEffort(body, { headerEffort: "auto", modelDefaultEffort: "auto" }); + assert.equal(out.reasoning_effort, "high"); + assert.equal(hasExplicitReasoningField(body), true); +}); + +test("header auto on trivial ask injects low", () => { + const body: Record = { messages: [msg("user", "hi")] }; + const out = applyAdaptiveEffort(body, { headerEffort: "auto" }); + assert.equal(out.reasoning_effort, "low"); +}); + +test("model default auto injects resolved level", () => { + const out = applyAdaptiveEffort({ messages: [msg("user", "hello")] } as Record, { + modelDefaultEffort: "auto", + }); + assert.equal(out.reasoning_effort, "low"); + assert.equal(isAdaptiveEffort("auto"), true); + assert.equal(isAdaptiveEffort("AUTO "), true); +}); + +test("no auto opt-in → unchanged reference", () => { + const body = { messages: [msg("user", "hello")] }; + const out = applyAdaptiveEffort(body, {}); + assert.equal(out, body); +}); + +test("empty messages resolves medium (safe default)", () => { + assert.equal(resolveAdaptiveEffort([]), "medium"); + assert.equal(resolveAdaptiveEffort(undefined), "medium"); + assert.equal(resolveAdaptiveEffort(null), "medium"); +});