diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c84fd340c5..edad4daaf20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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. diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index 71eadc9a7ba..7a32fed686a 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -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, + 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, + 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, + 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, + 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" }, + ]); + }); }); diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index e2c67533194..64c5946325f 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -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)[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); + 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)[reasoningField]); - stream.push({ - type: "thinking_delta", - contentIndex: blockIndex(), - delta: String((choice.delta as Record)[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): { + 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);