@codehz/ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxfmtrc.json +12 -0
- package/.oxlintrc.json +49 -0
- package/README.md +225 -0
- package/bun.lock +231 -0
- package/dist/index.d.mts +978 -0
- package/dist/index.mjs +2758 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +30 -0
- package/src/adapters/chat-completions.ts +707 -0
- package/src/adapters/index.ts +20 -0
- package/src/adapters/messages.ts +700 -0
- package/src/adapters/ollama.ts +604 -0
- package/src/adapters/responses.ts +516 -0
- package/src/core/aggregator.ts +349 -0
- package/src/core/client.ts +23 -0
- package/src/core/collect-stream.ts +19 -0
- package/src/core/errors.ts +99 -0
- package/src/core/event-factory.ts +141 -0
- package/src/core/index.ts +18 -0
- package/src/core/normalize.ts +51 -0
- package/src/core/validation.ts +341 -0
- package/src/helpers/adapter-auxiliary.ts +181 -0
- package/src/helpers/adapter-base.ts +184 -0
- package/src/helpers/auxiliary-collector.ts +166 -0
- package/src/helpers/index.ts +40 -0
- package/src/helpers/mapping.ts +200 -0
- package/src/helpers/sse-parser.ts +87 -0
- package/src/helpers/synthetic-stream.ts +196 -0
- package/src/index.ts +17 -0
- package/src/types/adapter.ts +109 -0
- package/src/types/content.ts +12 -0
- package/src/types/events.ts +134 -0
- package/src/types/index.ts +59 -0
- package/src/types/items.ts +58 -0
- package/src/types/request.ts +39 -0
- package/src/types/response.ts +65 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat Completions Adapter
|
|
3
|
+
*
|
|
4
|
+
* 接入 OpenAI Chat Completions API (chat/completions 端点)。
|
|
5
|
+
* 弱能力兼容层:
|
|
6
|
+
* - third-party reasoning 字段仅做 best-effort 提取
|
|
7
|
+
* - 工具调用通常整块到达(非逐 token 流)
|
|
8
|
+
* - replay fidelity 依赖 provider 是否暴露可回放的 assistant turn 字段
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { AdapterBase } from "../helpers/adapter-base.js";
|
|
12
|
+
import { AIRequestError } from "../core/errors.js";
|
|
13
|
+
import {
|
|
14
|
+
textBlock,
|
|
15
|
+
messageItem,
|
|
16
|
+
reasoningItem,
|
|
17
|
+
toolCallItem,
|
|
18
|
+
opaqueItem,
|
|
19
|
+
replayFromOutput,
|
|
20
|
+
mapStopReason,
|
|
21
|
+
contentBlocksToText,
|
|
22
|
+
} from "../helpers/mapping.js";
|
|
23
|
+
import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
|
|
24
|
+
|
|
25
|
+
import type { AdapterCapabilities, NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
|
|
26
|
+
|
|
27
|
+
// ── 类型 ──────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export type ChatCompletionsAdapterOptions = {
|
|
30
|
+
apiKey: string;
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
fetch?: FetchFn;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// ── Chat API 请求类型 ─────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
type ChatRequest = {
|
|
38
|
+
model: string;
|
|
39
|
+
messages: ChatMessage[];
|
|
40
|
+
tools?: ChatTool[];
|
|
41
|
+
tool_choice?: "auto" | "none" | { type: "function"; function: { name: string } };
|
|
42
|
+
metadata?: Record<string, string>;
|
|
43
|
+
temperature?: number;
|
|
44
|
+
max_tokens?: number;
|
|
45
|
+
stream: true;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ChatMessage = {
|
|
49
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
50
|
+
content: string | null;
|
|
51
|
+
tool_calls?: ChatToolCall[];
|
|
52
|
+
tool_call_id?: string;
|
|
53
|
+
name?: string;
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type ChatToolCall = {
|
|
58
|
+
id: string;
|
|
59
|
+
type: "function";
|
|
60
|
+
function: { name: string; arguments: string };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ChatTool = {
|
|
64
|
+
type: "function";
|
|
65
|
+
function: { name: string; description?: string; parameters: Record<string, unknown> };
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ── SSE chunk 类型 ────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
type ChatChunk = {
|
|
71
|
+
id: string;
|
|
72
|
+
object: string;
|
|
73
|
+
created: number;
|
|
74
|
+
model: string;
|
|
75
|
+
choices: ChatChunkChoice[];
|
|
76
|
+
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ChatChunkChoice = {
|
|
80
|
+
index: number;
|
|
81
|
+
delta: {
|
|
82
|
+
role?: string;
|
|
83
|
+
content?: string | null;
|
|
84
|
+
reasoning?: unknown;
|
|
85
|
+
reasoning_content?: unknown;
|
|
86
|
+
tool_calls?: ChatChunkToolCall[];
|
|
87
|
+
function_call?: { name?: string; arguments?: string };
|
|
88
|
+
[key: string]: unknown;
|
|
89
|
+
};
|
|
90
|
+
finish_reason?: string | null;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
type ChatChunkToolCall = {
|
|
94
|
+
index: number;
|
|
95
|
+
id?: string;
|
|
96
|
+
type?: string;
|
|
97
|
+
function?: { name?: string; arguments?: string };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type PendingToolCall = {
|
|
101
|
+
id: string;
|
|
102
|
+
name: string;
|
|
103
|
+
args: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ReasoningFieldName = "reasoning" | "reasoning_content";
|
|
107
|
+
|
|
108
|
+
const REASONING_FIELDS: readonly ReasoningFieldName[] = ["reasoning_content", "reasoning"];
|
|
109
|
+
|
|
110
|
+
function assertChatToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
|
|
111
|
+
if (outcome !== "success") {
|
|
112
|
+
throw new AIRequestError(
|
|
113
|
+
`chat-completions does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
|
|
114
|
+
"UNSUPPORTED_TOOL_RESULT_OUTCOME",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── SSE 解析 ──────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Chat Completions 的简化 SSE 解析器。
|
|
123
|
+
*
|
|
124
|
+
* 约束:
|
|
125
|
+
* - 每条 `data:` 行必须已经是一个完整 JSON 对象
|
|
126
|
+
* - 允许传输层把单行拆成多个 chunk,但不接受 provider 把一个 JSON event 改写成多条 `data:` 行
|
|
127
|
+
*/
|
|
128
|
+
function parseChatSSE(buffer: string): { chunks: ChatChunk[]; rest: string; malformedEvents: number } {
|
|
129
|
+
const chunks: ChatChunk[] = [];
|
|
130
|
+
let rest = buffer;
|
|
131
|
+
let malformedEvents = 0;
|
|
132
|
+
|
|
133
|
+
while (true) {
|
|
134
|
+
const lineEnd = rest.indexOf("\n");
|
|
135
|
+
if (lineEnd === -1) {
|
|
136
|
+
// 没有更多完整行,剩余部分保留到下次
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const line = rest.slice(0, lineEnd).trim();
|
|
141
|
+
rest = rest.slice(lineEnd + 1);
|
|
142
|
+
|
|
143
|
+
if (!line.startsWith("data: ")) continue;
|
|
144
|
+
|
|
145
|
+
const data = line.slice(6).trim();
|
|
146
|
+
if (data === "[DONE]") continue;
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
chunks.push(JSON.parse(data));
|
|
150
|
+
} catch {
|
|
151
|
+
malformedEvents++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { chunks, rest, malformedEvents };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ensureTextCompatibleBlocks(
|
|
159
|
+
blocks: import("../index.js").ContentBlock[],
|
|
160
|
+
field: string,
|
|
161
|
+
): import("../index.js").ContentBlock[] {
|
|
162
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
163
|
+
const block = blocks[i];
|
|
164
|
+
if (block.type !== "text" && block.type !== "json") {
|
|
165
|
+
throw new AIRequestError(
|
|
166
|
+
`chat-completions does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
|
|
167
|
+
"UNSUPPORTED_CONTENT_BLOCK",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return blocks;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function contentBlocksToChatText(blocks: import("../index.js").ContentBlock[], field: string): string {
|
|
176
|
+
return contentBlocksToText(ensureTextCompatibleBlocks(blocks, field));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function extractReasoningText(value: unknown): string {
|
|
180
|
+
if (typeof value === "string") return value;
|
|
181
|
+
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return value.map(extractReasoningText).join("");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (value && typeof value === "object") {
|
|
187
|
+
const record = value as Record<string, unknown>;
|
|
188
|
+
for (const key of ["text", "content", "reasoning", "reasoning_content", "thinking", "value"]) {
|
|
189
|
+
const nested = extractReasoningText(record[key]);
|
|
190
|
+
if (nested) return nested;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractReasoningDeltas(delta: ChatChunkChoice["delta"]): Array<{ field: ReasoningFieldName; text: string }> {
|
|
198
|
+
const deltas: Array<{ field: ReasoningFieldName; text: string }> = [];
|
|
199
|
+
|
|
200
|
+
for (const field of REASONING_FIELDS) {
|
|
201
|
+
const text = extractReasoningText(delta[field]);
|
|
202
|
+
if (text) {
|
|
203
|
+
deltas.push({ field, text });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return deltas;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rollbackTrailingAssistantMessages(messages: ChatMessage[]): void {
|
|
211
|
+
while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
|
|
212
|
+
messages.pop();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function buildAssistantReplayMessage(params: {
|
|
217
|
+
content: string;
|
|
218
|
+
reasoningByField: ReadonlyMap<ReasoningFieldName, string>;
|
|
219
|
+
toolCalls: readonly PendingToolCall[];
|
|
220
|
+
}): ChatMessage | null {
|
|
221
|
+
const { content, reasoningByField, toolCalls } = params;
|
|
222
|
+
if (!content && reasoningByField.size === 0 && toolCalls.length === 0) return null;
|
|
223
|
+
|
|
224
|
+
const replayMessage: ChatMessage = {
|
|
225
|
+
role: "assistant",
|
|
226
|
+
content: content || null,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
for (const [field, text] of reasoningByField) {
|
|
230
|
+
replayMessage[field] = text;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (toolCalls.length > 0) {
|
|
234
|
+
replayMessage.tool_calls = toolCalls.map((toolCall) => ({
|
|
235
|
+
id: toolCall.id,
|
|
236
|
+
type: "function",
|
|
237
|
+
function: {
|
|
238
|
+
name: toolCall.name,
|
|
239
|
+
arguments: toolCall.args,
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return replayMessage;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Adapter ───────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
export class ChatCompletionsAdapter extends AdapterBase {
|
|
250
|
+
readonly kind = "chat-completions" as const;
|
|
251
|
+
readonly capabilities: AdapterCapabilities = {
|
|
252
|
+
nativeStreaming: true,
|
|
253
|
+
messageStreaming: true,
|
|
254
|
+
reasoningStreaming: false,
|
|
255
|
+
toolCallStreaming: false,
|
|
256
|
+
hiddenReasoningReplay: "none" as const,
|
|
257
|
+
replayFidelity: "low" as const,
|
|
258
|
+
tools: true,
|
|
259
|
+
usage: "full" as const,
|
|
260
|
+
billing: "derived" as const,
|
|
261
|
+
providerMetadata: false,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
private apiKey: string;
|
|
265
|
+
private baseUrl: string;
|
|
266
|
+
private fetchFn: FetchFn;
|
|
267
|
+
|
|
268
|
+
private markReasoningCompatibility(): void {
|
|
269
|
+
this.capabilities.reasoningStreaming = true;
|
|
270
|
+
this.capabilities.hiddenReasoningReplay = "partial";
|
|
271
|
+
this.capabilities.replayFidelity = "medium";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
constructor(options: ChatCompletionsAdapterOptions) {
|
|
275
|
+
super();
|
|
276
|
+
this.apiKey = options.apiKey;
|
|
277
|
+
this.baseUrl = options.baseUrl ?? "https://api.openai.com/v1";
|
|
278
|
+
this.fetchFn = options.fetch ?? globalThis.fetch;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── buildRequest ──────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
protected buildRequest(request: NormalizedRequest): ChatRequest {
|
|
284
|
+
const messages: ChatMessage[] = [];
|
|
285
|
+
|
|
286
|
+
// handle instructions → system message
|
|
287
|
+
if (request.instructions) {
|
|
288
|
+
const content =
|
|
289
|
+
typeof request.instructions === "string"
|
|
290
|
+
? request.instructions
|
|
291
|
+
: contentBlocksToChatText(request.instructions, "instructions");
|
|
292
|
+
messages.push({ role: "system", content });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const item of request.input) {
|
|
296
|
+
switch (item.type) {
|
|
297
|
+
case "message": {
|
|
298
|
+
const role =
|
|
299
|
+
item.role === "developer"
|
|
300
|
+
? "system"
|
|
301
|
+
: item.role === "system"
|
|
302
|
+
? "system"
|
|
303
|
+
: item.role === "user"
|
|
304
|
+
? "user"
|
|
305
|
+
: "assistant";
|
|
306
|
+
const text = contentBlocksToChatText(item.content, `input message (${item.role}) content`);
|
|
307
|
+
messages.push({ role, content: text || null });
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
case "tool_call": {
|
|
311
|
+
// 只允许附着到尾部 assistant turn,否则新建一个
|
|
312
|
+
const lastAssistant = messages.length > 0 && messages[messages.length - 1]?.role === "assistant"
|
|
313
|
+
? messages[messages.length - 1]
|
|
314
|
+
: null;
|
|
315
|
+
const tc: ChatToolCall = {
|
|
316
|
+
id: item.id,
|
|
317
|
+
type: "function",
|
|
318
|
+
function: { name: item.name, arguments: item.argumentsText },
|
|
319
|
+
};
|
|
320
|
+
if (lastAssistant) {
|
|
321
|
+
lastAssistant.tool_calls = [...(lastAssistant.tool_calls ?? []), tc];
|
|
322
|
+
} else {
|
|
323
|
+
messages.push({ role: "assistant", content: null, tool_calls: [tc] });
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "tool_result": {
|
|
328
|
+
assertChatToolResultOutcome(item.outcome);
|
|
329
|
+
messages.push({
|
|
330
|
+
role: "tool",
|
|
331
|
+
tool_call_id: item.callId,
|
|
332
|
+
name: item.toolName,
|
|
333
|
+
content: contentBlocksToChatText(item.content, `tool_result ${item.callId} content`),
|
|
334
|
+
});
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
case "reasoning": {
|
|
338
|
+
// chat.completions doesn't support reasoning items in input
|
|
339
|
+
// Convert to a text message for best-effort
|
|
340
|
+
messages.push({
|
|
341
|
+
role: "assistant",
|
|
342
|
+
content: contentBlocksToChatText(item.content, "reasoning content"),
|
|
343
|
+
});
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case "opaque": {
|
|
347
|
+
// Try to restore from opaque replay
|
|
348
|
+
if (item.purpose === "replay" && typeof item.payload === "object" && item.payload !== null) {
|
|
349
|
+
const payload = item.payload as Record<string, unknown>;
|
|
350
|
+
if (payload.role === "assistant" && typeof payload.content === "string") {
|
|
351
|
+
messages.push({ role: "assistant", content: payload.content as string });
|
|
352
|
+
} else if (payload.replaceCanonical === true && Array.isArray(payload.messages)) {
|
|
353
|
+
rollbackTrailingAssistantMessages(messages);
|
|
354
|
+
for (const m of payload.messages as ChatMessage[]) {
|
|
355
|
+
messages.push(m);
|
|
356
|
+
}
|
|
357
|
+
} else if (Array.isArray(payload.messages)) {
|
|
358
|
+
for (const m of payload.messages as ChatMessage[]) {
|
|
359
|
+
messages.push(m);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const body: ChatRequest = {
|
|
369
|
+
model: request.model,
|
|
370
|
+
messages,
|
|
371
|
+
stream: true,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
if (request.tools && request.tools.length > 0) {
|
|
375
|
+
body.tools = request.tools.map(
|
|
376
|
+
(t): ChatTool => ({
|
|
377
|
+
type: "function",
|
|
378
|
+
function: {
|
|
379
|
+
name: t.name,
|
|
380
|
+
description: t.description,
|
|
381
|
+
parameters: t.inputSchema as Record<string, unknown>,
|
|
382
|
+
},
|
|
383
|
+
}),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (request.toolChoice) {
|
|
388
|
+
if (request.toolChoice === "auto") body.tool_choice = "auto";
|
|
389
|
+
else if (request.toolChoice === "none") body.tool_choice = "none";
|
|
390
|
+
else if (request.toolChoice.type === "tool") {
|
|
391
|
+
body.tool_choice = { type: "function", function: { name: request.toolChoice.name } };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (request.temperature !== undefined) body.temperature = request.temperature;
|
|
396
|
+
if (request.maxOutputTokens !== undefined) body.max_tokens = request.maxOutputTokens;
|
|
397
|
+
if (request.metadata) body.metadata = request.metadata;
|
|
398
|
+
|
|
399
|
+
return body;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── runStream ─────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
protected async *runStream(
|
|
405
|
+
providerRequest: ChatRequest,
|
|
406
|
+
factory: EventFactory,
|
|
407
|
+
request: NormalizedRequest,
|
|
408
|
+
): AsyncIterable<AIStreamEvent> {
|
|
409
|
+
const auxiliary = this.createAuxiliaryState(request);
|
|
410
|
+
const response = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
|
|
411
|
+
method: "POST",
|
|
412
|
+
headers: {
|
|
413
|
+
"Content-Type": "application/json",
|
|
414
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
415
|
+
},
|
|
416
|
+
body: JSON.stringify(providerRequest),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
if (!response.ok) {
|
|
420
|
+
const errorText = await response.text().catch(() => "unknown error");
|
|
421
|
+
throw new Error(`Chat Completions API error ${response.status}: ${errorText}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const reader = response.body?.getReader();
|
|
425
|
+
if (!reader) {
|
|
426
|
+
throw new Error("Response body is not readable");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const output: OutputItem[] = [];
|
|
430
|
+
const decoder = new TextDecoder();
|
|
431
|
+
let buffer = "";
|
|
432
|
+
|
|
433
|
+
// 累积状态 — 支持多 choice,此处只取 index 0
|
|
434
|
+
let responseId: string | undefined;
|
|
435
|
+
let accumulatedContent = "";
|
|
436
|
+
let accumulatedReasoning = "";
|
|
437
|
+
let currentMessageId = "";
|
|
438
|
+
let currentReasoningId = "";
|
|
439
|
+
let hasMessageStarted = false;
|
|
440
|
+
let hasReasoningStarted = false;
|
|
441
|
+
let hasStreamedReasoning = false;
|
|
442
|
+
|
|
443
|
+
// tool_calls 累积: tool call index → { id, name, args }
|
|
444
|
+
const pendingToolCalls = new Map<number, PendingToolCall>();
|
|
445
|
+
const reasoningByField = new Map<ReasoningFieldName, string>();
|
|
446
|
+
|
|
447
|
+
const finalizePendingTurn = (): { events: AIStreamEvent[]; assistantReplayMessage: ChatMessage | null } => {
|
|
448
|
+
const events: AIStreamEvent[] = [];
|
|
449
|
+
const finalizedToolCalls = [...pendingToolCalls.values()];
|
|
450
|
+
const finalizedReasoningByField = new Map(reasoningByField);
|
|
451
|
+
|
|
452
|
+
if (hasReasoningStarted && accumulatedReasoning) {
|
|
453
|
+
const reasoning = reasoningItem([textBlock(accumulatedReasoning)], "full", currentReasoningId);
|
|
454
|
+
events.push(factory.reasoningCompleted(reasoning));
|
|
455
|
+
output.push(reasoning);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (hasMessageStarted && accumulatedContent) {
|
|
459
|
+
const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
|
|
460
|
+
events.push(factory.messageCompleted(message));
|
|
461
|
+
output.push(message);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
for (const pending of finalizedToolCalls) {
|
|
465
|
+
const toolCall = toolCallItem(pending.id, pending.name, pending.args);
|
|
466
|
+
events.push(factory.toolCallCompleted(toolCall));
|
|
467
|
+
output.push(toolCall);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const assistantReplayMessage = buildAssistantReplayMessage({
|
|
471
|
+
content: accumulatedContent,
|
|
472
|
+
reasoningByField: finalizedReasoningByField,
|
|
473
|
+
toolCalls: finalizedToolCalls,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
accumulatedContent = "";
|
|
477
|
+
accumulatedReasoning = "";
|
|
478
|
+
currentMessageId = "";
|
|
479
|
+
currentReasoningId = "";
|
|
480
|
+
hasMessageStarted = false;
|
|
481
|
+
hasReasoningStarted = false;
|
|
482
|
+
pendingToolCalls.clear();
|
|
483
|
+
reasoningByField.clear();
|
|
484
|
+
|
|
485
|
+
return { events, assistantReplayMessage };
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
while (true) {
|
|
490
|
+
const { done, value } = await reader.read();
|
|
491
|
+
if (done) break;
|
|
492
|
+
|
|
493
|
+
buffer += decoder.decode(value, { stream: true });
|
|
494
|
+
const { chunks, rest, malformedEvents } = parseChatSSE(buffer);
|
|
495
|
+
buffer = rest;
|
|
496
|
+
|
|
497
|
+
const malformedWarning = emitMalformedStreamWarning(factory, {
|
|
498
|
+
count: malformedEvents,
|
|
499
|
+
providerLabel: "Chat Completions",
|
|
500
|
+
transportLabel: "SSE event(s)",
|
|
501
|
+
});
|
|
502
|
+
if (malformedWarning) {
|
|
503
|
+
yield malformedWarning;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
for (const chunk of chunks) {
|
|
507
|
+
responseId = chunk.id;
|
|
508
|
+
|
|
509
|
+
// usage 可能在最终 chunk 中
|
|
510
|
+
if (chunk.usage) {
|
|
511
|
+
auxiliary.recordUsage(
|
|
512
|
+
{
|
|
513
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
514
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
515
|
+
totalTokens: chunk.usage.total_tokens,
|
|
516
|
+
},
|
|
517
|
+
"final",
|
|
518
|
+
chunk.usage,
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
for (const choice of chunk.choices) {
|
|
523
|
+
if (choice.index !== 0) continue;
|
|
524
|
+
|
|
525
|
+
const delta = choice.delta;
|
|
526
|
+
const finishReason = choice.finish_reason;
|
|
527
|
+
const reasoningDeltas = extractReasoningDeltas(delta);
|
|
528
|
+
|
|
529
|
+
// 处理 role: assistant (首块标识)
|
|
530
|
+
if (delta.role === "assistant" && typeof delta.content === "string" && !hasMessageStarted) {
|
|
531
|
+
currentMessageId = `msg-${chunk.id}`;
|
|
532
|
+
hasMessageStarted = true;
|
|
533
|
+
accumulatedContent = "";
|
|
534
|
+
yield factory.messageStarted(currentMessageId);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 处理 third-party reasoning delta
|
|
538
|
+
if (reasoningDeltas.length > 0) {
|
|
539
|
+
if (!hasReasoningStarted) {
|
|
540
|
+
currentReasoningId = `reason-${chunk.id}`;
|
|
541
|
+
hasReasoningStarted = true;
|
|
542
|
+
hasStreamedReasoning = true;
|
|
543
|
+
accumulatedReasoning = "";
|
|
544
|
+
yield factory.reasoningStarted(currentReasoningId, "full");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const reasoningDelta of reasoningDeltas) {
|
|
548
|
+
accumulatedReasoning += reasoningDelta.text;
|
|
549
|
+
reasoningByField.set(
|
|
550
|
+
reasoningDelta.field,
|
|
551
|
+
(reasoningByField.get(reasoningDelta.field) ?? "") + reasoningDelta.text,
|
|
552
|
+
);
|
|
553
|
+
yield factory.reasoningDelta(currentReasoningId, textBlock(reasoningDelta.text));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// 处理 content delta
|
|
558
|
+
if (delta.content) {
|
|
559
|
+
if (!hasMessageStarted) {
|
|
560
|
+
currentMessageId = `msg-${chunk.id}`;
|
|
561
|
+
hasMessageStarted = true;
|
|
562
|
+
yield factory.messageStarted(currentMessageId);
|
|
563
|
+
}
|
|
564
|
+
accumulatedContent += delta.content;
|
|
565
|
+
yield factory.messageDelta(currentMessageId, delta.content);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// 处理 tool_calls delta
|
|
569
|
+
if (delta.tool_calls) {
|
|
570
|
+
for (const tc of delta.tool_calls) {
|
|
571
|
+
const idx = tc.index;
|
|
572
|
+
|
|
573
|
+
if (tc.id) {
|
|
574
|
+
pendingToolCalls.set(idx, { id: tc.id, name: tc.function?.name ?? "", args: "" });
|
|
575
|
+
yield factory.toolCallStarted(tc.id, tc.function?.name ?? "");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (tc.function?.arguments) {
|
|
579
|
+
const pending = pendingToolCalls.get(idx);
|
|
580
|
+
if (pending) {
|
|
581
|
+
pending.args += tc.function.arguments;
|
|
582
|
+
yield factory.toolCallDelta(pending.id, { argumentsText: tc.function.arguments });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// 处理 function_call delta (legacy format)
|
|
589
|
+
if (delta.function_call) {
|
|
590
|
+
if (delta.function_call.name) {
|
|
591
|
+
const fcId = `fc-${chunk.id}-0`;
|
|
592
|
+
pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
|
|
593
|
+
yield factory.toolCallStarted(fcId, delta.function_call.name);
|
|
594
|
+
}
|
|
595
|
+
if (delta.function_call.arguments) {
|
|
596
|
+
const pending = pendingToolCalls.get(0);
|
|
597
|
+
if (pending) {
|
|
598
|
+
pending.args += delta.function_call.arguments;
|
|
599
|
+
yield factory.toolCallDelta(pending.id, { argumentsText: delta.function_call.arguments });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// 处理 finish_reason
|
|
605
|
+
if (finishReason && finishReason !== null) {
|
|
606
|
+
const { events, assistantReplayMessage } = finalizePendingTurn();
|
|
607
|
+
for (const event of events) {
|
|
608
|
+
yield event;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (hasStreamedReasoning) this.markReasoningCompatibility();
|
|
612
|
+
|
|
613
|
+
// 构建 stop reason
|
|
614
|
+
const stopReason = mapStopReason(finishReason);
|
|
615
|
+
|
|
616
|
+
// 构建 replay
|
|
617
|
+
const replay = [...replayFromOutput(output)];
|
|
618
|
+
|
|
619
|
+
// 附加 opaque replay
|
|
620
|
+
if (assistantReplayMessage) {
|
|
621
|
+
replay.push(
|
|
622
|
+
opaqueItem("chat.completions", "replay", {
|
|
623
|
+
replaceCanonical: true,
|
|
624
|
+
messages: [assistantReplayMessage],
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
630
|
+
for (const event of auxiliaryResult.events) {
|
|
631
|
+
yield event;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
yield factory.responseCompleted(
|
|
635
|
+
this.buildResponse(
|
|
636
|
+
request,
|
|
637
|
+
{
|
|
638
|
+
output,
|
|
639
|
+
replay,
|
|
640
|
+
stopReason,
|
|
641
|
+
usage: auxiliaryResult.usage,
|
|
642
|
+
billing: auxiliaryResult.billing,
|
|
643
|
+
auxiliary: auxiliaryResult.auxiliary,
|
|
644
|
+
warnings: auxiliaryResult.warnings,
|
|
645
|
+
metadataSources: auxiliaryResult.metadataSources,
|
|
646
|
+
rawResponseId: chunk.id,
|
|
647
|
+
},
|
|
648
|
+
factory,
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
} finally {
|
|
656
|
+
reader.releaseLock();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (buffer.trim().length > 0) {
|
|
660
|
+
yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// 如果流结束时没有 finish_reason(断流),也尝试关闭
|
|
664
|
+
if (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0) {
|
|
665
|
+
yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
|
|
666
|
+
|
|
667
|
+
if (hasStreamedReasoning) this.markReasoningCompatibility();
|
|
668
|
+
|
|
669
|
+
const { events, assistantReplayMessage } = finalizePendingTurn();
|
|
670
|
+
for (const event of events) {
|
|
671
|
+
yield event;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const replay = [...replayFromOutput(output)];
|
|
675
|
+
if (assistantReplayMessage) {
|
|
676
|
+
replay.push(
|
|
677
|
+
opaqueItem("chat.completions", "replay", {
|
|
678
|
+
replaceCanonical: true,
|
|
679
|
+
messages: [assistantReplayMessage],
|
|
680
|
+
}),
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
685
|
+
for (const event of auxiliaryResult.events) {
|
|
686
|
+
yield event;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
yield factory.responseCompleted(
|
|
690
|
+
this.buildResponse(
|
|
691
|
+
request,
|
|
692
|
+
{
|
|
693
|
+
output,
|
|
694
|
+
replay,
|
|
695
|
+
usage: auxiliaryResult.usage,
|
|
696
|
+
billing: auxiliaryResult.billing,
|
|
697
|
+
auxiliary: auxiliaryResult.auxiliary,
|
|
698
|
+
warnings: auxiliaryResult.warnings,
|
|
699
|
+
metadataSources: auxiliaryResult.metadataSources,
|
|
700
|
+
rawResponseId: responseId,
|
|
701
|
+
},
|
|
702
|
+
factory,
|
|
703
|
+
),
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|