mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:40:42 +00:00
fix: handle OpenRouter Qwen3 reasoning_details streams (#66905) (thanks @bladin)
* fix(openrouter): handle reasoning_details field in Qwen3 stream parsing Add support for the reasoning_details field returned by OpenRouter/Qwen3 models. Previously this field was not recognized, causing payloads=0 and incomplete turn errors. - Add reasoning_details handling in processOpenAICompletionsStream - Extract text from reasoning_details array items with type reasoning.text - Treat as thinking content, similar to other reasoning fields - Add test case for reasoning_details handling Fixes #66833 * fix(openrouter): keep tool calls with reasoning_details * fix: handle OpenRouter Qwen3 reasoning_details streams (#66905) (thanks @bladin) * fix: preserve streamed tool calls with reasoning deltas (#66905) (thanks @bladin) --------- Co-authored-by: bladin <bladin@users.noreply.github.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/dreaming: stop ordinary transcripts that merely quote the dream-diary prompt from being classified as internal dreaming runs and silently dropped from session recall ingestion. (#66852) Thanks @gumadeiras.
|
||||
- Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so `.epub` and `.mobi` uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-`text/plain` coercion. (#66877) Thanks @martinfrancois.
|
||||
- Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when `commands.native` and `commands.nativeSkills` stay on `auto`. (#66843) Thanks @kashevk0.
|
||||
- OpenRouter/Qwen3: parse `reasoning_details` stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.
|
||||
- fix(bluebubbles): replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
|
||||
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
|
||||
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
|
||||
|
||||
@@ -1727,4 +1727,294 @@ describe("openai transport stream", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("handles reasoning_details from OpenRouter/Qwen3 in completions stream", async () => {
|
||||
const model = {
|
||||
id: "openrouter/qwen/qwen3-235b-a22b",
|
||||
name: "Qwen3 235B A22B",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const output = {
|
||||
role: "assistant" as const,
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const stream: { push(event: unknown): void } = { push() {} };
|
||||
|
||||
const mockChunks = [
|
||||
{
|
||||
id: "chatcmpl-reasoning",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.text", text: "I need to think about this." },
|
||||
{ type: "reasoning.text", text: " Let me analyze." },
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-reasoning",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
content: " Hello! How can I help you?",
|
||||
},
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-reasoning",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
logprobs: null,
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function* mockStream() {
|
||||
for (const chunk of mockChunks) {
|
||||
yield chunk as never;
|
||||
}
|
||||
}
|
||||
|
||||
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
|
||||
|
||||
const thinkingBlock = output.content[0] as { type: string; thinking: string };
|
||||
const textBlock = output.content[1] as { type: string; text: string };
|
||||
|
||||
expect(output.content.length).toBe(2);
|
||||
expect(thinkingBlock.type).toBe("thinking");
|
||||
expect(thinkingBlock.thinking).toBe("I need to think about this. Let me analyze.");
|
||||
expect(textBlock.type).toBe("text");
|
||||
expect(textBlock.text).toBe(" Hello! How can I help you?");
|
||||
});
|
||||
|
||||
it("keeps tool calls when reasoning_details and tool_calls share a chunk", async () => {
|
||||
const model = {
|
||||
id: "openrouter/qwen/qwen3-235b-a22b",
|
||||
name: "Qwen3 235B A22B",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const output = {
|
||||
role: "assistant" as const,
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const stream: { push(event: unknown): void } = { push() {} };
|
||||
|
||||
const mockChunks = [
|
||||
{
|
||||
id: "chatcmpl-toolcall",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_details: [{ type: "reasoning.text", text: "Need a tool." }],
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function" as const,
|
||||
function: { name: "lookup", arguments: '{"query":"qwen3"}' },
|
||||
},
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-toolcall",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
logprobs: null,
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function* mockStream() {
|
||||
for (const chunk of mockChunks) {
|
||||
yield chunk as never;
|
||||
}
|
||||
}
|
||||
|
||||
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toMatchObject([
|
||||
{ type: "thinking", thinking: "Need a tool.", thinkingSignature: "reasoning_details" },
|
||||
{ type: "toolCall", id: "call_1", name: "lookup", arguments: { query: "qwen3" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps streamed tool call arguments intact when reasoning_details repeats", async () => {
|
||||
const model = {
|
||||
id: "openrouter/qwen/qwen3-235b-a22b",
|
||||
name: "Qwen3 235B A22B",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const output = {
|
||||
role: "assistant" as const,
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const stream: { push(event: unknown): void } = { push() {} };
|
||||
|
||||
const mockChunks = [
|
||||
{
|
||||
id: "chatcmpl-toolcall-stream",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_details: [{ type: "reasoning.text", text: "Need a tool." }],
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function" as const,
|
||||
function: { name: "lookup", arguments: '{"query":' },
|
||||
},
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-toolcall-stream",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_details: [{ type: "reasoning.text", text: " Still thinking." }],
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function" as const,
|
||||
function: { arguments: '"qwen3"}' },
|
||||
},
|
||||
],
|
||||
} as Record<string, unknown>,
|
||||
logprobs: null,
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-toolcall-stream",
|
||||
object: "chat.completion.chunk" as const,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
logprobs: null,
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
async function* mockStream() {
|
||||
for (const chunk of mockChunks) {
|
||||
yield chunk as never;
|
||||
}
|
||||
}
|
||||
|
||||
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
|
||||
|
||||
expect(output.stopReason).toBe("toolUse");
|
||||
expect(output.content).toMatchObject([
|
||||
{ type: "thinking", thinking: "Need a tool." },
|
||||
{ type: "toolCall", id: "call_1", name: "lookup", arguments: { query: "qwen3" } },
|
||||
{ type: "thinking", thinking: " Still thinking.", thinkingSignature: "reasoning_details" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1053,6 +1053,7 @@ async function processOpenAICompletionsStream(
|
||||
partialArgs: string;
|
||||
}
|
||||
| null = null;
|
||||
let pendingThinkingDelta: { signature: string; text: string } | null = null;
|
||||
const blockIndex = () => output.content.length - 1;
|
||||
const finishCurrentBlock = () => {
|
||||
if (!currentBlock) {
|
||||
@@ -1067,6 +1068,33 @@ async function processOpenAICompletionsStream(
|
||||
output.content[blockIndex()] = completed;
|
||||
}
|
||||
};
|
||||
const appendThinkingDelta = (reasoningDelta: { signature: string; text: string }) => {
|
||||
if (!currentBlock || currentBlock.type !== "thinking") {
|
||||
finishCurrentBlock();
|
||||
currentBlock = {
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
thinkingSignature: reasoningDelta.signature,
|
||||
};
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
currentBlock.thinking += reasoningDelta.text;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: reasoningDelta.text,
|
||||
partial: output,
|
||||
});
|
||||
};
|
||||
const flushPendingThinkingDelta = () => {
|
||||
if (!pendingThinkingDelta) {
|
||||
return;
|
||||
}
|
||||
const bufferedDelta = pendingThinkingDelta;
|
||||
pendingThinkingDelta = null;
|
||||
appendThinkingDelta(bufferedDelta);
|
||||
};
|
||||
for await (const chunk of responseStream) {
|
||||
output.responseId ||= chunk.id;
|
||||
if (chunk.usage) {
|
||||
@@ -1091,6 +1119,7 @@ async function processOpenAICompletionsStream(
|
||||
continue;
|
||||
}
|
||||
if (choice.delta.content) {
|
||||
flushPendingThinkingDelta();
|
||||
if (!currentBlock || currentBlock.type !== "text") {
|
||||
finishCurrentBlock();
|
||||
currentBlock = { type: "text", text: "" };
|
||||
@@ -1106,26 +1135,17 @@ async function processOpenAICompletionsStream(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"] as const;
|
||||
const reasoningField = reasoningFields.find((field) => {
|
||||
const value = (choice.delta as Record<string, unknown>)[field];
|
||||
return typeof value === "string" && value.length > 0;
|
||||
});
|
||||
if (reasoningField) {
|
||||
if (!currentBlock || currentBlock.type !== "thinking") {
|
||||
finishCurrentBlock();
|
||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: reasoningField };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
||||
const reasoningDelta = getCompletionsReasoningDelta(choice.delta as Record<string, unknown>);
|
||||
if (reasoningDelta) {
|
||||
if (currentBlock?.type === "toolCall") {
|
||||
if (!pendingThinkingDelta) {
|
||||
pendingThinkingDelta = { ...reasoningDelta };
|
||||
} else {
|
||||
pendingThinkingDelta.text += reasoningDelta.text;
|
||||
}
|
||||
} else {
|
||||
appendThinkingDelta(reasoningDelta);
|
||||
}
|
||||
currentBlock.thinking += String((choice.delta as Record<string, unknown>)[reasoningField]);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: String((choice.delta as Record<string, unknown>)[reasoningField]),
|
||||
partial: output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice.delta.tool_calls && choice.delta.tool_calls.length > 0) {
|
||||
for (const toolCall of choice.delta.tool_calls) {
|
||||
@@ -1168,12 +1188,40 @@ async function processOpenAICompletionsStream(
|
||||
}
|
||||
}
|
||||
finishCurrentBlock();
|
||||
flushPendingThinkingDelta();
|
||||
const hasToolCalls = output.content.some((block) => block.type === "toolCall");
|
||||
if (output.stopReason === "toolUse" && !hasToolCalls) {
|
||||
output.stopReason = "stop";
|
||||
}
|
||||
}
|
||||
|
||||
function getCompletionsReasoningDelta(delta: Record<string, unknown>): {
|
||||
signature: string;
|
||||
text: string;
|
||||
} | null {
|
||||
const reasoningDetails = delta.reasoning_details;
|
||||
if (Array.isArray(reasoningDetails)) {
|
||||
let text = "";
|
||||
for (const item of reasoningDetails) {
|
||||
const detail = item as { type?: unknown; text?: unknown };
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) {
|
||||
text += detail.text;
|
||||
}
|
||||
}
|
||||
if (text) {
|
||||
return { signature: "reasoning_details", text };
|
||||
}
|
||||
}
|
||||
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"] as const;
|
||||
for (const field of reasoningFields) {
|
||||
const value = delta[field];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return { signature: field, text: value };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function detectCompat(model: OpenAIModeModel) {
|
||||
const provider = model.provider;
|
||||
const { capabilities, defaults: compatDefaults } = detectOpenAICompletionsCompat(model);
|
||||
|
||||
Reference in New Issue
Block a user