@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/src/adapters/ollama.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { AdapterBase } from "../helpers/adapter-base.js";
|
|
19
|
-
import { AIRequestError } from "../core/errors.js";
|
|
19
|
+
import { AIProviderError, AIRequestError, AIStreamError, WarningCode } from "../core/errors.js";
|
|
20
20
|
import {
|
|
21
21
|
textBlock,
|
|
22
22
|
messageItem,
|
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
contentBlocksToText,
|
|
28
28
|
} from "../helpers/mapping.js";
|
|
29
29
|
import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
|
|
30
|
+
import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
|
|
30
31
|
import { usageFromOllama } from "../helpers/usage-mapping.js";
|
|
32
|
+
import { NormalizedRequestMapper, splitLines, IncrementalStreamParser } from "../helpers/index.js";
|
|
33
|
+
import type { ProviderProfile } from "../helpers/index.js";
|
|
31
34
|
|
|
32
35
|
import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
|
|
33
36
|
|
|
@@ -79,45 +82,24 @@ type OllamaTool = {
|
|
|
79
82
|
};
|
|
80
83
|
};
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function ensureOllamaReasoningBlocks(
|
|
101
|
-
blocks: import("../index.js").ContentBlock[],
|
|
102
|
-
field: string,
|
|
103
|
-
): Array<Extract<import("../index.js").ContentBlock, { type: "text" }>> {
|
|
104
|
-
return blocks.map((block, index) => {
|
|
105
|
-
if (block.type !== "text") {
|
|
106
|
-
throw new AIRequestError(
|
|
107
|
-
`ollama does not support ${field}[${index}] of type "${block.type}"; reasoning only supports text blocks`,
|
|
108
|
-
"UNSUPPORTED_CONTENT_BLOCK",
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return block;
|
|
113
|
-
});
|
|
114
|
-
}
|
|
85
|
+
// ── ProviderProfile & Mapper ────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const profile: ProviderProfile = {
|
|
88
|
+
kind: "ollama",
|
|
89
|
+
instructionsMode: "system_message",
|
|
90
|
+
supportedBlockTypes: ["text", "json"] as const,
|
|
91
|
+
reasoningBlockTypes: ["text"] as const,
|
|
92
|
+
capabilities: {
|
|
93
|
+
textStreaming: "native",
|
|
94
|
+
reasoningStreaming: "none",
|
|
95
|
+
toolCallStreaming: "synthetic",
|
|
96
|
+
replay: "opaque",
|
|
97
|
+
usage: "final",
|
|
98
|
+
toolResultOutcomes: ["success"],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
115
101
|
|
|
116
|
-
|
|
117
|
-
return typeof instructions === "string"
|
|
118
|
-
? instructions
|
|
119
|
-
: contentBlocksToText(ensureOllamaTextBlocks(instructions, "instructions"));
|
|
120
|
-
}
|
|
102
|
+
const mapper = new NormalizedRequestMapper(profile);
|
|
121
103
|
|
|
122
104
|
function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Record<string, unknown> {
|
|
123
105
|
if (item.argumentsJson && typeof item.argumentsJson === "object" && item.argumentsJson !== null) {
|
|
@@ -139,15 +121,6 @@ function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Rec
|
|
|
139
121
|
);
|
|
140
122
|
}
|
|
141
123
|
|
|
142
|
-
function assertOllamaToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
|
|
143
|
-
if (outcome !== "success") {
|
|
144
|
-
throw new AIRequestError(
|
|
145
|
-
`ollama does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
|
|
146
|
-
"UNSUPPORTED_TOOL_RESULT_OUTCOME",
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
124
|
// ── Ollama 流式 chunk ─────────────────────────────────────────
|
|
152
125
|
|
|
153
126
|
type OllamaChatChunk = {
|
|
@@ -169,50 +142,17 @@ type OllamaChatChunk = {
|
|
|
169
142
|
eval_duration?: number;
|
|
170
143
|
};
|
|
171
144
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
function parseOllamaNDJSON(buffer: string): { chunks: OllamaChatChunk[]; rest: string; malformedLines: number } {
|
|
175
|
-
const chunks: OllamaChatChunk[] = [];
|
|
176
|
-
let rest = buffer;
|
|
177
|
-
let malformedLines = 0;
|
|
178
|
-
|
|
179
|
-
while (true) {
|
|
180
|
-
const lineEnd = rest.indexOf("\n");
|
|
181
|
-
if (lineEnd === -1) break;
|
|
182
|
-
|
|
183
|
-
const line = rest.slice(0, lineEnd).trim();
|
|
184
|
-
rest = rest.slice(lineEnd + 1);
|
|
185
|
-
|
|
186
|
-
if (!line) continue;
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
const parsed = JSON.parse(line);
|
|
190
|
-
// Ollama chunks have a "message" field in streaming mode
|
|
191
|
-
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
|
192
|
-
chunks.push(parsed as OllamaChatChunk);
|
|
193
|
-
} else {
|
|
194
|
-
malformedLines++;
|
|
195
|
-
}
|
|
196
|
-
} catch {
|
|
197
|
-
malformedLines++;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return { chunks, rest, malformedLines };
|
|
202
|
-
}
|
|
145
|
+
/** Opaque replay may carry optional local `id`s; wire tool_calls never include them. */
|
|
146
|
+
type OllamaReplayToolCall = OllamaToolCall & { id?: string };
|
|
203
147
|
|
|
204
|
-
function
|
|
205
|
-
while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
|
|
206
|
-
messages.pop();
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function isOllamaToolCalls(value: unknown): value is OllamaToolCall[] {
|
|
148
|
+
function isOllamaReplayToolCalls(value: unknown): value is OllamaReplayToolCall[] {
|
|
211
149
|
return (
|
|
212
150
|
Array.isArray(value) &&
|
|
213
151
|
value.every((entry) => {
|
|
214
152
|
if (!entry || typeof entry !== "object" || !("function" in entry)) return false;
|
|
215
153
|
const fn = (entry as { function?: unknown }).function;
|
|
154
|
+
const id = (entry as { id?: unknown }).id;
|
|
155
|
+
if (id !== undefined && typeof id !== "string") return false;
|
|
216
156
|
return (
|
|
217
157
|
!!fn &&
|
|
218
158
|
typeof fn === "object" &&
|
|
@@ -226,11 +166,20 @@ function isOllamaToolCalls(value: unknown): value is OllamaToolCall[] {
|
|
|
226
166
|
);
|
|
227
167
|
}
|
|
228
168
|
|
|
169
|
+
function toWireOllamaToolCalls(toolCalls: OllamaReplayToolCall[]): OllamaToolCall[] {
|
|
170
|
+
return toolCalls.map((tc) => ({
|
|
171
|
+
function: {
|
|
172
|
+
name: tc.function.name,
|
|
173
|
+
arguments: tc.function.arguments,
|
|
174
|
+
},
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
229
178
|
// ── Adapter ───────────────────────────────────────────────────
|
|
230
179
|
|
|
231
180
|
export class OllamaAdapter extends AdapterBase {
|
|
232
181
|
readonly kind = "ollama" as const;
|
|
233
|
-
readonly
|
|
182
|
+
readonly capabilities = profile.capabilities;
|
|
234
183
|
|
|
235
184
|
private baseUrl: string;
|
|
236
185
|
private apiKey: string | undefined;
|
|
@@ -251,10 +200,12 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
251
200
|
}
|
|
252
201
|
|
|
253
202
|
const messages: OllamaMessage[] = [];
|
|
203
|
+
/** Local-only name → call id queue for best-effort tool_result association (not sent to Ollama). */
|
|
204
|
+
const callIdsByName = new Map<string, string[]>();
|
|
254
205
|
|
|
255
206
|
// handle instructions → system message
|
|
256
207
|
if (request.instructions) {
|
|
257
|
-
messages.push({ role: "system", content:
|
|
208
|
+
messages.push({ role: "system", content: mapper.mapInstructions(request.instructions) });
|
|
258
209
|
}
|
|
259
210
|
|
|
260
211
|
for (const item of request.input) {
|
|
@@ -263,7 +214,7 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
263
214
|
const role = item.role;
|
|
264
215
|
messages.push({
|
|
265
216
|
role,
|
|
266
|
-
content: contentBlocksToText(
|
|
217
|
+
content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`)),
|
|
267
218
|
});
|
|
268
219
|
break;
|
|
269
220
|
}
|
|
@@ -276,6 +227,9 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
276
227
|
arguments: parseOllamaToolArguments(item),
|
|
277
228
|
},
|
|
278
229
|
};
|
|
230
|
+
const queue = callIdsByName.get(item.name) ?? [];
|
|
231
|
+
queue.push(item.id);
|
|
232
|
+
callIdsByName.set(item.name, queue);
|
|
279
233
|
if (lastAssistant) {
|
|
280
234
|
lastAssistant.tool_calls = [...(lastAssistant.tool_calls ?? []), tc];
|
|
281
235
|
} else {
|
|
@@ -284,10 +238,15 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
284
238
|
break;
|
|
285
239
|
}
|
|
286
240
|
case "tool_result": {
|
|
287
|
-
|
|
241
|
+
mapper.assertToolResultOutcome(item.outcome);
|
|
242
|
+
// Best-effort: consume matching id from name queue when present (no wire call_id)
|
|
243
|
+
const queue = callIdsByName.get(item.toolName);
|
|
244
|
+
if (queue && queue.length > 0) {
|
|
245
|
+
queue.shift();
|
|
246
|
+
}
|
|
288
247
|
messages.push({
|
|
289
248
|
role: "tool",
|
|
290
|
-
content: contentBlocksToText(
|
|
249
|
+
content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
|
|
291
250
|
});
|
|
292
251
|
break;
|
|
293
252
|
}
|
|
@@ -295,27 +254,42 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
295
254
|
// Ollama doesn't support reasoning in input; convert to text message
|
|
296
255
|
messages.push({
|
|
297
256
|
role: "assistant",
|
|
298
|
-
content: contentBlocksToText(
|
|
257
|
+
content: contentBlocksToText(mapper.ensureReasoningBlocks(item.content, "reasoning content")),
|
|
299
258
|
});
|
|
300
259
|
break;
|
|
301
260
|
}
|
|
302
261
|
case "opaque": {
|
|
303
|
-
// Best-effort restore from opaque replay
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
262
|
+
// Best-effort restore from opaque replay (local ids stripped before wire)
|
|
263
|
+
if (item.source !== "ollama" || item.purpose !== "replay") break;
|
|
264
|
+
assertOpaqueReplayEnvelope(item.payload);
|
|
265
|
+
const payload = item.payload as Record<string, unknown>;
|
|
266
|
+
if (payload.role === "assistant" && typeof payload.content === "string") {
|
|
267
|
+
mapper.rollbackTrailingAssistantMessages(messages);
|
|
268
|
+
let replayToolCalls: OllamaReplayToolCall[] | undefined;
|
|
269
|
+
if ("tool_calls" in payload && payload.tool_calls !== undefined) {
|
|
270
|
+
if (!isOllamaReplayToolCalls(payload.tool_calls)) {
|
|
271
|
+
throw new AIRequestError(
|
|
272
|
+
"Invalid opaque replay payload: tool_calls is not a valid ollama tool_calls array",
|
|
273
|
+
"INVALID_OPAQUE_REPLAY",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
replayToolCalls = payload.tool_calls;
|
|
277
|
+
}
|
|
278
|
+
// Record name → id order for best-effort tool_result correlation (local only)
|
|
279
|
+
if (replayToolCalls) {
|
|
280
|
+
for (const tc of replayToolCalls) {
|
|
281
|
+
if (tc.id) {
|
|
282
|
+
const queue = callIdsByName.get(tc.function.name) ?? [];
|
|
283
|
+
queue.push(tc.id);
|
|
284
|
+
callIdsByName.set(tc.function.name, queue);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
318
287
|
}
|
|
288
|
+
messages.push({
|
|
289
|
+
role: "assistant",
|
|
290
|
+
content: payload.content,
|
|
291
|
+
tool_calls: replayToolCalls ? toWireOllamaToolCalls(replayToolCalls) : undefined,
|
|
292
|
+
});
|
|
319
293
|
}
|
|
320
294
|
break;
|
|
321
295
|
}
|
|
@@ -358,6 +332,7 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
358
332
|
request: NormalizedRequest,
|
|
359
333
|
): AsyncIterable<AIStreamEvent> {
|
|
360
334
|
const auxiliary = this.createAuxiliaryState(request);
|
|
335
|
+
let completedEmitted = false;
|
|
361
336
|
if (request.metadata) {
|
|
362
337
|
yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
|
|
363
338
|
}
|
|
@@ -369,25 +344,44 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
369
344
|
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
370
345
|
}
|
|
371
346
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
347
|
+
let response: Response;
|
|
348
|
+
|
|
349
|
+
try {
|
|
350
|
+
response = await this.fetchFn(`${this.baseUrl}/api/chat`, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers,
|
|
353
|
+
body: JSON.stringify(providerRequest),
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
|
|
357
|
+
}
|
|
377
358
|
|
|
378
359
|
if (!response.ok) {
|
|
379
|
-
const
|
|
380
|
-
throw
|
|
360
|
+
const errorBody = await response.text().catch(() => "");
|
|
361
|
+
throw providerHttpError(response.status, errorBody);
|
|
381
362
|
}
|
|
382
363
|
|
|
383
364
|
const reader = response.body?.getReader();
|
|
384
365
|
if (!reader) {
|
|
385
|
-
throw new
|
|
366
|
+
throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
|
|
386
367
|
}
|
|
387
368
|
|
|
369
|
+
const parser = new IncrementalStreamParser<OllamaChatChunk>(splitLines, (item: string) => {
|
|
370
|
+
const trimmed = item.trim();
|
|
371
|
+
if (!trimmed) return { status: "ignored" };
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(trimmed);
|
|
374
|
+
if (parsed && typeof parsed === "object" && "message" in parsed) {
|
|
375
|
+
return { status: "parsed", value: parsed as OllamaChatChunk };
|
|
376
|
+
}
|
|
377
|
+
return { status: "malformed" };
|
|
378
|
+
} catch {
|
|
379
|
+
return { status: "malformed" };
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
388
383
|
const output: OutputItem[] = [];
|
|
389
|
-
|
|
390
|
-
let buffer = "";
|
|
384
|
+
let streamDone = false;
|
|
391
385
|
|
|
392
386
|
// 累积状态
|
|
393
387
|
let responseId: string | undefined;
|
|
@@ -397,15 +391,76 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
397
391
|
|
|
398
392
|
// tool_calls 累积(于 final chunk 到达)
|
|
399
393
|
let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
|
|
394
|
+
let toolCallIndex = 0;
|
|
395
|
+
const buildResponse = this.buildResponse.bind(this);
|
|
396
|
+
|
|
397
|
+
const emitCompleted = async function* (
|
|
398
|
+
stopReason: import("../index.js").StopReason | undefined,
|
|
399
|
+
rawResponseId: string | undefined,
|
|
400
|
+
): AsyncIterable<AIStreamEvent> {
|
|
401
|
+
if (completedEmitted) {
|
|
402
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
completedEmitted = true;
|
|
407
|
+
|
|
408
|
+
const replay = replayFromOutput(output);
|
|
409
|
+
|
|
410
|
+
if (accumulatedContent || pendingToolCalls.length > 0) {
|
|
411
|
+
replay.push(
|
|
412
|
+
opaqueItem("ollama", "replay", {
|
|
413
|
+
role: "assistant",
|
|
414
|
+
content: accumulatedContent,
|
|
415
|
+
tool_calls: pendingToolCalls.map((tc) => ({
|
|
416
|
+
id: tc.id,
|
|
417
|
+
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
418
|
+
})),
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
424
|
+
for (const event of auxiliaryResult.events) {
|
|
425
|
+
yield event;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const finalResponse = buildResponse(
|
|
429
|
+
request,
|
|
430
|
+
{
|
|
431
|
+
output,
|
|
432
|
+
replay,
|
|
433
|
+
stopReason,
|
|
434
|
+
usage: auxiliaryResult.usage,
|
|
435
|
+
billing: auxiliaryResult.billing,
|
|
436
|
+
auxiliary: auxiliaryResult.auxiliary,
|
|
437
|
+
warnings: auxiliaryResult.warnings,
|
|
438
|
+
metadataSources: auxiliaryResult.metadataSources,
|
|
439
|
+
rawResponseId,
|
|
440
|
+
},
|
|
441
|
+
factory,
|
|
442
|
+
);
|
|
443
|
+
yield factory.responseCompleted({
|
|
444
|
+
replay: finalResponse.replay,
|
|
445
|
+
stopReason: finalResponse.stopReason,
|
|
446
|
+
trace: finalResponse.backend,
|
|
447
|
+
usage: finalResponse.usage,
|
|
448
|
+
billing: finalResponse.billing,
|
|
449
|
+
auxiliary: finalResponse.auxiliary,
|
|
450
|
+
warnings: finalResponse.warnings,
|
|
451
|
+
});
|
|
452
|
+
};
|
|
400
453
|
|
|
401
454
|
try {
|
|
402
455
|
while (true) {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
456
|
+
const readResult = await reader.read().catch((err: unknown) => {
|
|
457
|
+
throw new AIStreamError(
|
|
458
|
+
`Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
|
|
459
|
+
"STREAM_ERROR",
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
const { done, value } = readResult;
|
|
463
|
+
const { items: chunks, malformed: malformedLines } = done ? parser.flush() : parser.feed(value as Uint8Array);
|
|
409
464
|
|
|
410
465
|
const malformedWarning = emitMalformedStreamWarning(factory, {
|
|
411
466
|
count: malformedLines,
|
|
@@ -419,6 +474,13 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
419
474
|
for (const chunk of chunks) {
|
|
420
475
|
responseId = chunk.created_at;
|
|
421
476
|
|
|
477
|
+
if (completedEmitted) {
|
|
478
|
+
if (chunk.done) {
|
|
479
|
+
yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
422
484
|
const msg = chunk.message;
|
|
423
485
|
|
|
424
486
|
// 处理 content delta
|
|
@@ -429,13 +491,13 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
429
491
|
yield factory.messageStarted(currentMessageId);
|
|
430
492
|
}
|
|
431
493
|
accumulatedContent += msg.content;
|
|
432
|
-
yield factory.messageDelta(currentMessageId, msg.content);
|
|
494
|
+
yield factory.messageDelta(currentMessageId, textBlock(msg.content));
|
|
433
495
|
}
|
|
434
496
|
|
|
435
497
|
// 处理 tool_calls (整块到达,在最终 chunk 中)
|
|
436
498
|
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
|
437
499
|
for (const tc of msg.tool_calls) {
|
|
438
|
-
const tcId = `tc-${
|
|
500
|
+
const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
|
|
439
501
|
const argsText = JSON.stringify(tc.function.arguments);
|
|
440
502
|
pendingToolCalls.push({
|
|
441
503
|
id: tcId,
|
|
@@ -458,18 +520,25 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
458
520
|
// 完成消息(如果有累积的内容或正在进行的消息)
|
|
459
521
|
if (hasMessageStarted) {
|
|
460
522
|
const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
|
|
461
|
-
yield factory.messageCompleted(
|
|
523
|
+
yield factory.messageCompleted(currentMessageId);
|
|
462
524
|
if (accumulatedContent) {
|
|
463
525
|
output.push(message);
|
|
464
526
|
}
|
|
465
527
|
}
|
|
466
528
|
|
|
529
|
+
if (pendingToolCalls.length > 0) {
|
|
530
|
+
yield factory.responseWarning(
|
|
531
|
+
`Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
|
|
532
|
+
WarningCode.TOOL_CALL_BATCHED,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
467
536
|
// 发出 tool_call 完成事件
|
|
468
537
|
for (const pending of pendingToolCalls) {
|
|
469
538
|
const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
|
|
470
539
|
yield factory.toolCallStarted(pending.id, pending.name);
|
|
471
540
|
yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
|
|
472
|
-
yield factory.toolCallCompleted(
|
|
541
|
+
yield factory.toolCallCompleted(pending.id);
|
|
473
542
|
output.push(toolCall);
|
|
474
543
|
}
|
|
475
544
|
|
|
@@ -494,44 +563,7 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
494
563
|
// 构建 stop reason
|
|
495
564
|
const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
|
|
496
565
|
|
|
497
|
-
|
|
498
|
-
const replay = replayFromOutput(output);
|
|
499
|
-
|
|
500
|
-
// 附加 opaque replay(若有关联的 assistant 消息)
|
|
501
|
-
if (accumulatedContent || pendingToolCalls.length > 0) {
|
|
502
|
-
replay.push(
|
|
503
|
-
opaqueItem("ollama", "replay", {
|
|
504
|
-
role: "assistant",
|
|
505
|
-
content: accumulatedContent,
|
|
506
|
-
tool_calls: pendingToolCalls.map((tc) => ({
|
|
507
|
-
function: { name: tc.name, arguments: tc.argumentsJson },
|
|
508
|
-
})),
|
|
509
|
-
}),
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
514
|
-
for (const event of auxiliaryResult.events) {
|
|
515
|
-
yield event;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
yield factory.responseCompleted(
|
|
519
|
-
this.buildResponse(
|
|
520
|
-
request,
|
|
521
|
-
{
|
|
522
|
-
output,
|
|
523
|
-
replay,
|
|
524
|
-
stopReason,
|
|
525
|
-
usage: auxiliaryResult.usage,
|
|
526
|
-
billing: auxiliaryResult.billing,
|
|
527
|
-
auxiliary: auxiliaryResult.auxiliary,
|
|
528
|
-
warnings: auxiliaryResult.warnings,
|
|
529
|
-
metadataSources: auxiliaryResult.metadataSources,
|
|
530
|
-
rawResponseId: chunk.created_at,
|
|
531
|
-
},
|
|
532
|
-
factory,
|
|
533
|
-
),
|
|
534
|
-
);
|
|
566
|
+
yield* emitCompleted(stopReason, chunk.created_at);
|
|
535
567
|
|
|
536
568
|
// 重置累积状态
|
|
537
569
|
accumulatedContent = "";
|
|
@@ -540,56 +572,52 @@ export class OllamaAdapter extends AdapterBase {
|
|
|
540
572
|
pendingToolCalls = [];
|
|
541
573
|
}
|
|
542
574
|
}
|
|
575
|
+
|
|
576
|
+
if (done) {
|
|
577
|
+
streamDone = true;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
543
580
|
}
|
|
544
581
|
} finally {
|
|
545
|
-
|
|
582
|
+
try {
|
|
583
|
+
if (!streamDone) await reader.cancel().catch(() => undefined);
|
|
584
|
+
} finally {
|
|
585
|
+
reader.releaseLock();
|
|
586
|
+
}
|
|
546
587
|
}
|
|
547
588
|
|
|
548
|
-
if (
|
|
589
|
+
if (parser.getRemaining().trim().length > 0) {
|
|
549
590
|
yield factory.responseWarning("Stream ended with an incomplete Ollama NDJSON line", "STREAM_ERROR");
|
|
550
591
|
}
|
|
551
592
|
|
|
552
593
|
// 流结束但无 done=true(断流保护)
|
|
553
|
-
if (hasMessageStarted || pendingToolCalls.length > 0) {
|
|
594
|
+
if (!completedEmitted && (hasMessageStarted || pendingToolCalls.length > 0)) {
|
|
554
595
|
yield factory.responseWarning("Stream ended without a done signal", "INCOMPLETE_STREAM");
|
|
555
596
|
|
|
556
597
|
if (hasMessageStarted) {
|
|
557
598
|
const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
|
|
558
|
-
yield factory.messageCompleted(
|
|
599
|
+
yield factory.messageCompleted(currentMessageId);
|
|
559
600
|
if (accumulatedContent) {
|
|
560
601
|
output.push(message);
|
|
561
602
|
}
|
|
562
603
|
}
|
|
563
604
|
|
|
605
|
+
if (pendingToolCalls.length > 0) {
|
|
606
|
+
yield factory.responseWarning(
|
|
607
|
+
`Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
|
|
608
|
+
WarningCode.TOOL_CALL_BATCHED,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
564
612
|
for (const pending of pendingToolCalls) {
|
|
565
613
|
const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
|
|
566
614
|
yield factory.toolCallStarted(pending.id, pending.name);
|
|
567
615
|
yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
|
|
568
|
-
yield factory.toolCallCompleted(
|
|
616
|
+
yield factory.toolCallCompleted(pending.id);
|
|
569
617
|
output.push(toolCall);
|
|
570
618
|
}
|
|
571
619
|
|
|
572
|
-
|
|
573
|
-
const auxiliaryResult = await auxiliary.finalize(factory);
|
|
574
|
-
for (const event of auxiliaryResult.events) {
|
|
575
|
-
yield event;
|
|
576
|
-
}
|
|
577
|
-
yield factory.responseCompleted(
|
|
578
|
-
this.buildResponse(
|
|
579
|
-
request,
|
|
580
|
-
{
|
|
581
|
-
output,
|
|
582
|
-
replay,
|
|
583
|
-
usage: auxiliaryResult.usage,
|
|
584
|
-
billing: auxiliaryResult.billing,
|
|
585
|
-
auxiliary: auxiliaryResult.auxiliary,
|
|
586
|
-
warnings: auxiliaryResult.warnings,
|
|
587
|
-
metadataSources: auxiliaryResult.metadataSources,
|
|
588
|
-
rawResponseId: responseId,
|
|
589
|
-
},
|
|
590
|
-
factory,
|
|
591
|
-
),
|
|
592
|
-
);
|
|
620
|
+
yield* emitCompleted(undefined, responseId);
|
|
593
621
|
}
|
|
594
622
|
}
|
|
595
623
|
}
|