From 4ebaf4ff0a742cf5c3406d8298b2bcaa2d053c17 Mon Sep 17 00:00:00 2001 From: Onur Solmaz <2453968+osolmaz@users.noreply.github.com> Date: Mon, 25 May 2026 19:49:04 +0800 Subject: [PATCH] fix: scope openai payload turn guard --- src/agents/openai-completions-compat.test.ts | 20 ++++ src/agents/openai-completions-compat.ts | 6 ++ src/agents/openai-transport-stream.test.ts | 100 ++++++++++++++++++- src/agents/openai-transport-stream.ts | 8 +- 4 files changed, 128 insertions(+), 6 deletions(-) diff --git a/src/agents/openai-completions-compat.test.ts b/src/agents/openai-completions-compat.test.ts index 60b5a10d63f9..51342af7f983 100644 --- a/src/agents/openai-completions-compat.test.ts +++ b/src/agents/openai-completions-compat.test.ts @@ -82,6 +82,26 @@ describe("resolveOpenAICompletionsCompatDefaults", () => { expect(defaults.supportsReasoningEffort).toBe(false); expect(defaults.maxTokensField).toBe("max_tokens"); }); + + it("requires a non-empty user or assistant turn for ModelStudio-compatible providers", () => { + expect( + resolveOpenAICompletionsCompatDefaults({ + provider: "qwen", + endpointClass: "modelstudio-native", + knownProviderFamily: "modelstudio", + }).requiresNonEmptyUserOrAssistantMessage, + ).toBe(true); + }); + + it("does not require a non-empty user or assistant turn for generic local endpoints", () => { + expect( + resolveOpenAICompletionsCompatDefaults({ + provider: "vllm", + endpointClass: "local", + knownProviderFamily: "vllm", + }).requiresNonEmptyUserOrAssistantMessage, + ).toBe(false); + }); }); describe("detectOpenAICompletionsCompat", () => { diff --git a/src/agents/openai-completions-compat.ts b/src/agents/openai-completions-compat.ts index 6302caba9076..acfadd3e6327 100644 --- a/src/agents/openai-completions-compat.ts +++ b/src/agents/openai-completions-compat.ts @@ -21,6 +21,7 @@ type OpenAICompletionsCompatDefaults = { visibleReasoningDetailTypes: string[]; supportsStrictMode: boolean; requiresReasoningContentOnAssistantMessages: boolean; + requiresNonEmptyUserOrAssistantMessage: boolean; }; type DetectedOpenAICompletionsCompat = { @@ -51,6 +52,10 @@ export function resolveOpenAICompletionsCompatDefaults( knownProviderFamily === "modelstudio" || endpointClass === "moonshot-native" || endpointClass === "modelstudio-native"; + const isModelStudioLike = + knownProviderFamily === "modelstudio" || + endpointClass === "modelstudio-native" || + (isDefaultRoute && isDefaultRouteProvider(provider, "dashscope", "modelstudio", "qwen")); const isZai = endpointClass === "zai-native" || (isDefaultRoute && isDefaultRouteProvider(input.provider, "zai")); @@ -112,6 +117,7 @@ export function resolveOpenAICompletionsCompatDefaults( visibleReasoningDetailTypes: isOpenRouterLike ? ["response.output_text", "response.text"] : [], supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint, requiresReasoningContentOnAssistantMessages: isDeepSeek || isXiaomi, + requiresNonEmptyUserOrAssistantMessage: isModelStudioLike, }; } diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index d71733d1b6eb..13ec89ad7130 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -1028,13 +1028,13 @@ describe("openai transport stream", () => { } }); - it("refuses OpenAI-compatible chat streams with no user or assistant payload turns", async () => { + it("refuses ModelStudio chat streams with no user or assistant payload turns", async () => { const model = { - id: "mlx-community/Qwen3-30B-A3B-6bit", - name: "Qwen3 MLX", + id: "qwen-coder-plus", + name: "qwen-coder-plus", api: "openai-completions", - provider: "mlx", - baseUrl: "http://127.0.0.1:9/v1", + provider: "qwen", + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -1068,6 +1068,96 @@ describe("openai transport stream", () => { expect(String(errorPayload?.errorMessage)).toContain("system/tool-only request"); }); + it("allows generic OpenAI-compatible chat streams without the ModelStudio turn guard", async () => { + let capturedRoles: string[] | undefined; + const server = createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const parsed = JSON.parse(body) as { messages?: Array<{ role?: string }> }; + capturedRoles = parsed.messages?.map((message) => message.role ?? ""); + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const created = Math.floor(Date.now() / 1000); + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-system-only", + object: "chat.completion.chunk", + created, + model: "generic-openai-compatible", + choices: [ + { + index: 0, + delta: { role: "assistant", content: "OK" }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-system-only", + object: "chat.completion.chunk", + created, + model: "generic-openai-compatible", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + ); + res.write("data: [DONE]\n\n"); + res.end(); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Missing loopback server address"); + } + const model = { + id: "generic-openai-compatible", + name: "Generic OpenAI Compatible", + api: "openai-completions", + provider: "custom-openai-compatible", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 4096, + maxTokens: 256, + } satisfies Model<"openai-completions">; + const stream = createOpenAICompletionsTransportStreamFn()( + model, + { + systemPrompt: "runtime-only system prompt", + messages: [], + tools: [], + } as never, + { apiKey: "test-key" } as never, + ); + + let doneReason: string | undefined; + for await (const event of stream as AsyncIterable<{ type: string; reason?: string }>) { + if (event.type === "done") { + doneReason = event.reason; + } + } + + expect(capturedRoles).toEqual(["system"]); + expect(doneReason).toBe("stop"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("parses JSON chat completions returned to streaming requests", async () => { let capturedStreamFlag: unknown; const server = createServer((req, res) => { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 467c28205f78..e4334dd34e85 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -2419,7 +2419,10 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { enforceCodeModeResponsesToolSurface(params); assertCodeModeResponsesToolSurface(params); } - assertOpenAICompletionsPayloadHasConversationTurn(params, model); + const compat = getCompat(model as OpenAIModeModel); + if (compat.requiresNonEmptyUserOrAssistantMessage) { + assertOpenAICompletionsPayloadHasConversationTurn(params, model); + } const responseStream = (await client.chat.completions.create( params as never, buildOpenAISdkRequestOptions(model, options?.signal), @@ -2869,6 +2872,7 @@ function detectCompat(model: OpenAIModeModel) { supportsStrictMode: compatDefaults.supportsStrictMode, requiresReasoningContentOnAssistantMessages: compatDefaults.requiresReasoningContentOnAssistantMessages, + requiresNonEmptyUserOrAssistantMessage: compatDefaults.requiresNonEmptyUserOrAssistantMessage, }; } @@ -2891,6 +2895,7 @@ function getCompat(model: OpenAIModeModel): { strictMessageKeys: boolean; visibleReasoningDetailTypes: string[]; requiresReasoningContentOnAssistantMessages: boolean; + requiresNonEmptyUserOrAssistantMessage: boolean; } { const detected = detectCompat(model); const compat = model.compat ?? {}; @@ -2924,6 +2929,7 @@ function getCompat(model: OpenAIModeModel): { compat.visibleReasoningDetailTypes ?? detected.visibleReasoningDetailTypes, requiresReasoningContentOnAssistantMessages: detected.requiresReasoningContentOnAssistantMessages, + requiresNonEmptyUserOrAssistantMessage: detected.requiresNonEmptyUserOrAssistantMessage, }; }