diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 7bf4e6fa456..879e53c9fbc 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1125,8 +1125,9 @@ async function handleClaudeMessagesWithBudget( let clientEncoder: ClientEncoderOption | undefined; // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped - // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", - // verified live 2026-07-11). Strip them for that route; routed providers keep them. + // bodies: it 400s on sampling params (verified live 2026-07-11). Every openai-responses + // route drops temperature/top_p/stop/user below; max_output_tokens is left to + // stripUnsupportedForwardParams (canonical-forward.ts), which drops it only for that backend. let settledRoute: RouteResult | undefined; /** * Which replayed thinking fields the send that actually happened will serialize. @@ -1193,7 +1194,8 @@ async function handleClaudeMessagesWithBudget( clientEncoder = { protocol: "messages", stream, model: requestedModel, inputTokenFloor: claudeRequestTokenFloor() }; } if (route.provider.adapter === "openai-responses") { - delete internalBody.max_output_tokens; + // max_output_tokens is the caller's spend cap: the adapter strips it only for the canonical + // ChatGPT backend that rejects it (#5753), so other Responses gateways keep the cap. delete internalBody.temperature; delete internalBody.top_p; delete internalBody.stop; diff --git a/tests/claude-integration/claude-messages-endpoint.test.ts b/tests/claude-integration/claude-messages-endpoint.test.ts index 1663a564e8a..0f83a49d0ee 100644 --- a/tests/claude-integration/claude-messages-endpoint.test.ts +++ b/tests/claude-integration/claude-messages-endpoint.test.ts @@ -724,6 +724,41 @@ test("native openai-responses route carries prompt_cache_key + synthesized sessi } }); +test("non-canonical openai-responses Claude route keeps the caller's max_tokens cap", async () => { + let body: Record | undefined; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + body = await req.json() as Record; + return new Response([ + `event: response.output_text.delta\ndata: ${JSON.stringify({ delta: "Hello" })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ response: { status: "completed", usage: { input_tokens: 10, output_tokens: 2 } } })}\n\n`, + ].join(""), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + saveConfig({ + port: 0, + defaultProvider: "gateway", + providers: { + gateway: { adapter: "openai-responses", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`, apiKey: "k", allowPrivateNetwork: true }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "gateway/gpt-test", max_tokens: 128, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(body?.max_output_tokens).toBe(128); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("native openai-responses Claude route logs cyber terminals as 400 cyber_policy", async () => { clearRequestLogsForTests(); const upstream = Bun.serve({