diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index bc2a36c8c7..822229b984 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -445,11 +445,14 @@ export function openaiResponsesToOpenAIRequest( } delete result.store; - // Copilot-only: promote Responses `reasoning.{effort,summary}` to Chat fields - // so the downstream openai-to-claude translator can enable extended thinking. - // Gated by the UA marker from translateRequest; other clients see `reasoning` dropped. + // Promote Responses `reasoning.effort` to the Chat-Completions-native + // `reasoning_effort` field so OpenAI-family upstreams (and the downstream + // openai-to-claude translator's extended-thinking path) keep the hint when a + // Responses client is routed across formats. The Copilot-only `summary` -> + // Claude summarized-thinking marker stays behind the UA gate from + // translateRequest because it is Copilot-specific glue, not an OpenAI-native + // field. Ported from upstream PR decolua/9router#1817 (ryanngit). if ( - credentialRecord._copilotClient === true && root.reasoning && typeof root.reasoning === "object" && !Array.isArray(root.reasoning) @@ -459,7 +462,10 @@ export function openaiResponsesToOpenAIRequest( if (effort && result.reasoning_effort === undefined) { result.reasoning_effort = normalizeResponsesReasoningEffort(effort); } - if (shouldRequestClaudeSummarizedThinking(reasoningRec.summary)) { + if ( + credentialRecord._copilotClient === true && + shouldRequestClaudeSummarizedThinking(reasoningRec.summary) + ) { result[COPILOT_REASONING_SUMMARY_MARKER] = "summarized"; } } diff --git a/tests/unit/openai-responses-reasoning-effort.test.ts b/tests/unit/openai-responses-reasoning-effort.test.ts new file mode 100644 index 0000000000..9b8151bf15 --- /dev/null +++ b/tests/unit/openai-responses-reasoning-effort.test.ts @@ -0,0 +1,72 @@ +/** + * Reasoning-effort mapping across the OpenAI Chat <-> Responses request translators. + * + * Chat Completions carries the reasoning hint as top-level `reasoning_effort`; the + * Responses API nests it as `reasoning.effort`. These tests pin both directions so + * the hint survives when a request crosses formats (e.g. a Responses client routed + * to an OpenAI-native Chat Completions upstream). + * + * Ported from upstream PR https://github.com/decolua/9router/pull/1817 (ryanngit). + * Adapted: OmniRoute previously promoted `reasoning.effort` only behind the + * Copilot-client gate (commit 75d9a83c25), which silently dropped the field for + * every other Responses client (OpenCode, Cursor, raw OpenAI Responses, ...). + * This test pins the unconditional promotion of effort while keeping the + * Copilot-only `summary` -> Claude thinking marker behind its existing gate. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + openaiToOpenAIResponsesRequest, + openaiResponsesToOpenAIRequest, +} from "../../open-sse/translator/request/openai-responses.ts"; +import { convertResponsesApiFormat } from "../../open-sse/translator/helpers/responsesApiHelper.ts"; + +function asRecord(value: unknown): Record { + return value as Record; +} + +test("Responses -> Chat promotes reasoning.effort for non-Copilot clients", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "gpt-test", + { input: "hello", reasoning: { effort: "high" } }, + true, + {} // no _copilotClient marker + ) + ); + assert.equal(out.reasoning_effort, "high"); + assert.equal(out.reasoning, undefined); +}); + +test("Responses -> Chat preserves reasoning.effort via the helper wrapper", () => { + const out = asRecord( + convertResponsesApiFormat({ input: "hello", reasoning: { effort: "medium" } }) + ); + assert.equal(out.reasoning_effort, "medium"); + assert.equal(out.reasoning, undefined); +}); + +test("Responses -> Chat does not overwrite an explicit reasoning_effort", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "gpt-test", + { input: "hello", reasoning_effort: "low", reasoning: { effort: "high" } }, + true, + {} + ) + ); + // Explicit Chat-level field wins over the Responses nesting. + assert.equal(out.reasoning_effort, "low"); +}); + +test("Chat -> Responses already wraps reasoning_effort into reasoning.effort", () => { + const out = asRecord( + openaiToOpenAIResponsesRequest( + "gpt-test", + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + {} + ) + ); + assert.deepEqual(out.reasoning, { effort: "high" }); +}); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index b20ac64bb0..4ce567f8e1 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -623,7 +623,10 @@ test("Chat -> Responses prefers max_completion_tokens over max_tokens when both assert.equal((result as any).max_completion_tokens, undefined); }); -test("Responses -> Chat drops `reasoning` and does not synthesize reasoning_effort without Copilot marker", () => { +test("Responses -> Chat drops `reasoning` and promotes effort to reasoning_effort even without Copilot marker", () => { + // Updated per upstream PR decolua/9router#1817 (ryanngit): the OpenAI-native + // `reasoning_effort` hint is always preserved across the Responses -> Chat + // hop; only the Copilot-specific `summary` -> Claude marker stays gated. const result = openaiResponsesToOpenAIRequest( "claude-opus-4-7", { @@ -635,7 +638,7 @@ test("Responses -> Chat drops `reasoning` and does not synthesize reasoning_effo ) as Record; assert.equal(result.reasoning, undefined); - assert.equal(result.reasoning_effort, undefined); + assert.equal(result.reasoning_effort, "high"); }); test("Responses -> Chat promotes reasoning.effort to reasoning_effort when _copilotClient is set", () => {