From 3fd52514e35f9200f35dfdcf7b388979fe7a3e9b Mon Sep 17 00:00:00 2001 From: wuqxuan Date: Thu, 9 Jul 2026 21:10:03 +0800 Subject: [PATCH] fix: surface OpenAI chat-completions refusal as assistant text (#102344) * fix: surface OpenAI chat-completions refusal as assistant text * fix: cover message.refusal on packages/ai completions path --- .../src/providers/openai-completions.test.ts | 57 ++++++++++- .../ai/src/providers/openai-completions.ts | 32 +++++-- src/agents/openai-transport-stream.test.ts | 95 +++++++++++++++++++ src/agents/openai-transport-stream.ts | 12 +++ 4 files changed, 186 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/providers/openai-completions.test.ts b/packages/ai/src/providers/openai-completions.test.ts index 9315144beda..a8c82fd5be5 100644 --- a/packages/ai/src/providers/openai-completions.test.ts +++ b/packages/ai/src/providers/openai-completions.test.ts @@ -9,8 +9,13 @@ type DeepPartial = { [P in keyof T]?: DeepPartial }; type OpenAICompatibleDelta = DeepPartial & { reasoning_content?: string; }; -type OpenAICompatibleChoice = Omit, "delta"> & { +type OpenAICompatibleChoice = Omit< + DeepPartial, + "delta" | "message" +> & { delta?: OpenAICompatibleDelta; + // Some OpenAI-compatible endpoints deliver a full message instead of delta. + message?: OpenAICompatibleDelta; }; type OpenAICompatibleChatCompletionChunk = Omit< DeepPartial, @@ -124,6 +129,32 @@ function makeTextChunk(text: string): OpenAICompatibleChatCompletionChunk { }; } +function makeRefusalChunk(refusal: string): OpenAICompatibleChatCompletionChunk { + return { + id: "chatcmpl-test", + choices: [ + { + index: 0, + delta: { role: "assistant", content: null, refusal }, + finish_reason: "stop", + }, + ], + }; +} + +function makeRefusalMessageChunk(refusal: string): OpenAICompatibleChatCompletionChunk { + return { + id: "chatcmpl-test", + choices: [ + { + index: 0, + message: { role: "assistant", content: null, refusal }, + finish_reason: "stop", + }, + ], + }; +} + function makeToolCallChunk( id: string, name: string, @@ -221,6 +252,30 @@ describe("OpenAI-compatible completions params", () => { } }); + it("surfaces chat-completions refusal deltas as visible assistant text", async () => { + mockChunksRef.chunks = [makeRefusalChunk("I can't help with that.")]; + + const result = await streamOpenAICompletions(model, context, { + apiKey: "sk-test", + }).result(); + + expect(result.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]); + expect(result.stopReason).toBe("stop"); + }); + + it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => { + mockChunksRef.chunks = [makeRefusalMessageChunk("Requests like this are not allowed.")]; + + const result = await streamOpenAICompletions(model, context, { + apiKey: "sk-test", + }).result(); + + expect(result.content).toStrictEqual([ + { type: "text", text: "Requests like this are not allowed." }, + ]); + expect(result.stopReason).toBe("stop"); + }); + it("preserves a valid provider-reported usage cost", async () => { mockChunksRef.chunks = [ makeTextChunk("ok"), diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 4934a9b152e..d14e2895383 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -424,13 +424,19 @@ export const streamOpenAICompletions: StreamFunction< hasFinishReason = true; } - if (choice.delta) { + // Some OpenAI-compatible endpoints deliver a full `message` instead of + // `delta` (including refusal-only turns with content: null). Normalize + // the same way the managed agent transport does. + const choiceDelta = + choice.delta ?? + (choice as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message; + if (choiceDelta) { // Some endpoints return reasoning in reasoning_content (llama.cpp), // or reasoning (other openai compatible endpoints) // Use the first non-empty reasoning field to avoid duplication // (e.g., chutes.ai returns both reasoning_content and reasoning with same content) const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"]; - const deltaFields = choice.delta as Record; + const deltaFields = choiceDelta as Record; const shouldEmitReasoning = Boolean(model.reasoning && options?.reasoningEffort); let foundReasoningField: string | null = null; for (const field of reasoningFields) { @@ -454,19 +460,27 @@ export const streamOpenAICompletions: StreamFunction< } } if ( - choice.delta.content !== null && - choice.delta.content !== undefined && - choice.delta.content.length > 0 + choiceDelta.content !== null && + choiceDelta.content !== undefined && + choiceDelta.content.length > 0 ) { - appendPartitionedContent(choice.delta.content, Boolean(foundReasoningField)); + appendPartitionedContent(choiceDelta.content, Boolean(foundReasoningField)); } - if (choice?.delta?.tool_calls) { + // Chat Completions can put safety/structured-output refusals in a + // top-level `refusal` field with content null. Surface that as + // visible text so the assistant turn is not empty. + const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : ""; + if (refusalText.length > 0) { + appendPartitionedContent(refusalText, Boolean(foundReasoningField)); + } + + if (choiceDelta.tool_calls) { flushPartitionedContent(); // The tool-call lane is also a reasoning boundary; seal the thought // before toolcall_start so thinking_end never trails the action. sealNativeReasoningBeforeText(); - for (const toolCall of choice.delta.tool_calls) { + for (const toolCall of choiceDelta.tool_calls) { const block = ensureToolCallBlock(toolCall); if (!block.id && toolCall.id) { block.id = toolCall.id; @@ -492,7 +506,7 @@ export const streamOpenAICompletions: StreamFunction< } } - const reasoningDetails = (choice.delta as { reasoning_details?: unknown }) + const reasoningDetails = (choiceDelta as { reasoning_details?: unknown }) .reasoning_details; if (reasoningDetails && Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index d03b436fd49..1e8a936f869 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -2785,6 +2785,101 @@ describe("openai transport stream", () => { expect(output.stopReason).toBe("stop"); }); + it("surfaces chat-completions refusal deltas as visible assistant text", async () => { + const model = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-completions" as const, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"] as const, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + } satisfies Model<"openai-completions">; + const output = createAssistantOutput(model); + const events: CapturedStreamEvent[] = []; + + await testing.processOpenAICompletionsStream( + streamChunks([ + { + id: "chatcmpl-refusal-delta", + object: "chat.completion.chunk", + created: 1, + model: model.id, + choices: [ + { + index: 0, + delta: { role: "assistant", content: null, refusal: "I can't help with that." }, + logprobs: null, + finish_reason: "stop", + }, + ], + }, + ]), + output, + model, + { push: (event) => events.push(event as CapturedStreamEvent) }, + ); + + expect(output.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]); + expect(output.stopReason).toBe("stop"); + expect( + events.some( + (event) => event.type === "text_delta" && event.delta === "I can't help with that.", + ), + ).toBe(true); + }); + + it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => { + const model = { + id: "gpt-5.5", + name: "GPT-5.5", + api: "openai-completions" as const, + provider: "openai", + baseUrl: "https://api.openai.com/v1", + reasoning: false, + input: ["text"] as const, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 4096, + } satisfies Model<"openai-completions">; + const output = createAssistantOutput(model); + + await testing.processOpenAICompletionsStream( + streamChunks([ + { + id: "chatcmpl-refusal-message", + object: "chat.completion.chunk", + created: 1, + model: model.id, + choices: [ + { + index: 0, + // Some OpenAI-compatible endpoints deliver a full message instead of delta. + message: { + role: "assistant", + content: null, + refusal: "Requests like this are not allowed.", + }, + logprobs: null, + finish_reason: "stop", + } as unknown as ChatCompletionChunk["choices"][number], + ], + }, + ]), + output, + model, + { push() {} }, + ); + + expect(output.content).toStrictEqual([ + { type: "text", text: "Requests like this are not allowed." }, + ]); + expect(output.stopReason).toBe("stop"); + }); + it("filters DeepSeek DSML content without disturbing native tool calls", async () => { const model = createDeepSeekCompletionsModel(); const output = createAssistantOutput(model); diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 44e04e3d936..35969dcb149 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -3166,6 +3166,18 @@ async function processOpenAICompletionsStream( } } } + // Chat Completions can put safety/structured-output refusals in a top-level + // `refusal` field with content null. Surface that as visible text so the + // assistant turn is not empty (Responses path already routes refusal deltas). + const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : ""; + if (refusalText) { + const routedDeltas = hasMirroredReasoning + ? reasoningTagTextPartitioner.push(refusalText) + : reasoningTagTextPartitioner.pushVisible(refusalText); + for (const routedDelta of routedDeltas) { + appendPartitionedVisibleDelta(routedDelta); + } + } for (const reasoningDelta of reasoningDeltas) { if (reasoningDelta.kind === "thinking" && !emitReasoning) { continue;