@codehz/ai 0.2.0 → 0.2.2
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/README.md +9 -3
- package/dist/index.d.mts +151 -27
- package/dist/index.mjs +1291 -703
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/chat-completions.ts +236 -197
- package/src/adapters/messages.ts +150 -124
- package/src/adapters/mock.ts +44 -11
- package/src/adapters/ollama.ts +219 -191
- package/src/adapters/responses.ts +222 -137
- package/src/core/aggregator.ts +233 -62
- package/src/core/errors.ts +7 -1
- package/src/core/event-factory.ts +24 -14
- package/src/core/merge-auxiliary.ts +22 -0
- package/src/core/normalize.ts +15 -1
- package/src/core/validation.ts +29 -21
- package/src/helpers/adapter-base.ts +23 -25
- package/src/helpers/adapter-security.ts +126 -0
- package/src/helpers/incremental-stream-parser.ts +84 -0
- package/src/helpers/index.ts +19 -0
- package/src/helpers/request-mapper.ts +72 -0
- package/src/helpers/sse-parser.ts +51 -25
- package/src/helpers/synthetic-stream.ts +13 -21
- package/src/helpers/usage-mapping.ts +4 -9
- package/src/types/adapter.ts +12 -1
- package/src/types/events.ts +14 -10
- package/src/types/index.ts +9 -1
- package/src/types/response.ts +0 -1
package/package.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { AdapterBase } from "../helpers/adapter-base.js";
|
|
12
|
-
import { AIRequestError } from "../core/errors.js";
|
|
12
|
+
import { AIProviderError, AIRequestError, AIStreamError } from "../core/errors.js";
|
|
13
13
|
import {
|
|
14
14
|
textBlock,
|
|
15
15
|
messageItem,
|
|
@@ -21,7 +21,10 @@ import {
|
|
|
21
21
|
contentBlocksToText,
|
|
22
22
|
} from "../helpers/mapping.js";
|
|
23
23
|
import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
|
|
24
|
+
import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
|
|
24
25
|
import { usageFromChatCompletions } from "../helpers/usage-mapping.js";
|
|
26
|
+
import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
|
|
27
|
+
import type { ProviderProfile } from "../helpers/index.js";
|
|
25
28
|
|
|
26
29
|
import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
|
|
27
30
|
|
|
@@ -44,6 +47,7 @@ type ChatRequest = {
|
|
|
44
47
|
temperature?: number;
|
|
45
48
|
max_tokens?: number;
|
|
46
49
|
stream: true;
|
|
50
|
+
n: 1;
|
|
47
51
|
};
|
|
48
52
|
|
|
49
53
|
type ChatMessage = {
|
|
@@ -114,75 +118,24 @@ type ReasoningFieldName = "reasoning" | "reasoning_content";
|
|
|
114
118
|
|
|
115
119
|
const REASONING_FIELDS: readonly ReasoningFieldName[] = ["reasoning_content", "reasoning"];
|
|
116
120
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
* - 允许传输层把单行拆成多个 chunk,但不接受 provider 把一个 JSON event 改写成多条 `data:` 行
|
|
134
|
-
*/
|
|
135
|
-
function parseChatSSE(buffer: string): { chunks: ChatChunk[]; rest: string; malformedEvents: number } {
|
|
136
|
-
const chunks: ChatChunk[] = [];
|
|
137
|
-
let rest = buffer;
|
|
138
|
-
let malformedEvents = 0;
|
|
139
|
-
|
|
140
|
-
while (true) {
|
|
141
|
-
const lineEnd = rest.indexOf("\n");
|
|
142
|
-
if (lineEnd === -1) {
|
|
143
|
-
// 没有更多完整行,剩余部分保留到下次
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const line = rest.slice(0, lineEnd).trim();
|
|
148
|
-
rest = rest.slice(lineEnd + 1);
|
|
149
|
-
|
|
150
|
-
if (!line.startsWith("data: ")) continue;
|
|
151
|
-
|
|
152
|
-
const data = line.slice(6).trim();
|
|
153
|
-
if (data === "[DONE]") continue;
|
|
154
|
-
|
|
155
|
-
try {
|
|
156
|
-
chunks.push(JSON.parse(data));
|
|
157
|
-
} catch {
|
|
158
|
-
malformedEvents++;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return { chunks, rest, malformedEvents };
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function ensureTextCompatibleBlocks(
|
|
166
|
-
blocks: import("../index.js").ContentBlock[],
|
|
167
|
-
field: string,
|
|
168
|
-
): import("../index.js").ContentBlock[] {
|
|
169
|
-
for (let i = 0; i < blocks.length; i++) {
|
|
170
|
-
const block = blocks[i];
|
|
171
|
-
if (!block) continue;
|
|
172
|
-
if (block.type !== "text" && block.type !== "json") {
|
|
173
|
-
throw new AIRequestError(
|
|
174
|
-
`chat-completions does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
|
|
175
|
-
"UNSUPPORTED_CONTENT_BLOCK",
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return blocks;
|
|
181
|
-
}
|
|
121
|
+
// ── ProviderProfile & Mapper ────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
const profile: ProviderProfile = {
|
|
124
|
+
kind: "chat-completions",
|
|
125
|
+
instructionsMode: "system_message",
|
|
126
|
+
supportedBlockTypes: ["text", "json"] as const,
|
|
127
|
+
reasoningBlockTypes: ["text"] as const,
|
|
128
|
+
capabilities: {
|
|
129
|
+
textStreaming: "native",
|
|
130
|
+
reasoningStreaming: "native",
|
|
131
|
+
toolCallStreaming: "native",
|
|
132
|
+
replay: "opaque",
|
|
133
|
+
usage: "final",
|
|
134
|
+
toolResultOutcomes: ["success"],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
182
137
|
|
|
183
|
-
|
|
184
|
-
return contentBlocksToText(ensureTextCompatibleBlocks(blocks, field));
|
|
185
|
-
}
|
|
138
|
+
const mapper = new NormalizedRequestMapper(profile);
|
|
186
139
|
|
|
187
140
|
function extractReasoningText(value: unknown): string {
|
|
188
141
|
if (typeof value === "string") return value;
|
|
@@ -215,9 +168,51 @@ function extractReasoningDeltas(delta: ChatChunkChoice["delta"]): Array<{ field:
|
|
|
215
168
|
return deltas;
|
|
216
169
|
}
|
|
217
170
|
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
|
|
171
|
+
function isChatReplayToolCall(value: unknown): value is ChatToolCall {
|
|
172
|
+
if (!value || typeof value !== "object") return false;
|
|
173
|
+
const entry = value as Record<string, unknown>;
|
|
174
|
+
if (typeof entry.id !== "string" || entry.type !== "function") return false;
|
|
175
|
+
const fn = entry.function;
|
|
176
|
+
if (!fn || typeof fn !== "object") return false;
|
|
177
|
+
const f = fn as Record<string, unknown>;
|
|
178
|
+
return typeof f.name === "string" && typeof f.arguments === "string";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isChatReplayMessage(value: unknown): value is ChatMessage {
|
|
182
|
+
if (!value || typeof value !== "object") return false;
|
|
183
|
+
const msg = value as Record<string, unknown>;
|
|
184
|
+
const role = msg.role;
|
|
185
|
+
if (role !== "system" && role !== "user" && role !== "assistant" && role !== "tool") {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (!(msg.content === null || typeof msg.content === "string")) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
if (msg.tool_calls !== undefined) {
|
|
192
|
+
if (!Array.isArray(msg.tool_calls) || !msg.tool_calls.every(isChatReplayToolCall)) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (msg.tool_call_id !== undefined && typeof msg.tool_call_id !== "string") {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
if (msg.name !== undefined && typeof msg.name !== "string") {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertChatReplayMessages(messages: unknown, field: string): asserts messages is ChatMessage[] {
|
|
206
|
+
if (!Array.isArray(messages)) {
|
|
207
|
+
throw new AIRequestError(`Invalid opaque replay payload: ${field} must be an array`, "INVALID_OPAQUE_REPLAY");
|
|
208
|
+
}
|
|
209
|
+
for (let i = 0; i < messages.length; i++) {
|
|
210
|
+
if (!isChatReplayMessage(messages[i])) {
|
|
211
|
+
throw new AIRequestError(
|
|
212
|
+
`Invalid opaque replay payload: ${field}[${i}] is not a valid chat message`,
|
|
213
|
+
"INVALID_OPAQUE_REPLAY",
|
|
214
|
+
);
|
|
215
|
+
}
|
|
221
216
|
}
|
|
222
217
|
}
|
|
223
218
|
|
|
@@ -256,7 +251,7 @@ function buildAssistantReplayMessage(params: {
|
|
|
256
251
|
|
|
257
252
|
export class ChatCompletionsAdapter extends AdapterBase {
|
|
258
253
|
readonly kind = "chat-completions" as const;
|
|
259
|
-
readonly
|
|
254
|
+
readonly capabilities = profile.capabilities;
|
|
260
255
|
|
|
261
256
|
private apiKey: string;
|
|
262
257
|
private baseUrl: string;
|
|
@@ -276,18 +271,16 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
276
271
|
|
|
277
272
|
// handle instructions → system message
|
|
278
273
|
if (request.instructions) {
|
|
279
|
-
|
|
280
|
-
typeof request.instructions === "string"
|
|
281
|
-
? request.instructions
|
|
282
|
-
: contentBlocksToChatText(request.instructions, "instructions");
|
|
283
|
-
messages.push({ role: "system", content });
|
|
274
|
+
messages.push({ role: "system", content: mapper.mapInstructions(request.instructions) });
|
|
284
275
|
}
|
|
285
276
|
|
|
286
277
|
for (const item of request.input) {
|
|
287
278
|
switch (item.type) {
|
|
288
279
|
case "message": {
|
|
289
280
|
const role = item.role;
|
|
290
|
-
const text =
|
|
281
|
+
const text = contentBlocksToText(
|
|
282
|
+
mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`),
|
|
283
|
+
);
|
|
291
284
|
messages.push({ role, content: text || null });
|
|
292
285
|
break;
|
|
293
286
|
}
|
|
@@ -310,12 +303,12 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
310
303
|
break;
|
|
311
304
|
}
|
|
312
305
|
case "tool_result": {
|
|
313
|
-
|
|
306
|
+
mapper.assertToolResultOutcome(item.outcome);
|
|
314
307
|
messages.push({
|
|
315
308
|
role: "tool",
|
|
316
309
|
tool_call_id: item.callId,
|
|
317
310
|
name: item.toolName,
|
|
318
|
-
content:
|
|
311
|
+
content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
|
|
319
312
|
});
|
|
320
313
|
break;
|
|
321
314
|
}
|
|
@@ -324,25 +317,27 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
324
317
|
// Convert to a text message for best-effort
|
|
325
318
|
messages.push({
|
|
326
319
|
role: "assistant",
|
|
327
|
-
content:
|
|
320
|
+
content: contentBlocksToText(mapper.ensureTextBlocks(item.content, "reasoning content")),
|
|
328
321
|
});
|
|
329
322
|
break;
|
|
330
323
|
}
|
|
331
324
|
case "opaque": {
|
|
332
325
|
// Try to restore from opaque replay
|
|
333
|
-
if (item.purpose
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
326
|
+
if (item.purpose !== "replay") break;
|
|
327
|
+
assertOpaqueReplayEnvelope(item.payload);
|
|
328
|
+
const payload = item.payload as Record<string, unknown>;
|
|
329
|
+
if (payload.role === "assistant" && typeof payload.content === "string") {
|
|
330
|
+
messages.push({ role: "assistant", content: payload.content });
|
|
331
|
+
} else if (payload.replaceCanonical === true && "messages" in payload) {
|
|
332
|
+
assertChatReplayMessages(payload.messages, "messages");
|
|
333
|
+
mapper.rollbackTrailingAssistantMessages(messages);
|
|
334
|
+
for (const m of payload.messages) {
|
|
335
|
+
messages.push(m);
|
|
336
|
+
}
|
|
337
|
+
} else if ("messages" in payload) {
|
|
338
|
+
assertChatReplayMessages(payload.messages, "messages");
|
|
339
|
+
for (const m of payload.messages) {
|
|
340
|
+
messages.push(m);
|
|
346
341
|
}
|
|
347
342
|
}
|
|
348
343
|
break;
|
|
@@ -354,6 +349,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
354
349
|
model: request.model,
|
|
355
350
|
messages,
|
|
356
351
|
stream: true,
|
|
352
|
+
n: 1,
|
|
357
353
|
};
|
|
358
354
|
|
|
359
355
|
if (request.tools && request.tools.length > 0) {
|
|
@@ -392,28 +388,45 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
392
388
|
request: NormalizedRequest,
|
|
393
389
|
): AsyncIterable<AIStreamEvent> {
|
|
394
390
|
const auxiliary = this.createAuxiliaryState(request);
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
391
|
+
let response: Response;
|
|
392
|
+
|
|
393
|
+
try {
|
|
394
|
+
response = await this.fetchFn(`${this.baseUrl}/chat/completions`, {
|
|
395
|
+
method: "POST",
|
|
396
|
+
headers: {
|
|
397
|
+
"Content-Type": "application/json",
|
|
398
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
399
|
+
},
|
|
400
|
+
body: JSON.stringify(providerRequest),
|
|
401
|
+
});
|
|
402
|
+
} catch (err) {
|
|
403
|
+
throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
|
|
404
|
+
}
|
|
403
405
|
|
|
404
406
|
if (!response.ok) {
|
|
405
|
-
const
|
|
406
|
-
throw
|
|
407
|
+
const errorBody = await response.text().catch(() => "");
|
|
408
|
+
throw providerHttpError(response.status, errorBody);
|
|
407
409
|
}
|
|
408
410
|
|
|
409
411
|
const reader = response.body?.getReader();
|
|
410
412
|
if (!reader) {
|
|
411
|
-
throw new
|
|
413
|
+
throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
|
|
412
414
|
}
|
|
413
415
|
|
|
416
|
+
const parser = new IncrementalStreamParser<ChatChunk>(splitLines, (item: string) => {
|
|
417
|
+
const trimmed = item.trim();
|
|
418
|
+
if (!trimmed.startsWith("data: ")) return { status: "ignored" };
|
|
419
|
+
const data = trimmed.slice(6).trim();
|
|
420
|
+
if (data === "[DONE]") return { status: "ignored" };
|
|
421
|
+
try {
|
|
422
|
+
return { status: "parsed", value: JSON.parse(data) as ChatChunk };
|
|
423
|
+
} catch {
|
|
424
|
+
return { status: "malformed" };
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
414
428
|
const output: OutputItem[] = [];
|
|
415
|
-
|
|
416
|
-
let buffer = "";
|
|
429
|
+
let streamDone = false;
|
|
417
430
|
|
|
418
431
|
// 累积状态 — 支持多 choice,此处只取 index 0
|
|
419
432
|
let responseId: string | undefined;
|
|
@@ -423,6 +436,9 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
423
436
|
let currentReasoningId = "";
|
|
424
437
|
let hasMessageStarted = false;
|
|
425
438
|
let hasReasoningStarted = false;
|
|
439
|
+
let completedEmitted = false;
|
|
440
|
+
let warnedNonZeroChoice = false;
|
|
441
|
+
const buildResponse = this.buildResponse.bind(this);
|
|
426
442
|
|
|
427
443
|
// tool_calls 累积: tool call index → { id, name, args }
|
|
428
444
|
const pendingToolCalls = new Map<number, PendingToolCall>();
|
|
@@ -435,19 +451,19 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
435
451
|
|
|
436
452
|
if (hasReasoningStarted && accumulatedReasoning) {
|
|
437
453
|
const reasoning = reasoningItem([textBlock(accumulatedReasoning)], "full", currentReasoningId);
|
|
438
|
-
events.push(factory.reasoningCompleted(
|
|
454
|
+
events.push(factory.reasoningCompleted(currentReasoningId));
|
|
439
455
|
output.push(reasoning);
|
|
440
456
|
}
|
|
441
457
|
|
|
442
|
-
if (hasMessageStarted
|
|
458
|
+
if (hasMessageStarted) {
|
|
443
459
|
const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
|
|
444
|
-
events.push(factory.messageCompleted(
|
|
460
|
+
events.push(factory.messageCompleted(currentMessageId));
|
|
445
461
|
output.push(message);
|
|
446
462
|
}
|
|
447
463
|
|
|
448
464
|
for (const pending of finalizedToolCalls) {
|
|
449
465
|
const toolCall = toolCallItem(pending.id, pending.name, pending.args);
|
|
450
|
-
events.push(factory.toolCallCompleted(
|
|
466
|
+
events.push(factory.toolCallCompleted(pending.id));
|
|
451
467
|
output.push(toolCall);
|
|
452
468
|
}
|
|
453
469
|
|
|
@@ -469,14 +485,70 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
469
485
|
return { events, assistantReplayMessage };
|
|
470
486
|
};
|
|
471
487
|
|
|
488
|
+
const emitCompleted = async function* (
|
|
489
|
+
stopReason: import("../index.js").StopReason | undefined,
|
|
490
|
+
assistantReplayMessage: ChatMessage | null,
|
|
491
|
+
rawResponseId: string | undefined,
|
|
492
|
+
): AsyncIterable<AIStreamEvent> {
|
|
493
|
+
if (completedEmitted) {
|
|
494
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
completedEmitted = true;
|
|
499
|
+
|
|
500
|
+
const replay = [...replayFromOutput(output)];
|
|
501
|
+
|
|
502
|
+
if (assistantReplayMessage) {
|
|
503
|
+
replay.push(
|
|
504
|
+
opaqueItem("chat.completions", "replay", {
|
|
505
|
+
replaceCanonical: true,
|
|
506
|
+
messages: [assistantReplayMessage],
|
|
507
|
+
}),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
512
|
+
for (const event of auxiliaryResult.events) {
|
|
513
|
+
yield event;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const finalResponse = buildResponse(
|
|
517
|
+
request,
|
|
518
|
+
{
|
|
519
|
+
output,
|
|
520
|
+
replay,
|
|
521
|
+
stopReason,
|
|
522
|
+
usage: auxiliaryResult.usage,
|
|
523
|
+
billing: auxiliaryResult.billing,
|
|
524
|
+
auxiliary: auxiliaryResult.auxiliary,
|
|
525
|
+
warnings: auxiliaryResult.warnings,
|
|
526
|
+
metadataSources: auxiliaryResult.metadataSources,
|
|
527
|
+
rawResponseId,
|
|
528
|
+
},
|
|
529
|
+
factory,
|
|
530
|
+
);
|
|
531
|
+
yield factory.responseCompleted({
|
|
532
|
+
replay: finalResponse.replay,
|
|
533
|
+
stopReason: finalResponse.stopReason,
|
|
534
|
+
trace: finalResponse.backend,
|
|
535
|
+
usage: finalResponse.usage,
|
|
536
|
+
billing: finalResponse.billing,
|
|
537
|
+
auxiliary: finalResponse.auxiliary,
|
|
538
|
+
warnings: finalResponse.warnings,
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
|
|
472
542
|
try {
|
|
473
543
|
while (true) {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
544
|
+
const readResult = await reader.read().catch((err: unknown) => {
|
|
545
|
+
throw new AIStreamError(
|
|
546
|
+
`Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
|
|
547
|
+
"STREAM_ERROR",
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
const { done, value } = readResult;
|
|
551
|
+
const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
|
|
480
552
|
|
|
481
553
|
const malformedWarning = emitMalformedStreamWarning(factory, {
|
|
482
554
|
count: malformedEvents,
|
|
@@ -496,19 +568,34 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
496
568
|
}
|
|
497
569
|
|
|
498
570
|
for (const choice of chunk.choices) {
|
|
499
|
-
if (choice.index !== 0)
|
|
571
|
+
if (choice.index !== 0) {
|
|
572
|
+
if (!warnedNonZeroChoice) {
|
|
573
|
+
yield factory.responseWarning(
|
|
574
|
+
`Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
|
|
575
|
+
"MULTIPLE_CHOICES_IGNORED",
|
|
576
|
+
);
|
|
577
|
+
warnedNonZeroChoice = true;
|
|
578
|
+
}
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (completedEmitted) {
|
|
583
|
+
if (choice.finish_reason) {
|
|
584
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
500
588
|
|
|
501
589
|
const delta = choice.delta;
|
|
502
590
|
const finishReason = choice.finish_reason;
|
|
503
591
|
const reasoningDeltas = extractReasoningDeltas(delta);
|
|
504
592
|
|
|
505
|
-
|
|
506
|
-
|
|
593
|
+
const ensureMessageStarted = (): void => {
|
|
594
|
+
if (hasMessageStarted) return;
|
|
507
595
|
currentMessageId = `msg-${chunk.id}`;
|
|
508
596
|
hasMessageStarted = true;
|
|
509
597
|
accumulatedContent = "";
|
|
510
|
-
|
|
511
|
-
}
|
|
598
|
+
};
|
|
512
599
|
|
|
513
600
|
// 处理 third-party reasoning delta
|
|
514
601
|
if (reasoningDeltas.length > 0) {
|
|
@@ -532,16 +619,20 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
532
619
|
// 处理 content delta
|
|
533
620
|
if (delta.content) {
|
|
534
621
|
if (!hasMessageStarted) {
|
|
535
|
-
|
|
536
|
-
hasMessageStarted = true;
|
|
622
|
+
ensureMessageStarted();
|
|
537
623
|
yield factory.messageStarted(currentMessageId);
|
|
538
624
|
}
|
|
539
625
|
accumulatedContent += delta.content;
|
|
540
|
-
yield factory.messageDelta(currentMessageId, delta.content);
|
|
626
|
+
yield factory.messageDelta(currentMessageId, textBlock(delta.content));
|
|
541
627
|
}
|
|
542
628
|
|
|
543
629
|
// 处理 tool_calls delta
|
|
544
630
|
if (delta.tool_calls) {
|
|
631
|
+
if (!hasMessageStarted) {
|
|
632
|
+
ensureMessageStarted();
|
|
633
|
+
yield factory.messageStarted(currentMessageId);
|
|
634
|
+
}
|
|
635
|
+
|
|
545
636
|
for (const tc of delta.tool_calls) {
|
|
546
637
|
const idx = tc.index;
|
|
547
638
|
|
|
@@ -562,6 +653,11 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
562
653
|
|
|
563
654
|
// 处理 function_call delta (legacy format)
|
|
564
655
|
if (delta.function_call) {
|
|
656
|
+
if (!hasMessageStarted) {
|
|
657
|
+
ensureMessageStarted();
|
|
658
|
+
yield factory.messageStarted(currentMessageId);
|
|
659
|
+
}
|
|
660
|
+
|
|
565
661
|
if (delta.function_call.name) {
|
|
566
662
|
const fcId = `fc-${chunk.id}-0`;
|
|
567
663
|
pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
|
|
@@ -583,58 +679,31 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
583
679
|
yield event;
|
|
584
680
|
}
|
|
585
681
|
|
|
586
|
-
// 构建 stop reason
|
|
587
682
|
const stopReason = mapStopReason(finishReason);
|
|
588
|
-
|
|
589
|
-
// 构建 replay
|
|
590
|
-
const replay = [...replayFromOutput(output)];
|
|
591
|
-
|
|
592
|
-
// 附加 opaque replay
|
|
593
|
-
if (assistantReplayMessage) {
|
|
594
|
-
replay.push(
|
|
595
|
-
opaqueItem("chat.completions", "replay", {
|
|
596
|
-
replaceCanonical: true,
|
|
597
|
-
messages: [assistantReplayMessage],
|
|
598
|
-
}),
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
603
|
-
for (const event of auxiliaryResult.events) {
|
|
604
|
-
yield event;
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
yield factory.responseCompleted(
|
|
608
|
-
this.buildResponse(
|
|
609
|
-
request,
|
|
610
|
-
{
|
|
611
|
-
output,
|
|
612
|
-
replay,
|
|
613
|
-
stopReason,
|
|
614
|
-
usage: auxiliaryResult.usage,
|
|
615
|
-
billing: auxiliaryResult.billing,
|
|
616
|
-
auxiliary: auxiliaryResult.auxiliary,
|
|
617
|
-
warnings: auxiliaryResult.warnings,
|
|
618
|
-
metadataSources: auxiliaryResult.metadataSources,
|
|
619
|
-
rawResponseId: chunk.id,
|
|
620
|
-
},
|
|
621
|
-
factory,
|
|
622
|
-
),
|
|
623
|
-
);
|
|
683
|
+
yield* emitCompleted(stopReason, assistantReplayMessage, chunk.id);
|
|
624
684
|
}
|
|
625
685
|
}
|
|
626
686
|
}
|
|
687
|
+
|
|
688
|
+
if (done) {
|
|
689
|
+
streamDone = true;
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
627
692
|
}
|
|
628
693
|
} finally {
|
|
629
|
-
|
|
694
|
+
try {
|
|
695
|
+
if (!streamDone) await reader.cancel().catch(() => undefined);
|
|
696
|
+
} finally {
|
|
697
|
+
reader.releaseLock();
|
|
698
|
+
}
|
|
630
699
|
}
|
|
631
700
|
|
|
632
|
-
if (
|
|
701
|
+
if (parser.getRemaining().trim().length > 0) {
|
|
633
702
|
yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
|
|
634
703
|
}
|
|
635
704
|
|
|
636
705
|
// 如果流结束时没有 finish_reason(断流),也尝试关闭
|
|
637
|
-
if (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0) {
|
|
706
|
+
if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
|
|
638
707
|
yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
|
|
639
708
|
|
|
640
709
|
const { events, assistantReplayMessage } = finalizePendingTurn();
|
|
@@ -642,37 +711,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
642
711
|
yield event;
|
|
643
712
|
}
|
|
644
713
|
|
|
645
|
-
|
|
646
|
-
if (assistantReplayMessage) {
|
|
647
|
-
replay.push(
|
|
648
|
-
opaqueItem("chat.completions", "replay", {
|
|
649
|
-
replaceCanonical: true,
|
|
650
|
-
messages: [assistantReplayMessage],
|
|
651
|
-
}),
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
656
|
-
for (const event of auxiliaryResult.events) {
|
|
657
|
-
yield event;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
yield factory.responseCompleted(
|
|
661
|
-
this.buildResponse(
|
|
662
|
-
request,
|
|
663
|
-
{
|
|
664
|
-
output,
|
|
665
|
-
replay,
|
|
666
|
-
usage: auxiliaryResult.usage,
|
|
667
|
-
billing: auxiliaryResult.billing,
|
|
668
|
-
auxiliary: auxiliaryResult.auxiliary,
|
|
669
|
-
warnings: auxiliaryResult.warnings,
|
|
670
|
-
metadataSources: auxiliaryResult.metadataSources,
|
|
671
|
-
rawResponseId: responseId,
|
|
672
|
-
},
|
|
673
|
-
factory,
|
|
674
|
-
),
|
|
675
|
-
);
|
|
714
|
+
yield* emitCompleted(undefined, assistantReplayMessage, responseId);
|
|
676
715
|
}
|
|
677
716
|
}
|
|
678
717
|
}
|