Skip to content

Commit b5775f0

Browse files
committed
fix(text-chat): validate Chat Completions stream completion and aggregate tool_calls
1 parent a2956d5 commit b5775f0

4 files changed

Lines changed: 595 additions & 30 deletions

File tree

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { BailianError, ExitCode, type LocalizedText } from "bailian-cli-core";
2+
import { parseModelStreamEvent } from "./model-stream.ts";
3+
4+
export interface ChatStreamUpdate {
5+
content: string;
6+
reasoning: string;
7+
}
8+
9+
export interface ChatStreamToolCall {
10+
id: string;
11+
type: "function";
12+
function: { name: string; arguments: string };
13+
}
14+
15+
export interface ChatStreamResult {
16+
content: string;
17+
tool_calls?: ChatStreamToolCall[];
18+
finish_reason: string | null;
19+
}
20+
21+
interface PendingToolCall {
22+
id: string;
23+
name: string;
24+
arguments: string;
25+
}
26+
27+
function asRecord(value: unknown): Record<string, unknown> | undefined {
28+
return value !== null && typeof value === "object" && !Array.isArray(value)
29+
? (value as Record<string, unknown>)
30+
: undefined;
31+
}
32+
33+
/** Aggregate Chat Completions SSE deltas with completion and tool-call checks. */
34+
export function createChatCompletionStreamAccumulator(localize: (text: LocalizedText) => string): {
35+
readonly completed: boolean;
36+
accept(event: { event?: string; data: string }): ChatStreamUpdate;
37+
finish(): ChatStreamResult;
38+
} {
39+
let completed = false;
40+
let content = "";
41+
let finishReason: string | null = null;
42+
const toolCalls = new Map<number, PendingToolCall>();
43+
44+
const missingIndexError = () =>
45+
new BailianError(
46+
localize({
47+
"en-US": "Tool call delta is missing a valid index.",
48+
"zh-CN": "工具调用增量缺少有效的 index。",
49+
}),
50+
ExitCode.GENERAL,
51+
);
52+
53+
const conflictingIdError = () =>
54+
new BailianError(
55+
localize({
56+
"en-US": "Tool call index returned conflicting ids.",
57+
"zh-CN": "同一工具调用 index 返回了不同的 id。",
58+
}),
59+
ExitCode.GENERAL,
60+
);
61+
62+
const acceptToolCallDelta = (fragment: unknown): void => {
63+
const delta = asRecord(fragment);
64+
if (!delta) throw missingIndexError();
65+
66+
const index = delta.index;
67+
if (typeof index !== "number" || !Number.isInteger(index) || index < 0) {
68+
throw missingIndexError();
69+
}
70+
71+
const entry = toolCalls.get(index) ?? { id: "", name: "", arguments: "" };
72+
if (typeof delta.id === "string" && delta.id) {
73+
if (entry.id && entry.id !== delta.id) throw conflictingIdError();
74+
entry.id = delta.id;
75+
}
76+
77+
const functionDelta = asRecord(delta.function);
78+
if (typeof functionDelta?.name === "string") entry.name += functionDelta.name;
79+
if (typeof functionDelta?.arguments === "string") entry.arguments += functionDelta.arguments;
80+
81+
toolCalls.set(index, entry);
82+
};
83+
84+
return {
85+
get completed() {
86+
return completed;
87+
},
88+
89+
accept(event: { event?: string; data: string }): ChatStreamUpdate {
90+
let contentDelta = "";
91+
let reasoningDelta = "";
92+
93+
if (event.data === "[DONE]") {
94+
completed = true;
95+
return { content: "", reasoning: "" };
96+
}
97+
98+
const parsed = parseModelStreamEvent(event);
99+
if (!parsed) return { content: "", reasoning: "" };
100+
101+
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
102+
for (const choiceValue of choices) {
103+
const choice = asRecord(choiceValue);
104+
if (!choice) continue;
105+
106+
const delta = asRecord(choice.delta);
107+
if (delta) {
108+
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) {
109+
reasoningDelta += delta.reasoning_content;
110+
}
111+
if (typeof delta.content === "string" && delta.content) {
112+
contentDelta += delta.content;
113+
content += delta.content;
114+
}
115+
if (Array.isArray(delta.tool_calls)) {
116+
for (const fragment of delta.tool_calls) acceptToolCallDelta(fragment);
117+
}
118+
}
119+
120+
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
121+
if (finishReason === null) finishReason = choice.finish_reason;
122+
completed = true;
123+
}
124+
}
125+
126+
return { content: contentDelta, reasoning: reasoningDelta };
127+
},
128+
129+
finish(): ChatStreamResult {
130+
if (!completed) {
131+
throw new BailianError(
132+
localize({
133+
"en-US":
134+
"Stream disconnected before completion: stream closed before [DONE] or finish_reason.",
135+
"zh-CN": "流在完成前断开:未收到 [DONE] 或 finish_reason。",
136+
}),
137+
ExitCode.GENERAL,
138+
);
139+
}
140+
141+
const sorted = [...toolCalls.entries()]
142+
.sort(([left], [right]) => left - right)
143+
.map(([, call]) => call);
144+
145+
const seenIds = new Set<string>();
146+
const tool_calls: ChatStreamToolCall[] = [];
147+
for (const call of sorted) {
148+
if (!call.id || !call.name) {
149+
throw new BailianError(
150+
localize({
151+
"en-US": "Tool call is missing a required id or function name.",
152+
"zh-CN": "工具调用缺少必需的 id 或 function.name。",
153+
}),
154+
ExitCode.GENERAL,
155+
);
156+
}
157+
if (seenIds.has(call.id)) {
158+
throw new BailianError(
159+
localize({
160+
"en-US": "Duplicate tool call id across indexes.",
161+
"zh-CN": "不同工具调用 index 返回了重复的 id。",
162+
}),
163+
ExitCode.GENERAL,
164+
);
165+
}
166+
seenIds.add(call.id);
167+
tool_calls.push({
168+
id: call.id,
169+
type: "function",
170+
function: { name: call.name, arguments: call.arguments },
171+
});
172+
}
173+
174+
const result: ChatStreamResult = {
175+
content,
176+
finish_reason: finishReason,
177+
};
178+
if (tool_calls.length > 0) result.tool_calls = tool_calls;
179+
return result;
180+
},
181+
};
182+
}

