From be6c3ac662a0efada099c20950da64be1972ecde Mon Sep 17 00:00:00 2001 From: ivan_yakimkin Date: Thu, 16 Apr 2026 13:26:49 +0300 Subject: [PATCH 1/2] fix: preserve max_output_tokens for Responses API targets in chatCore sanitization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The common input sanitization in chatCore unconditionally normalizes max_output_tokens to max_tokens (#994). This works for Chat Completions targets but breaks Responses API passthrough (source and target both openai-responses), because: 1. chatCore replaces max_output_tokens with max_tokens 2. Same-format requests skip the translator entirely 3. max_tokens reaches the upstream, which rejects it with 'Unsupported parameter: max_tokens' The fix makes the normalization conditional: skip it when the target format is openai-responses, where max_output_tokens is the canonical field. The existing openaiToOpenAIResponsesRequest translator (#1245) still handles the openai → openai-responses path correctly. Adds a test that verifies max_output_tokens is not normalized to max_tokens when routing to a Responses API target. --- open-sse/handlers/chatCore.ts | 8 ++++-- tests/unit/chatcore-sanitization.test.ts | 32 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 24532fda14..cd2f999e49 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -910,8 +910,12 @@ export async function handleChatCore({ } // ── Common input sanitization (runs for ALL paths including passthrough) ── - // #994: Normalize max_output_tokens to max_tokens for universal compatibility - if (body.max_output_tokens !== undefined) { + // #994: Normalize max_output_tokens to max_tokens for universal compatibility. + // Skip for Responses API targets where max_output_tokens is the canonical field; + // normalizing it to max_tokens there causes "Unsupported parameter: max_tokens" + // errors because Responses→Responses passthrough skips the translator that would + // convert it back (see openaiToOpenAIResponsesRequest). + if (body.max_output_tokens !== undefined && targetFormat !== FORMATS.OPENAI_RESPONSES) { if (body.max_tokens === undefined) { body.max_tokens = body.max_output_tokens; } diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index 3f56eadda6..be55dc3dc7 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -179,6 +179,38 @@ test("chatCore sanitization normalizes max_output_tokens into max_tokens", async assert.equal("max_tokens" in untouched.call.body, false); }); +test("chatCore sanitization preserves max_output_tokens for openai-responses targets", async () => { + // When the target provider uses openai-responses format (e.g. Codex), + // max_output_tokens is the canonical field and must NOT be normalized to + // max_tokens. Normalizing it breaks Responses→Responses passthrough because + // the translator (which converts max_tokens back) is skipped for same-format. + const { call } = await invokeChatCore({ + endpoint: "/v1/responses", + body: { + model: "gpt-5.4", + max_output_tokens: 4096, + input: [{ role: "user", content: "hello" }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_test", + object: "response", + status: "completed", + model: "gpt-5.4", + output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + // max_output_tokens should survive sanitization for Responses targets + assert.equal("max_tokens" in call.body, false, "max_tokens must not be injected for Responses targets"); +}); + test("chatCore sanitization strips empty message names and filters empty tool names", async () => { // Note: `input` field is tested separately because its presence triggers // Responses format detection (PR #1002), which changes message handling. From c7a11eea4c000b8fd3e67787c8e376cba5c849eb Mon Sep 17 00:00:00 2001 From: ivan_yakimkin Date: Thu, 16 Apr 2026 13:34:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20add=20reverse=20normalization=20max?= =?UTF-8?q?=5Ftokens/max=5Fcompletion=5Ftokens=20=E2=86=92=20max=5Foutput?= =?UTF-8?q?=5Ftokens=20for=20Responses=20targets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: when targeting openai-responses, also normalize max_tokens and max_completion_tokens INTO max_output_tokens, not just skip the outbound normalization. This covers the case where a Chat Completions client sends max_tokens to a Responses endpoint via passthrough. Extends test to cover both reverse normalization paths. --- open-sse/handlers/chatCore.ts | 21 +++++++--- tests/unit/chatcore-sanitization.test.ts | 52 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index cd2f999e49..3f796d71c5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -910,12 +910,21 @@ export async function handleChatCore({ } // ── Common input sanitization (runs for ALL paths including passthrough) ── - // #994: Normalize max_output_tokens to max_tokens for universal compatibility. - // Skip for Responses API targets where max_output_tokens is the canonical field; - // normalizing it to max_tokens there causes "Unsupported parameter: max_tokens" - // errors because Responses→Responses passthrough skips the translator that would - // convert it back (see openaiToOpenAIResponsesRequest). - if (body.max_output_tokens !== undefined && targetFormat !== FORMATS.OPENAI_RESPONSES) { + // #994: Normalize between max_output_tokens and max_tokens for universal compatibility. + // For Responses API targets, max_output_tokens is the canonical field. For others, + // max_tokens is preferred. We handle normalization here to support passthrough + // paths where the translator is skipped. + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + if (body.max_output_tokens === undefined) { + if (body.max_completion_tokens !== undefined) { + body.max_output_tokens = body.max_completion_tokens; + delete body.max_completion_tokens; + } else if (body.max_tokens !== undefined) { + body.max_output_tokens = body.max_tokens; + delete body.max_tokens; + } + } + } else if (body.max_output_tokens !== undefined) { if (body.max_tokens === undefined) { body.max_tokens = body.max_output_tokens; } diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index be55dc3dc7..b4ebe8653a 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -209,6 +209,58 @@ test("chatCore sanitization preserves max_output_tokens for openai-responses tar // max_output_tokens should survive sanitization for Responses targets assert.equal("max_tokens" in call.body, false, "max_tokens must not be injected for Responses targets"); + + // Reverse normalization: max_tokens → max_output_tokens for Responses targets + const fromMaxTokens = await invokeChatCore({ + endpoint: "/v1/responses", + body: { + model: "gpt-5.4", + max_tokens: 2048, + input: [{ role: "user", content: "hello" }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_test2", + object: "response", + status: "completed", + model: "gpt-5.4", + output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + assert.equal("max_tokens" in fromMaxTokens.call.body, false, "max_tokens should be converted to max_output_tokens"); + + // Reverse normalization: max_completion_tokens → max_output_tokens for Responses targets + const fromMaxCompletion = await invokeChatCore({ + endpoint: "/v1/responses", + body: { + model: "gpt-5.4", + max_completion_tokens: 8192, + input: [{ role: "user", content: "hello" }], + }, + responseFactory: () => + new Response( + JSON.stringify({ + id: "resp_test3", + object: "response", + status: "completed", + model: "gpt-5.4", + output: [ + { type: "message", role: "assistant", content: [{ type: "output_text", text: "ok" }] }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + }); + + assert.equal("max_completion_tokens" in fromMaxCompletion.call.body, false, "max_completion_tokens should be converted to max_output_tokens"); }); test("chatCore sanitization strips empty message names and filters empty tool names", async () => {