Skip to content

Commit 3fd5251

Browse files
authored
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
1 parent cb7468f commit 3fd5251

4 files changed

Lines changed: 186 additions & 10 deletions

File tree

packages/ai/src/providers/openai-completions.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@ type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
99
type OpenAICompatibleDelta = DeepPartial<ChatCompletionChunk["choices"][number]["delta"]> & {
1010
reasoning_content?: string;
1111
};
12-
type OpenAICompatibleChoice = Omit<DeepPartial<ChatCompletionChunk["choices"][number]>, "delta"> & {
12+
type OpenAICompatibleChoice = Omit<
13+
DeepPartial<ChatCompletionChunk["choices"][number]>,
14+
"delta" | "message"
15+
> & {
1316
delta?: OpenAICompatibleDelta;
17+
// Some OpenAI-compatible endpoints deliver a full message instead of delta.
18+
message?: OpenAICompatibleDelta;
1419
};
1520
type OpenAICompatibleChatCompletionChunk = Omit<
1621
DeepPartial<ChatCompletionChunk>,
@@ -124,6 +129,32 @@ function makeTextChunk(text: string): OpenAICompatibleChatCompletionChunk {
124129
};
125130
}
126131

132+
function makeRefusalChunk(refusal: string): OpenAICompatibleChatCompletionChunk {
133+
return {
134+
id: "chatcmpl-test",
135+
choices: [
136+
{
137+
index: 0,
138+
delta: { role: "assistant", content: null, refusal },
139+
finish_reason: "stop",
140+
},
141+
],
142+
};
143+
}
144+
145+
function makeRefusalMessageChunk(refusal: string): OpenAICompatibleChatCompletionChunk {
146+
return {
147+
id: "chatcmpl-test",
148+
choices: [
149+
{
150+
index: 0,
151+
message: { role: "assistant", content: null, refusal },
152+
finish_reason: "stop",
153+
},
154+
],
155+
};
156+
}
157+
127158
function makeToolCallChunk(
128159
id: string,
129160
name: string,
@@ -221,6 +252,30 @@ describe("OpenAI-compatible completions params", () => {
221252
}
222253
});
223254