‎packages/commands/src/commands/text/chat.ts‎

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseModelStreamEvent } from "../shared/model-stream.ts";
1+
import { createChatCompletionStreamAccumulator } from "../shared/chat-completion-stream.ts";
22
import {
33
defineCommand,
44
resolveBatchChatEndpoint,
@@ -13,7 +13,6 @@ import {
1313
type ResponsesRequest,
1414
type ResponsesResponse,
1515
type ResponsesStreamEvent,
16-
type StreamChunk,
1716
type FlagsDef,
1817
type ParsedFlags,
1918
} from "bailian-cli-core";
@@ -338,6 +337,7 @@ export default defineCommand({
338337
let textContent = "";
339338
let inThinking = false;
340339
let responsesCompleted = false;
340+
const accumulator = createChatCompletionStreamAccumulator(ctx.localize);
341341
const writesStreamingStdout = format === "text";
342342
const isTTY = process.stdout.isTTY;
343343
const statusOut =
@@ -346,8 +346,8 @@ export default defineCommand({
346346
const statusColor = ansi(statusOut);
347347

348348
for await (const event of parseSSE(responseStream)) {
349-
if (event.data === "[DONE]") break;
350349
if (api === "responses") {
350+
if (event.data === "[DONE]") break;
351351
let parsedEvent: ResponsesStreamEvent;
352352
try {
353353
parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent;
@@ -367,36 +367,36 @@ export default defineCommand({
367367
continue;
368368
}
369369

370-
const parsed = parseModelStreamEvent(event) as unknown as StreamChunk | undefined;
371-
if (!parsed) continue;
370+
const update = accumulator.accept(event);
372371

373-
for (const choice of parsed.choices ?? []) {
374-
const delta = choice.delta;
375-
376-
// Handle thinking/reasoning content
377-
if (delta.reasoning_content) {
378-
if (writesStreamingStdout && !inThinking) {
379-
inThinking = true;
380-
statusOut.write(statusColor.dim("Thinking:\n"));
381-
}
382-
if (writesStreamingStdout) statusOut.write(delta.reasoning_content);
372+
// Write thinking/reasoning content to the status stream.
373+
if (update.reasoning) {
374+
if (writesStreamingStdout && !inThinking) {
375+
inThinking = true;
376+
statusOut.write(statusColor.dim("Thinking:\n"));
383377
}
378+
if (writesStreamingStdout) statusOut.write(update.reasoning);
379+
}
384380

385-
// Handle regular content
386-
if (delta.content) {
387-
if (writesStreamingStdout && inThinking) {
388-
statusOut.write(`${statusColor.reset}\n\nResponse:\n`);
389-
inThinking = false;
390-
}
391-
textContent += delta.content;
392-
if (writesStreamingStdout) resultOut.write(delta.content);
381+
// Write regular content to stdout; tool_calls stay off the text stream.
382+
if (update.content) {
383+
if (writesStreamingStdout && inThinking) {
384+
statusOut.write(`${statusColor.reset}\n\nResponse:\n`);
385+
inThinking = false;
393386
}
387+
if (writesStreamingStdout) resultOut.write(update.content);
394388
}
389+
390+
if (accumulator.completed) break;
395391
}
396392
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
397393
if (inThinking) statusOut.write(statusColor.reset);
398394

399-
ctx.output.result({ content: textContent }, format === "text" ? { text: "" } : undefined);
395+
if (api === "responses") {
396+
ctx.output.result({ content: textContent }, format === "text" ? { text: "" } : undefined);
397+
} else {
398+
ctx.output.result(accumulator.finish(), format === "text" ? { text: "" } : undefined);
399+
}
400400
} else if (api === "responses") {
401401
const response = await ctx.client.requestJson<ResponsesResponse>({
402402
path: requestPath,

‎packages/commands/tests/output/fixtures/inference-output.json‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@
8383
]
8484
},
8585
"text chat:json:true": {
86-
"stdout": "{\n \"content\": \"Hello fixture\"\n}\n",
86+
"stdout": "{\n \"content\": \"Hello fixture\",\n \"finish_reason\": \"stop\"\n}\n",
8787
"data": {
88-
"content": "Hello fixture"
88+
"content": "Hello fixture",
89+
"finish_reason": "stop"
8990
},
9091
"requests": [
9192
{
@@ -126,9 +127,10 @@
126127
]
127128
},
128129
"text stream:json:false": {
129-
"stdout": "{\n \"content\": \"Hello fixture\"\n}\n",
130+
"stdout": "{\n \"content\": \"Hello fixture\",\n \"finish_reason\": \"stop\"\n}\n",
130131
"data": {
131-
"content": "Hello fixture"
132+
"content": "Hello fixture",
133+
"finish_reason": "stop"
132134
},
133135
"requests": [
134136
{
@@ -169,9 +171,10 @@
169171
]
170172
},
171173
"text stream:json:true": {
172-
"stdout": "{\n \"content\": \"Hello fixture\"\n}\n",
174+
"stdout": "{\n \"content\": \"Hello fixture\",\n \"finish_reason\": \"stop\"\n}\n",
173175
"data": {
174-
"content": "Hello fixture"
176+
"content": "Hello fixture",
177+
"finish_reason": "stop"
175178
},
176179
"requests": [
177180
{

0 commit comments

Comments
 (0)