Skip to content

Commit 94cda6c

Browse files
author
黑承亮0668000844
committed
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 defensive ??"" guards in setup-wizard text inputs (belt-and-suspenders) Fixes #66833
1 parent 734bb9c commit 94cda6c

4 files changed

Lines changed: 142 additions & 3 deletions

File tree

src/agents/openai-transport-stream.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1727,4 +1727,109 @@ describe("openai transport stream", () => {
17271727
false,
17281728
);
17291729
});
1730+
1731+
it("handles reasoning_details from OpenRouter/Qwen3 in completions stream", async () => {
1732+
const model = {
1733+
id: "openrouter/qwen/qwen3-235b-a22b",
1734+
name: "Qwen3 235B A22B",
1735+
api: "openai-completions",
1736+
provider: "openrouter",
1737+
baseUrl: "https://openrouter.ai/api/v1",
1738+
reasoning: true,
1739+
input: ["text"],
1740+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1741+
contextWindow: 200000,
1742+
maxTokens: 8192,
1743+
} satisfies Model<"openai-completions">;
1744+
1745+
const output = {
1746+
role: "assistant" as const,
1747+
content: [],
1748+
api: model.api,
1749+
provider: model.provider,
1750+
model: model.id,
1751+
usage: {
1752+
input: 0,
1753+
output: 0,
1754+
cacheRead: 0,
1755+
cacheWrite: 0,
1756+
totalTokens: 0,
1757+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1758+
},
1759+
stopReason: "stop",
1760+
timestamp: Date.now(),
1761+
};
1762+
1763+
const stream = {
1764+
push: (() => {}) as unknown as { push(event: unknown): void },
1765+
};
1766+
1767+
const mockChunks = [
1768+
// First chunk with reasoning_details
1769+
{
1770+
id: "chatcmpl-reasoning",
1771+
object: "chat.completion.chunk" as const,
1772+
choices: [
1773+
{
1774+
index: 0,
1775+
delta: {
1776+
reasoning_details: [
1777+
{ type: "reasoning.text", text: "I need to think about this." },
1778+
{ type: "reasoning.text", text: " Let me analyze." },
1779+
],
1780+
} as Record<string, unknown>,
1781+
logprobs: null,
1782+
finish_reason: null,
1783+
},
1784+
],
1785+
},
1786+
// Second chunk with actual content
1787+
{
1788+
id: "chatcmpl-reasoning",
1789+
object: "chat.completion.chunk" as const,
1790+
choices: [
1791+
{
1792+
index: 0,
1793+
delta: {
1794+
content: " Hello! How can I help you?",
1795+
},
1796+
logprobs: null,
1797+
finish_reason: null,
1798+
},
1799+
],
1800+
},
1801+
// Final chunk
1802+
{
1803+
id: "chatcmpl-reasoning",
1804+
object: "chat.completion.chunk" as const,
1805+
choices: [
1806+
{
1807+
index: 0,
1808+
delta: {},
1809+
logprobs: null,
1810+
finish_reason: "stop",
1811+
},
1812+
],
1813+
},
1814+
] as const;
1815+
1816+
async function* mockStream() {
1817+
for (const chunk of mockChunks) {
1818+
yield chunk as never;
1819+
}
1820+
}
1821+
1822+
await __testing.processOpenAICompletionsStream(mockStream(), output, model, stream);
1823+
1824+
// Should have 2 content blocks: thinking and text
1825+
expect(output.content.length).toBe(2);
1826+
expect(output.content[0].type).toBe("thinking");
1827+
expect((output.content[0] as { type: string; thinking: string }).thinking).toBe(
1828+
"I need to think about this. Let me analyze.",
1829+
);
1830+
expect(output.content[1].type).toBe("text");
1831+
expect((output.content[1] as { type: string; text: string }).text).toBe(
1832+
" Hello! How can I help you?",
1833+
);
1834+
});
17301835
});

src/agents/openai-transport-stream.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,40 @@ async function processOpenAICompletionsStream(
11061106
});
11071107
continue;
11081108
}
1109+
// Handle reasoning_details from OpenRouter/Qwen3 and other providers
1110+
const reasoningDetails = (choice.delta as Record<string, unknown>).reasoning_details;
1111+
if (reasoningDetails && Array.isArray(reasoningDetails)) {
1112+
const reasoningText = reasoningDetails
1113+
.filter((item: unknown) => {
1114+
const typed = item as { type?: string; text?: string };
1115+
return (
1116+
typed.type === "reasoning.text" &&
1117+
typeof typed.text === "string" &&
1118+
typed.text.length > 0
1119+
);
1120+
})
1121+
.map((item: unknown) => {
1122+
const typed = item as { text: string };
1123+
return typed.text;
1124+
})
1125+
.join("");
1126+
if (reasoningText) {
1127+
if (!currentBlock || currentBlock.type !== "thinking") {
1128+
finishCurrentBlock();
1129+
currentBlock = { type: "thinking", thinking: "", thinkingSignature: "reasoning_details" };
1130+
output.content.push(currentBlock);
1131+
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
1132+
}
1133+
currentBlock.thinking += reasoningText;
1134+
stream.push({
1135+
type: "thinking_delta",
1136+
contentIndex: blockIndex(),
1137+
delta: reasoningText,
1138+
partial: output,
1139+
});
1140+
continue;
1141+
}
1142+
}
11091143
const reasoningFields = ["reasoning_content", "reasoning", "reasoning_text"] as const;
11101144
const reasoningField = reasoningFields.find((field) => {
11111145
const value = (choice.delta as Record<string, unknown>)[field];

src/channels/plugins/setup-wizard-helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -987,10 +987,10 @@ export async function promptSingleChannelToken(params: {
987987
}): Promise<{ useEnv: boolean; token: string | null }> {
988988
const promptToken = async (): Promise<string> =>
989989
(
990-
await params.prompter.text({
990+
(await params.prompter.text({
991991
message: params.inputPrompt,
992992
validate: (value) => (value?.trim() ? undefined : "Required"),
993-
})
993+
})) ?? ""
994994
).trim();
995995

996996
if (params.canUseEnv) {

src/channels/plugins/setup-wizard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,7 @@ export function buildChannelSetupWizardAdapterFromSetupWizard(params: {
455455
});
456456
},
457457
});
458-
const trimmedValue = rawValue.trim();
458+
const trimmedValue = (rawValue ?? "").trim();
459459
if (!trimmedValue && textInput.required === false) {
460460
if (textInput.applyEmptyValue) {
461461
next = await applyWizardTextInputValue({

0 commit comments

Comments
 (0)