From b8b873b9872db04a938fb4357b5e8e824ddc051c Mon Sep 17 00:00:00 2001 From: scturtle Date: Tue, 1 Sep 2026 20:42:52 +0800 Subject: [PATCH] fix(ai): add supportsMaxOutputTokens compat flag for openai-responses (#8941) Some OpenAI Responses-compatible gateways (e.g. Codex-protocol proxies) reject the max_output_tokens parameter with a 400 BadRequest. The openai-responses API always sent it when maxTokens was set, with no way to opt out. Add a supportsMaxOutputTokens compat flag (default: true) to OpenAIResponsesCompat. When false, buildParams omits max_output_tokens from the request body. Also register the flag in the coding-agent model-config schema so custom providers can set it. --- packages/ai/src/api/openai-responses.ts | 3 +- packages/ai/src/types.ts | 2 + .../ai/test/openai-responses-compat.test.ts | 75 +++++++++++++++++++ .../coding-agent/src/core/model-config.ts | 1 + 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/api/openai-responses.ts b/packages/ai/src/api/openai-responses.ts index 6bfb3d65b..2b0723798 100644 --- a/packages/ai/src/api/openai-responses.ts +++ b/packages/ai/src/api/openai-responses.ts @@ -75,6 +75,7 @@ function getCompat(model: Model<"openai-responses">): Required { expect(result.usage.cost.total).toBe((model.cost.input + model.cost.output) * multiplier * tokenScale); }); }); + +describe("openai-responses max_output_tokens compat", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("sends max_output_tokens by default", async () => { + let capturedPayload: { max_output_tokens?: number } | undefined; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + getModel("openai", "gpt-5.4"), + { + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test-key", + maxTokens: 1024, + onPayload: (payload) => { + capturedPayload = payload as { max_output_tokens?: number }; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload?.max_output_tokens).toBe(1024); + }); + + it("omits max_output_tokens when supportsMaxOutputTokens is false", async () => { + const baseModel = getModel("openai", "gpt-5.4"); + const model: Model<"openai-responses"> = { + ...baseModel, + compat: { ...baseModel.compat, supportsMaxOutputTokens: false }, + }; + let capturedPayload: { max_output_tokens?: number } | undefined; + + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + ); + + const stream = streamOpenAIResponses( + model, + { + systemPrompt: "sys", + messages: [{ role: "user", content: "hi", timestamp: Date.now() }], + }, + { + apiKey: "test-key", + maxTokens: 1024, + onPayload: (payload) => { + capturedPayload = payload as { max_output_tokens?: number }; + }, + }, + ); + + for await (const event of stream) { + if (event.type === "done" || event.type === "error") break; + } + + expect(capturedPayload?.max_output_tokens).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/src/core/model-config.ts b/packages/coding-agent/src/core/model-config.ts index 6036d5af9..4d426649b 100644 --- a/packages/coding-agent/src/core/model-config.ts +++ b/packages/coding-agent/src/core/model-config.ts @@ -121,6 +121,7 @@ const OpenAIResponsesCompatSchema = Type.Object({ supportsOpenAIGrammarTools: Type.Optional(Type.Boolean()), supportsAdditionalTools: Type.Optional(Type.Boolean()), supportsToolSearch: Type.Optional(Type.Boolean()), + supportsMaxOutputTokens: Type.Optional(Type.Boolean()), }); const AnthropicMessagesCompatSchema = Type.Object({