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:
bladin
2026-04-15 10:45:58 +08:00
committed by GitHub
parent 0c0463b2b7
commit e0bf756b50
3 changed files with 358 additions and 19 deletions

View File

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

View File

@@ -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" },
]);
});
});

View File

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