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
This commit is contained in:
wuqxuan 2026-07-09 21:10:03 +08:00 committed by GitHub
parent cb7468fc58
commit 3fd52514e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 186 additions and 10 deletions

View file

@ -9,8 +9,13 @@ type DeepPartial<T> = { [P in keyof T]?: DeepPartial<T[P]> };
type OpenAICompatibleDelta = DeepPartial<ChatCompletionChunk["choices"][number]["delta"]> & {
reasoning_content?: string;
};
type OpenAICompatibleChoice = Omit<DeepPartial<ChatCompletionChunk["choices"][number]>, "delta"> & {
type OpenAICompatibleChoice = Omit<
DeepPartial<ChatCompletionChunk["choices"][number]>,
"delta" | "message"
> & {
delta?: OpenAICompatibleDelta;
// Some OpenAI-compatible endpoints deliver a full message instead of delta.
message?: OpenAICompatibleDelta;
};
type OpenAICompatibleChatCompletionChunk = Omit<
DeepPartial<ChatCompletionChunk>,
@ -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"),

View file

@ -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<string, unknown>;
const deltaFields = choiceDelta as Record<string, unknown>;
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) {

View file

@ -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);

View file

@ -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;