mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
cb7468fc58
commit
3fd52514e3
4 changed files with 186 additions and 10 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue