diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0544a90db3..4f95dee1d0 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -70,11 +70,8 @@ import { import { createRequestLogger } from "../utils/requestLogger.ts"; import { createPreparedRequestLogger, runWithCapture } from "../utils/providerRequestLogging.ts"; import { applyResponsesPreviousResponseIdPolicy } from "../utils/responsesStatePolicy.ts"; -import { - getModelTargetFormat, - PROVIDER_ID_TO_ALIAS, - splitClaudeEffortSuffix, -} from "../config/providerModels.ts"; +import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; +import { applyClaudeEffortVariant } from "./chatCore/claudeEffortVariant.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts"; import { getStripTypesForProviderModel, @@ -954,30 +951,18 @@ export async function handleChatCore({ // it into Claude thinking/effort config. An explicit client-supplied effort always // wins; native Claude passthrough is left untouched (it carries its own `thinking`), // and non-thinking base models are cleaned up later by normalizeThinkingForModel(). - if ( - (provider === "claude" || isClaudeCodeCompatibleProvider(provider)) && - typeof effectiveModel === "string" - ) { - const { baseModel, effort } = splitClaudeEffortSuffix(effectiveModel); - if (effort) { - effectiveModel = baseModel; - if (body && typeof body === "object" && !Array.isArray(body)) { - const claudeBody = body as Record; - claudeBody.model = baseModel; - if (sourceFormat !== FORMATS.CLAUDE) { - const explicitEffort = - claudeBody.reasoning_effort ?? - (claudeBody.reasoning as Record | undefined)?.effort ?? - (claudeBody.output_config as Record | undefined)?.effort; - if (explicitEffort === undefined || explicitEffort === null || explicitEffort === "") { - claudeBody.reasoning_effort = effort; - } - } - } - log?.info?.( - "PARAMS", - `Claude effort variant: stripped "-${effort}" → ${baseModel} (reasoning_effort=${effort})` - ); + // Extracted to chatCore/claudeEffortVariant.ts (#3501); mutates body in place and returns the + // stripped model + an optional log line, keeping behaviour byte-identical. + { + const effortVariant = applyClaudeEffortVariant({ + provider, + effectiveModel, + body, + sourceFormat, + }); + effectiveModel = effortVariant.effectiveModel; + if (effortVariant.log) { + log?.info?.("PARAMS", effortVariant.log); } } diff --git a/open-sse/handlers/chatCore/claudeEffortVariant.ts b/open-sse/handlers/chatCore/claudeEffortVariant.ts new file mode 100644 index 0000000000..e2a2a8e188 --- /dev/null +++ b/open-sse/handlers/chatCore/claudeEffortVariant.ts @@ -0,0 +1,62 @@ +/** + * chatCore Claude effort-variant normalizer (Quality Gate v2 / Fase 9 — chatCore god-file + * decomposition, #3501). + * + * The Claude / Claude-Code model picker (e.g. VS Code's "Effort" slider) advertises + * claude-...-{low,medium,high,xhigh,max}. Anthropic has no such model, so the suffixed id 404s + * upstream. This strips it back to the real base id and surfaces the level as reasoning_effort so + * the OpenAI→Claude translator / Claude-Code bridge can turn it into Claude thinking/effort config. + * An explicit client-supplied effort always wins; native Claude passthrough (sourceFormat === claude) + * is left untouched (it carries its own `thinking`). The body is mutated in place (model + + * reasoning_effort), byte-identical to the previous inline block; the new effectiveModel and an + * optional log line are returned for the handler to apply. + */ + +import { splitClaudeEffortSuffix } from "../../config/providerModels.ts"; +import { isClaudeCodeCompatibleProvider } from "../../services/claudeCodeCompatible.ts"; +import { FORMATS } from "../../translator/formats.ts"; + +/** + * True when the client already supplied an explicit reasoning effort (top-level reasoning_effort, + * reasoning.effort, or output_config.effort) — in which case the stripped suffix must not overwrite + * it. A blank string counts as "not supplied". + */ +function hasExplicitClaudeEffort(claudeBody: Record): boolean { + const explicitEffort = + claudeBody.reasoning_effort ?? + (claudeBody.reasoning as Record | undefined)?.effort ?? + (claudeBody.output_config as Record | undefined)?.effort; + return !(explicitEffort === undefined || explicitEffort === null || explicitEffort === ""); +} + +export function applyClaudeEffortVariant(opts: { + provider: string | null | undefined; + effectiveModel: string; + /** Mutated in place (model + reasoning_effort) when an effort suffix is stripped. */ + body: unknown; + sourceFormat: string; +}): { effectiveModel: string; log: string | null } { + const { provider, body, sourceFormat } = opts; + let effectiveModel = opts.effectiveModel; + let log: string | null = null; + + if ( + (provider === "claude" || isClaudeCodeCompatibleProvider(provider)) && + typeof effectiveModel === "string" + ) { + const { baseModel, effort } = splitClaudeEffortSuffix(effectiveModel); + if (effort) { + effectiveModel = baseModel; + if (body && typeof body === "object" && !Array.isArray(body)) { + const claudeBody = body as Record; + claudeBody.model = baseModel; + if (sourceFormat !== FORMATS.CLAUDE && !hasExplicitClaudeEffort(claudeBody)) { + claudeBody.reasoning_effort = effort; + } + } + log = `Claude effort variant: stripped "-${effort}" → ${baseModel} (reasoning_effort=${effort})`; + } + } + + return { effectiveModel, log }; +} diff --git a/tests/unit/chatcore-claude-effort-variant.test.ts b/tests/unit/chatcore-claude-effort-variant.test.ts new file mode 100644 index 0000000000..03bf40a4c1 --- /dev/null +++ b/tests/unit/chatcore-claude-effort-variant.test.ts @@ -0,0 +1,107 @@ +// tests/unit/chatcore-claude-effort-variant.test.ts +// Characterization of applyClaudeEffortVariant — the Claude effort-suffix normalization extracted +// from handleChatCore (chatCore god-file decomposition, #3501). The VS Code "Effort" slider +// advertises claude-...-{low,medium,high,xhigh,max}; Anthropic has no such model, so the suffix is +// stripped to the base id and surfaced as reasoning_effort. Locks: the provider gate (claude / +// claude-code-compatible only), the in-place body mutation (model + reasoning_effort), the +// sourceFormat==="claude" skip, the explicit-effort-wins rule, and the returned effectiveModel/log. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyClaudeEffortVariant } from "../../open-sse/handlers/chatCore/claudeEffortVariant.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +test("claude provider + effort suffix → strips to base, mutates body model + reasoning_effort, returns log", () => { + const body: Record = { model: "claude-sonnet-4-high", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "claude", + effectiveModel: "claude-sonnet-4-high", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(r.effectiveModel, "claude-sonnet-4"); + assert.equal(body.model, "claude-sonnet-4"); + assert.equal(body.reasoning_effort, "high"); + assert.match(String(r.log), /stripped "-high" → claude-sonnet-4 \(reasoning_effort=high\)/); +}); + +test("claude-code-compatible provider triggers the same stripping", () => { + const body: Record = { model: "claude-opus-4-xhigh", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "anthropic-compatible-cc-default", + effectiveModel: "claude-opus-4-xhigh", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(r.effectiveModel, "claude-opus-4"); + assert.equal(body.model, "claude-opus-4"); + assert.equal(body.reasoning_effort, "xhigh"); +}); + +test("sourceFormat 'claude' strips the model but does NOT inject reasoning_effort", () => { + const body: Record = { model: "claude-sonnet-4-medium", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "claude", + effectiveModel: "claude-sonnet-4-medium", + body, + sourceFormat: FORMATS.CLAUDE, + }); + assert.equal(r.effectiveModel, "claude-sonnet-4"); + assert.equal(body.model, "claude-sonnet-4"); + assert.equal(body.reasoning_effort, undefined); +}); + +test("an explicit client reasoning_effort wins (not overwritten)", () => { + const body: Record = { model: "claude-sonnet-4-low", reasoning_effort: "high", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "claude", + effectiveModel: "claude-sonnet-4-low", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(r.effectiveModel, "claude-sonnet-4"); + assert.equal(body.reasoning_effort, "high"); // unchanged +}); + +test("explicit effort nested under reasoning.effort also wins", () => { + const body: Record = { + model: "claude-sonnet-4-low", + reasoning: { effort: "medium" }, + messages: [], + }; + const r = applyClaudeEffortVariant({ + provider: "claude", + effectiveModel: "claude-sonnet-4-low", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(body.reasoning_effort, undefined); // explicit reasoning.effort present → no injection + assert.equal(r.effectiveModel, "claude-sonnet-4"); +}); + +test("no effort suffix → no change, no log", () => { + const body: Record = { model: "claude-sonnet-4", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "claude", + effectiveModel: "claude-sonnet-4", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(r.effectiveModel, "claude-sonnet-4"); + assert.equal(body.model, "claude-sonnet-4"); + assert.equal(body.reasoning_effort, undefined); + assert.equal(r.log, null); +}); + +test("non-claude provider is a no-op even with an effort suffix", () => { + const body: Record = { model: "gpt-5-high", messages: [] }; + const r = applyClaudeEffortVariant({ + provider: "openai", + effectiveModel: "gpt-5-high", + body, + sourceFormat: FORMATS.OPENAI, + }); + assert.equal(r.effectiveModel, "gpt-5-high"); + assert.equal(body.model, "gpt-5-high"); + assert.equal(body.reasoning_effort, undefined); + assert.equal(r.log, null); +});