diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 6e8d9b0058..c4cfae7a3a 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -223,7 +223,23 @@ export function openaiToClaudeRequest(model, body, stream) { }; // Temperature - if (body.temperature !== undefined) { + // + // Claude's Messages API rejects `temperature` when extended thinking is active. + // Two cases where thinking is on: + // (a) Caller passes `body.thinking` or `body.reasoning_effort` (handled later — + // `result.thinking` becomes truthy, and we strip temperature at the end). + // (b) The request targets Claude OAuth (claude-code), which always sends + // `Anthropic-Beta: ...,interleaved-thinking-2025-05-14,...` in headers. + // The model is forced into thinking server-side, but neither `body.thinking` + // nor `result.thinking` will be set, so we detect this by model name. This + // affects claude-opus-4.x and claude-sonnet-4.x (the families that support + // extended thinking). + // + // For models that don't force thinking (haiku, older sonnets), preserve temperature. + // Note: Opus 4.7+/Fable 5 already drop sampling params upstream of the translator via + // the registry `unsupportedParams` strip; this covers the remaining 4.x families. + const modelForcesThinking = /claude-(?:opus|sonnet)-4/i.test(String(model)); + if (body.temperature !== undefined && !modelForcesThinking) { result.temperature = body.temperature; } if (body.temperature === undefined && body.top_p !== undefined) { @@ -527,6 +543,15 @@ export function openaiToClaudeRequest(model, body, stream) { delete result[COPILOT_REASONING_SUMMARY_MARKER]; + // Final guard: Claude rejects `temperature` whenever extended thinking is + // enabled. If `result.thinking` was set above from `body.thinking` or + // `body.reasoning_effort` (manual budget or adaptive effort), drop temperature + // defensively. The model-name strip earlier already covers Claude OAuth's + // forced-thinking case (claude-opus-4.x / claude-sonnet-4.x). + if (result.thinking && result.temperature !== undefined) { + delete result.temperature; + } + // Attach toolNameMap to result for response translation if (toolNameMap.size > 0) { result._toolNameMap = toolNameMap; diff --git a/tests/unit/openai-to-claude-strip-temperature-thinking.test.ts b/tests/unit/openai-to-claude-strip-temperature-thinking.test.ts new file mode 100644 index 0000000000..480c294f92 --- /dev/null +++ b/tests/unit/openai-to-claude-strip-temperature-thinking.test.ts @@ -0,0 +1,69 @@ +/** + * openaiToClaudeRequest — strip `temperature` for Claude models with extended thinking. + * + * Claude's Messages API rejects `temperature` when extended thinking is active. + * Two cases the translator handles: + * (a) Model-name detection for forced-thinking families (/claude-(opus|sonnet)-4/), + * used by Claude OAuth which always sends the interleaved-thinking beta header. + * (b) A final guard that drops `temperature` whenever `result.thinking` was set from + * `body.thinking` or `body.reasoning_effort`. + * + * Ported from decolua/9router PR #1264 (thanks @noestelar). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiToClaudeRequest } = await import( + "../../open-sse/translator/request/openai-to-claude.ts" +); + +const baseBody = () => ({ + messages: [{ role: "user", content: "hi" }], + temperature: 0.7, +}); + +test("strips temperature for claude-opus-4 family (forced thinking)", () => { + const result = openaiToClaudeRequest("claude-opus-4-6", baseBody(), false); + assert.equal(result.temperature, undefined); +}); + +test("strips temperature for claude-sonnet-4 family (forced thinking)", () => { + const result = openaiToClaudeRequest("claude-sonnet-4-5", baseBody(), false); + assert.equal(result.temperature, undefined); +}); + +test("preserves temperature for non-thinking models (haiku, older sonnets)", () => { + const haiku = openaiToClaudeRequest("claude-haiku-5-20251001", baseBody(), false); + assert.equal(haiku.temperature, 0.7); + + const sonnet35 = openaiToClaudeRequest("claude-3-5-sonnet-20241022", baseBody(), false); + assert.equal(sonnet35.temperature, 0.7); +}); + +test("strips temperature when body.thinking is explicitly set (final guard)", () => { + // Non-thinking-forced model so the model-based strip doesn't fire; the final + // guard must catch this case. + const body = { + ...baseBody(), + thinking: { type: "enabled", budget_tokens: 4096 }, + }; + const result = openaiToClaudeRequest("claude-haiku-5-20251001", body, false); + assert.equal(result.temperature, undefined); + assert.ok(result.thinking, "thinking should still be present"); +}); + +test("strips temperature when reasoning_effort triggers thinking (final guard)", () => { + const body = { ...baseBody(), reasoning_effort: "medium" }; + const result = openaiToClaudeRequest("claude-haiku-5-20251001", body, false); + assert.equal(result.temperature, undefined); + assert.ok(result.thinking, "reasoning_effort should produce a thinking block"); +}); + +test("preserves temperature when reasoning_effort is unrecognized on a non-thinking model", () => { + // "none" is not in the effort→budget map, so no thinking block is produced and + // temperature must survive on a model that does not force thinking. + const body = { ...baseBody(), reasoning_effort: "none" }; + const result = openaiToClaudeRequest("claude-haiku-5-20251001", body, false); + assert.equal(result.temperature, 0.7); + assert.equal(result.thinking, undefined); +});