@codehz/ai 0.1.8 → 0.2.1
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 +153 -31
- package/dist/index.mjs +1290 -727
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/adapters/chat-completions.ts +243 -196
- 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 +218 -61
- 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 +6 -43
- 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 -4
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,21 @@ 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(
|
|
445
|
-
|
|
460
|
+
events.push(factory.messageCompleted(currentMessageId));
|
|
461
|
+
if (accumulatedContent) {
|
|
462
|
+
output.push(message);
|
|
463
|
+
}
|
|
446
464
|
}
|
|
447
465
|
|
|
448
466
|
for (const pending of finalizedToolCalls) {
|
|
449
467
|
const toolCall = toolCallItem(pending.id, pending.name, pending.args);
|
|
450
|
-
events.push(factory.toolCallCompleted(
|
|
468
|
+
events.push(factory.toolCallCompleted(pending.id));
|
|
451
469
|
output.push(toolCall);
|
|
452
470
|
}
|
|
453
471
|
|
|
@@ -469,14 +487,70 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
469
487
|
return { events, assistantReplayMessage };
|
|
470
488
|
};
|
|
471
489
|
|
|
490
|
+
const emitCompleted = async function* (
|
|
491
|
+
stopReason: import("../index.js").StopReason | undefined,
|
|
492
|
+
assistantReplayMessage: ChatMessage | null,
|
|
493
|
+
rawResponseId: string | undefined,
|
|
494
|
+
): AsyncIterable<AIStreamEvent> {
|
|
495
|
+
if (completedEmitted) {
|
|
496
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
completedEmitted = true;
|
|
501
|
+
|
|
502
|
+
const replay = [...replayFromOutput(output)];
|
|
503
|
+
|
|
504
|
+
if (assistantReplayMessage) {
|
|
505
|
+
replay.push(
|
|
506
|
+
opaqueItem("chat.completions", "replay", {
|
|
507
|
+
replaceCanonical: true,
|
|
508
|
+
messages: [assistantReplayMessage],
|
|
509
|
+
}),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
514
|
+
for (const event of auxiliaryResult.events) {
|
|
515
|
+
yield event;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const finalResponse = buildResponse(
|
|
519
|
+
request,
|
|
520
|
+
{
|
|
521
|
+
output,
|
|
522
|
+
replay,
|
|
523
|
+
stopReason,
|
|
524
|
+
usage: auxiliaryResult.usage,
|
|
525
|
+
billing: auxiliaryResult.billing,
|
|
526
|
+
auxiliary: auxiliaryResult.auxiliary,
|
|
527
|
+
warnings: auxiliaryResult.warnings,
|
|
528
|
+
metadataSources: auxiliaryResult.metadataSources,
|
|
529
|
+
rawResponseId,
|
|
530
|
+
},
|
|
531
|
+
factory,
|
|
532
|
+
);
|
|
533
|
+
yield factory.responseCompleted({
|
|
534
|
+
replay: finalResponse.replay,
|
|
535
|
+
stopReason: finalResponse.stopReason,
|
|
536
|
+
trace: finalResponse.backend,
|
|
537
|
+
usage: finalResponse.usage,
|
|
538
|
+
billing: finalResponse.billing,
|
|
539
|
+
auxiliary: finalResponse.auxiliary,
|
|
540
|
+
warnings: finalResponse.warnings,
|
|
541
|
+
});
|
|
542
|
+
};
|
|
543
|
+
|
|
472
544
|
try {
|
|
473
545
|
while (true) {
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
546
|
+
const readResult = await reader.read().catch((err: unknown) => {
|
|
547
|
+
throw new AIStreamError(
|
|
548
|
+
`Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
|
|
549
|
+
"STREAM_ERROR",
|
|
550
|
+
);
|
|
551
|
+
});
|
|
552
|
+
const { done, value } = readResult;
|
|
553
|
+
const { items: chunks, malformed: malformedEvents } = done ? parser.flush() : parser.feed(value as Uint8Array);
|
|
480
554
|
|
|
481
555
|
const malformedWarning = emitMalformedStreamWarning(factory, {
|
|
482
556
|
count: malformedEvents,
|
|
@@ -496,17 +570,38 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
496
570
|
}
|
|
497
571
|
|
|
498
572
|
for (const choice of chunk.choices) {
|
|
499
|
-
if (choice.index !== 0)
|
|
573
|
+
if (choice.index !== 0) {
|
|
574
|
+
if (!warnedNonZeroChoice) {
|
|
575
|
+
yield factory.responseWarning(
|
|
576
|
+
`Chat Completions returned choice index ${choice.index}; only the first choice (index 0) is supported. This choice is ignored.`,
|
|
577
|
+
"MULTIPLE_CHOICES_IGNORED",
|
|
578
|
+
);
|
|
579
|
+
warnedNonZeroChoice = true;
|
|
580
|
+
}
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (completedEmitted) {
|
|
585
|
+
if (choice.finish_reason) {
|
|
586
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
587
|
+
}
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
500
590
|
|
|
501
591
|
const delta = choice.delta;
|
|
502
592
|
const finishReason = choice.finish_reason;
|
|
503
593
|
const reasoningDeltas = extractReasoningDeltas(delta);
|
|
504
594
|
|
|
505
|
-
|
|
506
|
-
|
|
595
|
+
const ensureMessageStarted = (): void => {
|
|
596
|
+
if (hasMessageStarted) return;
|
|
507
597
|
currentMessageId = `msg-${chunk.id}`;
|
|
508
598
|
hasMessageStarted = true;
|
|
509
599
|
accumulatedContent = "";
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
// 处理 role: assistant(首块标识;不要求 content)
|
|
603
|
+
if (delta.role === "assistant" && !hasMessageStarted) {
|
|
604
|
+
ensureMessageStarted();
|
|
510
605
|
yield factory.messageStarted(currentMessageId);
|
|
511
606
|
}
|
|
512
607
|
|
|
@@ -532,16 +627,20 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
532
627
|
// 处理 content delta
|
|
533
628
|
if (delta.content) {
|
|
534
629
|
if (!hasMessageStarted) {
|
|
535
|
-
|
|
536
|
-
hasMessageStarted = true;
|
|
630
|
+
ensureMessageStarted();
|
|
537
631
|
yield factory.messageStarted(currentMessageId);
|
|
538
632
|
}
|
|
539
633
|
accumulatedContent += delta.content;
|
|
540
|
-
yield factory.messageDelta(currentMessageId, delta.content);
|
|
634
|
+
yield factory.messageDelta(currentMessageId, textBlock(delta.content));
|
|
541
635
|
}
|
|
542
636
|
|
|
543
637
|
// 处理 tool_calls delta
|
|
544
638
|
if (delta.tool_calls) {
|
|
639
|
+
if (!hasMessageStarted) {
|
|
640
|
+
ensureMessageStarted();
|
|
641
|
+
yield factory.messageStarted(currentMessageId);
|
|
642
|
+
}
|
|
643
|
+
|
|
545
644
|
for (const tc of delta.tool_calls) {
|
|
546
645
|
const idx = tc.index;
|
|
547
646
|
|
|
@@ -562,6 +661,11 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
562
661
|
|
|
563
662
|
// 处理 function_call delta (legacy format)
|
|
564
663
|
if (delta.function_call) {
|
|
664
|
+
if (!hasMessageStarted) {
|
|
665
|
+
ensureMessageStarted();
|
|
666
|
+
yield factory.messageStarted(currentMessageId);
|
|
667
|
+
}
|
|
668
|
+
|
|
565
669
|
if (delta.function_call.name) {
|
|
566
670
|
const fcId = `fc-${chunk.id}-0`;
|
|
567
671
|
pendingToolCalls.set(0, { id: fcId, name: delta.function_call.name, args: "" });
|
|
@@ -583,58 +687,31 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
583
687
|
yield event;
|
|
584
688
|
}
|
|
585
689
|
|
|
586
|
-
// 构建 stop reason
|
|
587
690
|
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
|
-
);
|
|
691
|
+
yield* emitCompleted(stopReason, assistantReplayMessage, chunk.id);
|
|
624
692
|
}
|
|
625
693
|
}
|
|
626
694
|
}
|
|
695
|
+
|
|
696
|
+
if (done) {
|
|
697
|
+
streamDone = true;
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
627
700
|
}
|
|
628
701
|
} finally {
|
|
629
|
-
|
|
702
|
+
try {
|
|
703
|
+
if (!streamDone) await reader.cancel().catch(() => undefined);
|
|
704
|
+
} finally {
|
|
705
|
+
reader.releaseLock();
|
|
706
|
+
}
|
|
630
707
|
}
|
|
631
708
|
|
|
632
|
-
if (
|
|
709
|
+
if (parser.getRemaining().trim().length > 0) {
|
|
633
710
|
yield factory.responseWarning("Stream ended with an incomplete Chat Completions SSE frame", "STREAM_ERROR");
|
|
634
711
|
}
|
|
635
712
|
|
|
636
713
|
// 如果流结束时没有 finish_reason(断流),也尝试关闭
|
|
637
|
-
if (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0) {
|
|
714
|
+
if (!completedEmitted && (hasMessageStarted || hasReasoningStarted || pendingToolCalls.size > 0)) {
|
|
638
715
|
yield factory.responseWarning("Stream ended without a finish_reason", "INCOMPLETE_STREAM");
|
|
639
716
|
|
|
640
717
|
const { events, assistantReplayMessage } = finalizePendingTurn();
|
|
@@ -642,37 +719,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
642
719
|
yield event;
|
|
643
720
|
}
|
|
644
721
|
|
|
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
|
-
);
|
|
722
|
+
yield* emitCompleted(undefined, assistantReplayMessage, responseId);
|
|
676
723
|
}
|
|
677
724
|
}
|
|
678
725
|
}
|