fix(sse): strip temperature for Claude models with extended thinking (#4853)

Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-26 00:15:23 -03:00
committed by GitHub
parent ac55b201ba
commit 8a47bf2f5f
2 changed files with 95 additions and 1 deletions

View File

@@ -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;

View File

@@ -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);
});