255+
it("surfaces chat-completions refusal deltas as visible assistant text", async () => {
256+
mockChunksRef.chunks = [makeRefusalChunk("I can't help with that.")];
257+
258+
const result = await streamOpenAICompletions(model, context, {
259+
apiKey: "sk-test",
260+
}).result();
261+
262+
expect(result.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]);
263+
expect(result.stopReason).toBe("stop");
264+
});
265+
266+
it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => {
267+
mockChunksRef.chunks = [makeRefusalMessageChunk("Requests like this are not allowed.")];
268+
269+
const result = await streamOpenAICompletions(model, context, {
270+
apiKey: "sk-test",
271+
}).result();
272+
273+
expect(result.content).toStrictEqual([
274+
{ type: "text", text: "Requests like this are not allowed." },
275+
]);
276+
expect(result.stopReason).toBe("stop");
277+
});
278+
224279
it("preserves a valid provider-reported usage cost", async () => {
225280
mockChunksRef.chunks = [
226281
makeTextChunk("ok"),

packages/ai/src/providers/openai-completions.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -424,13 +424,19 @@ export const streamOpenAICompletions: StreamFunction<
424424
hasFinishReason = true;
425425
}
426426

427-
if (choice.delta) {
427+
// Some OpenAI-compatible endpoints deliver a full `message` instead of
428+
// `delta` (including refusal-only turns with content: null). Normalize
429+
// the same way the managed agent transport does.
430+
const choiceDelta =
431+
choice.delta ??
432+
(choice as { message?: ChatCompletionChunk["choices"][number]["delta"] }).message;
433+
if (choiceDelta) {
428434
// Some endpoints return reasoning in reasoning_content (llama.cpp),
429435
// or reasoning (other openai compatible endpoints)
430436
// Use the first non-empty reasoning field to avoid duplication
431437
// (e.g., chutes.ai returns both reasoning_content and reasoning with same content)
432438
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"];
433-
const deltaFields = choice.delta as Record<string, unknown>;
439+
const deltaFields = choiceDelta as Record<string, unknown>;
434440
const shouldEmitReasoning = Boolean(model.reasoning && options?.reasoningEffort);
435441
let foundReasoningField: string | null = null;
436442
for (const field of reasoningFields) {
@@ -454,19 +460,27 @@ export const streamOpenAICompletions: StreamFunction<
454460
}
455461
}
456462
if (
457-
choice.delta.content !== null &&
458-
choice.delta.content !== undefined &&
459-
choice.delta.content.length > 0
463+
choiceDelta.content !== null &&
464+
choiceDelta.content !== undefined &&
465+
choiceDelta.content.length > 0
460466
) {
461-
appendPartitionedContent(choice.delta.content, Boolean(foundReasoningField));
467+
appendPartitionedContent(choiceDelta.content, Boolean(foundReasoningField));
462468
}
463469

464-
if (choice?.delta?.tool_calls) {
470+
// Chat Completions can put safety/structured-output refusals in a
471+
// top-level `refusal` field with content null. Surface that as
472+
// visible text so the assistant turn is not empty.
473+
const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : "";
474+
if (refusalText.length > 0) {
475+
appendPartitionedContent(refusalText, Boolean(foundReasoningField));
476+
}
477+
478+
if (choiceDelta.tool_calls) {
465479
flushPartitionedContent();
466480
// The tool-call lane is also a reasoning boundary; seal the thought
467481
// before toolcall_start so thinking_end never trails the action.
468482
sealNativeReasoningBeforeText();
469-
for (const toolCall of choice.delta.tool_calls) {
483+
for (const toolCall of choiceDelta.tool_calls) {
470484
const block = ensureToolCallBlock(toolCall);
471485
if (!block.id && toolCall.id) {
472486
block.id = toolCall.id;
@@ -492,7 +506,7 @@ export const streamOpenAICompletions: StreamFunction<
492506
}
493507
}
494508

495-
const reasoningDetails = (choice.delta as { reasoning_details?: unknown })
509+
const reasoningDetails = (choiceDelta as { reasoning_details?: unknown })
496510
.reasoning_details;
497511
if (reasoningDetails && Array.isArray(reasoningDetails)) {
498512
for (const detail of reasoningDetails) {

src/agents/openai-transport-stream.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2785,6 +2785,101 @@ describe("openai transport stream", () => {
27852785
expect(output.stopReason).toBe("stop");
27862786
});
27872787

2788+
it("surfaces chat-completions refusal deltas as visible assistant text", async () => {
2789+
const model = {
2790+
id: "gpt-5.5",
2791+
name: "GPT-5.5",
2792+
api: "openai-completions" as const,
2793+
provider: "openai",
2794+
baseUrl: "https://api.openai.com/v1",
2795+
reasoning: false,
2796+
input: ["text"] as const,
2797+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2798+
contextWindow: 128_000,
2799+
maxTokens: 4096,
2800+
} satisfies Model<"openai-completions">;
2801+
const output = createAssistantOutput(model);
2802+
const events: CapturedStreamEvent[] = [];
2803+
2804+
await testing.processOpenAICompletionsStream(
2805+
streamChunks([
2806+
{
2807+
id: "chatcmpl-refusal-delta",
2808+
object: "chat.completion.chunk",
2809+
created: 1,
2810+
model: model.id,
2811+
choices: [
2812+
{
2813+
index: 0,
2814+
delta: { role: "assistant", content: null, refusal: "I can't help with that." },
2815+
logprobs: null,
2816+
finish_reason: "stop",
2817+
},
2818+
],
2819+
},
2820+
]),
2821+
output,
2822+
model,
2823+
{ push: (event) => events.push(event as CapturedStreamEvent) },
2824+
);
2825+
2826+
expect(output.content).toStrictEqual([{ type: "text", text: "I can't help with that." }]);
2827+
expect(output.stopReason).toBe("stop");
2828+
expect(
2829+
events.some(
2830+
(event) => event.type === "text_delta" && event.delta === "I can't help with that.",
2831+
),
2832+
).toBe(true);
2833+
});
2834+
2835+
it("surfaces aggregated chat-completions message.refusal as visible assistant text", async () => {
2836+
const model = {
2837+
id: "gpt-5.5",
2838+
name: "GPT-5.5",
2839+
api: "openai-completions" as const,
2840+
provider: "openai",
2841+
baseUrl: "https://api.openai.com/v1",
2842+
reasoning: false,
2843+
input: ["text"] as const,
2844+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
2845+
contextWindow: 128_000,
2846+
maxTokens: 4096,
2847+
} satisfies Model<"openai-completions">;
2848+
const output = createAssistantOutput(model);
2849+
2850+
await testing.processOpenAICompletionsStream(
2851+
streamChunks([
2852+
{
2853+
id: "chatcmpl-refusal-message",
2854+
object: "chat.completion.chunk",
2855+
created: 1,
2856+
model: model.id,
2857+
choices: [
2858+
{
2859+
index: 0,
2860+
// Some OpenAI-compatible endpoints deliver a full message instead of delta.
2861+
message: {
2862+
role: "assistant",
2863+
content: null,
2864+
refusal: "Requests like this are not allowed.",
2865+
},
2866+
logprobs: null,
2867+
finish_reason: "stop",
2868+
} as unknown as ChatCompletionChunk["choices"][number],
2869+
],
2870+
},
2871+
]),
2872+
output,
2873+
model,
2874+
{ push() {} },
2875+
);
2876+
2877+
expect(output.content).toStrictEqual([
2878+
{ type: "text", text: "Requests like this are not allowed." },
2879+
]);
2880+
expect(output.stopReason).toBe("stop");
2881+
});
2882+
27882883
it("filters DeepSeek DSML content without disturbing native tool calls", async () => {
27892884
const model = createDeepSeekCompletionsModel();
27902885
const output = createAssistantOutput(model);

src/agents/openai-transport-stream.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3166,6 +3166,18 @@ async function processOpenAICompletionsStream(
31663166
}
31673167
}
31683168
}
3169+
// Chat Completions can put safety/structured-output refusals in a top-level
3170+
// `refusal` field with content null. Surface that as visible text so the
3171+
// assistant turn is not empty (Responses path already routes refusal deltas).
3172+
const refusalText = typeof choiceDelta.refusal === "string" ? choiceDelta.refusal : "";
3173+
if (refusalText) {
3174+
const routedDeltas = hasMirroredReasoning
3175+
? reasoningTagTextPartitioner.push(refusalText)
3176+
: reasoningTagTextPartitioner.pushVisible(refusalText);
3177+
for (const routedDelta of routedDeltas) {
3178+
appendPartitionedVisibleDelta(routedDelta);
3179+
}
3180+
}
31693181
for (const reasoningDelta of reasoningDeltas) {
31703182
if (reasoningDelta.kind === "thinking" && !emitReasoning) {
31713183
continue;

0 commit comments

Comments
 (0)