@codehz/ai 0.2.4 → 0.4.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.
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { AdapterBase } from "../helpers/adapter-base.js";
19
- import { AIProviderError, AIRequestError, AIStreamError, WarningCode } from "../core/errors.js";
19
+ import { AIRequestError, WarningCode } from "../core/errors.js";
20
20
  import {
21
21
  textBlock,
22
22
  messageItem,
@@ -26,13 +26,17 @@ import {
26
26
  mapStopReason,
27
27
  contentBlocksToText,
28
28
  } from "../helpers/mapping.js";
29
- import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
30
- import { assertOpaqueReplayEnvelope, providerHttpError } from "../helpers/adapter-security.js";
29
+ import { assertOpaqueReplayEnvelope } from "../helpers/adapter-security.js";
31
30
  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
+ import {
32
+ NormalizedRequestMapper,
33
+ createNdjsonLineParser,
34
+ openProviderJsonStream,
35
+ iterateProviderStreamBatches,
36
+ createCompletionGate,
37
+ } from "../helpers/index.js";
34
38
 
35
- import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
39
+ import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn, StopReason } from "../index.js";
36
40
 
37
41
  // ── 选项类型 ──────────────────────────────────────────────────
38
42
 
@@ -82,44 +86,7 @@ type OllamaTool = {
82
86
  };
83
87
  };
84
88
 
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
- };
101
-
102
- const mapper = new NormalizedRequestMapper(profile);
103
-
104
- function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Record<string, unknown> {
105
- if (item.argumentsJson && typeof item.argumentsJson === "object" && item.argumentsJson !== null) {
106
- return item.argumentsJson as Record<string, unknown>;
107
- }
108
-
109
- try {
110
- const parsed = JSON.parse(item.argumentsText);
111
- if (parsed && typeof parsed === "object") {
112
- return parsed as Record<string, unknown>;
113
- }
114
- } catch {
115
- // fall through
116
- }
117
-
118
- throw new AIRequestError(
119
- "ollama tool_call argumentsText must be valid JSON object when argumentsJson is absent",
120
- "TOOL_CALL_ARGUMENTS_INVALID",
121
- );
122
- }
89
+ const mapper = new NormalizedRequestMapper("ollama");
123
90
 
124
91
  // ── Ollama 流式 chunk ─────────────────────────────────────────
125
92
 
@@ -179,7 +146,7 @@ function toWireOllamaToolCalls(toolCalls: OllamaReplayToolCall[]): OllamaToolCal
179
146
 
180
147
  export class OllamaAdapter extends AdapterBase {
181
148
  readonly kind = "ollama" as const;
182
- readonly capabilities = profile.capabilities;
149
+ readonly isSyntheticStream = false;
183
150
 
184
151
  private baseUrl: string;
185
152
  private apiKey: string | undefined;
@@ -195,10 +162,6 @@ export class OllamaAdapter extends AdapterBase {
195
162
  // ── buildRequest ──────────────────────────────────────────
196
163
 
197
164
  protected buildRequest(request: NormalizedRequest): OllamaChatRequest {
198
- if (request.toolChoice && request.toolChoice !== "auto") {
199
- throw new AIRequestError("ollama does not support explicit toolChoice", "UNSUPPORTED_TOOL_CHOICE");
200
- }
201
-
202
165
  const messages: OllamaMessage[] = [];
203
166
  /** Local-only name → call id queue for best-effort tool_result association (not sent to Ollama). */
204
167
  const callIdsByName = new Map<string, string[]>();
@@ -214,7 +177,7 @@ export class OllamaAdapter extends AdapterBase {
214
177
  const role = item.role;
215
178
  messages.push({
216
179
  role,
217
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`)),
180
+ content: mapper.textFromBlocks(item.content, `input message (${item.role}) content`),
218
181
  });
219
182
  break;
220
183
  }
@@ -224,7 +187,7 @@ export class OllamaAdapter extends AdapterBase {
224
187
  const tc: OllamaToolCall = {
225
188
  function: {
226
189
  name: item.name,
227
- arguments: parseOllamaToolArguments(item),
190
+ arguments: mapper.parseToolArguments(item),
228
191
  },
229
192
  };
230
193
  const queue = callIdsByName.get(item.name) ?? [];
@@ -238,7 +201,6 @@ export class OllamaAdapter extends AdapterBase {
238
201
  break;
239
202
  }
240
203
  case "tool_result": {
241
- mapper.assertToolResultOutcome(item.outcome);
242
204
  // Best-effort: consume matching id from name queue when present (no wire call_id)
243
205
  const queue = callIdsByName.get(item.toolName);
244
206
  if (queue && queue.length > 0) {
@@ -246,7 +208,7 @@ export class OllamaAdapter extends AdapterBase {
246
208
  }
247
209
  messages.push({
248
210
  role: "tool",
249
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
211
+ content: mapper.textFromBlocks(item.content, `tool_result ${item.callId} content`),
250
212
  });
251
213
  break;
252
214
  }
@@ -302,8 +264,16 @@ export class OllamaAdapter extends AdapterBase {
302
264
  stream: true,
303
265
  };
304
266
 
305
- if (request.tools && request.tools.length > 0) {
306
- body.tools = request.tools.map(
267
+ const toolChoice = request.toolChoice;
268
+ const selectedTools =
269
+ toolChoice === "none"
270
+ ? []
271
+ : toolChoice && typeof toolChoice === "object"
272
+ ? request.tools?.filter((tool) => tool.name === toolChoice.name)
273
+ : request.tools;
274
+
275
+ if (selectedTools && selectedTools.length > 0) {
276
+ body.tools = selectedTools.map(
307
277
  (t): OllamaTool => ({
308
278
  type: "function",
309
279
  function: {
@@ -332,7 +302,16 @@ export class OllamaAdapter extends AdapterBase {
332
302
  request: NormalizedRequest,
333
303
  ): AsyncIterable<AIStreamEvent> {
334
304
  const auxiliary = this.createAuxiliaryState(request);
335
- let completedEmitted = false;
305
+ const gate = createCompletionGate();
306
+
307
+ if (request.toolChoice && request.toolChoice !== "auto") {
308
+ yield factory.responseWarning(
309
+ request.toolChoice === "none"
310
+ ? "Ollama toolChoice none was mapped by omitting tools"
311
+ : `Ollama cannot force tool choice; only tool "${request.toolChoice.name}" was provided as a best-effort constraint`,
312
+ WarningCode.CAPABILITY_DOWNGRADE,
313
+ );
314
+ }
336
315
  if (request.metadata) {
337
316
  yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
338
317
  }
@@ -344,70 +323,37 @@ export class OllamaAdapter extends AdapterBase {
344
323
  headers.Authorization = `Bearer ${this.apiKey}`;
345
324
  }
346
325
 
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
- signal: request.signal,
355
- });
356
- } catch (err) {
357
- throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
358
- }
359
-
360
- if (!response.ok) {
361
- const errorBody = await response.text().catch(() => "");
362
- throw providerHttpError(response.status, errorBody);
363
- }
364
-
365
- const reader = response.body?.getReader();
366
- if (!reader) {
367
- throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
368
- }
369
-
370
- const parser = new IncrementalStreamParser<OllamaChatChunk>(splitLines, (item: string) => {
371
- const trimmed = item.trim();
372
- if (!trimmed) return { status: "ignored" };
373
- try {
374
- const parsed = JSON.parse(trimmed);
375
- if (parsed && typeof parsed === "object" && "message" in parsed) {
376
- return { status: "parsed", value: parsed as OllamaChatChunk };
377
- }
378
- return { status: "malformed" };
379
- } catch {
380
- return { status: "malformed" };
381
- }
326
+ const { reader } = await openProviderJsonStream({
327
+ fetchFn: this.fetchFn,
328
+ url: `${this.baseUrl}/api/chat`,
329
+ headers,
330
+ body: providerRequest,
331
+ signal: request.signal,
382
332
  });
383
333
 
384
- const output: OutputItem[] = [];
385
- let streamDone = false;
334
+ const parser = createNdjsonLineParser<OllamaChatChunk>(
335
+ (value): value is OllamaChatChunk => !!value && typeof value === "object" && "message" in value,
336
+ );
386
337
 
387
- // 累积状态
338
+ const output: OutputItem[] = [];
388
339
  let responseId: string | undefined;
389
340
  let accumulatedContent = "";
390
341
  let currentMessageId = "";
391
342
  let hasMessageStarted = false;
392
-
393
- // tool_calls 累积(于 final chunk 到达)
394
- let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
343
+ let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string }> = [];
395
344
  let toolCallIndex = 0;
396
- const buildResponse = this.buildResponse.bind(this);
397
345
 
398
346
  const emitCompleted = async function* (
399
- stopReason: import("../index.js").StopReason | undefined,
347
+ this: OllamaAdapter,
348
+ stopReason: StopReason | undefined,
400
349
  rawResponseId: string | undefined,
401
350
  ): AsyncIterable<AIStreamEvent> {
402
- if (completedEmitted) {
351
+ if (!gate.tryComplete()) {
403
352
  yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
404
353
  return;
405
354
  }
406
355
 
407
- completedEmitted = true;
408
-
409
356
  const replay = replayFromOutput(output);
410
-
411
357
  if (accumulatedContent || pendingToolCalls.length > 0) {
412
358
  replay.push(
413
359
  opaqueItem("ollama", "replay", {
@@ -415,184 +361,123 @@ export class OllamaAdapter extends AdapterBase {
415
361
  content: accumulatedContent,
416
362
  tool_calls: pendingToolCalls.map((tc) => ({
417
363
  id: tc.id,
418
- function: { name: tc.name, arguments: tc.argumentsJson },
364
+ function: { name: tc.name, arguments: JSON.parse(tc.argumentsText) as Record<string, unknown> },
419
365
  })),
420
366
  }),
421
367
  );
422
368
  }
423
369
 
424
- const auxiliaryResult = await auxiliary.finalize(factory);
425
- for (const event of auxiliaryResult.events) {
426
- yield event;
427
- }
428
-
429
- const finalResponse = buildResponse(
430
- request,
431
- {
432
- output,
433
- replay,
434
- stopReason,
435
- usage: auxiliaryResult.usage,
436
- billing: auxiliaryResult.billing,
437
- auxiliary: auxiliaryResult.auxiliary,
438
- warnings: auxiliaryResult.warnings,
439
- metadataSources: auxiliaryResult.metadataSources,
440
- rawResponseId,
441
- },
442
- factory,
443
- );
444
- yield factory.responseCompleted({
445
- replay: finalResponse.replay,
446
- stopReason: finalResponse.stopReason,
447
- trace: finalResponse.backend,
448
- usage: finalResponse.usage,
449
- billing: finalResponse.billing,
450
- auxiliary: finalResponse.auxiliary,
451
- warnings: finalResponse.warnings,
370
+ yield* this.emitStreamCompleted(factory, request, auxiliary, {
371
+ output,
372
+ replay,
373
+ stopReason,
374
+ rawResponseId,
452
375
  });
453
- };
454
-
455
- try {
456
- while (true) {
457
- const readResult = await reader.read().catch((err: unknown) => {
458
- throw new AIStreamError(
459
- `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
460
- "STREAM_ERROR",
461
- );
462
- });
463
- const { done, value } = readResult;
464
- const { items: chunks, malformed: malformedLines } = done ? parser.flush() : parser.feed(value as Uint8Array);
465
-
466
- const malformedWarning = emitMalformedStreamWarning(factory, {
467
- count: malformedLines,
468
- providerLabel: "Ollama",
469
- transportLabel: "NDJSON line(s)",
470
- });
471
- if (malformedWarning) {
472
- yield malformedWarning;
376
+ }.bind(this);
377
+
378
+ for await (const batch of iterateProviderStreamBatches({
379
+ reader,
380
+ parser,
381
+ factory,
382
+ providerLabel: "Ollama",
383
+ transportLabel: "NDJSON line(s)",
384
+ incompleteMessage: "Stream ended with an incomplete Ollama NDJSON line",
385
+ })) {
386
+ for (const warning of batch.warnings) yield warning;
387
+
388
+ for (const chunk of batch.items) {
389
+ responseId = chunk.created_at;
390
+
391
+ if (gate.completed) {
392
+ if (chunk.done) {
393
+ yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
394
+ }
395
+ continue;
473
396
  }
474
397
 
475
- for (const chunk of chunks) {
476
- responseId = chunk.created_at;
398
+ const msg = chunk.message;
477
399
 
478
- if (completedEmitted) {
479
- if (chunk.done) {
480
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
481
- }
482
- continue;
400
+ if (msg.content) {
401
+ if (!hasMessageStarted) {
402
+ currentMessageId = `msg-${chunk.created_at}`;
403
+ hasMessageStarted = true;
404
+ yield factory.messageStarted(currentMessageId);
483
405
  }
406
+ accumulatedContent += msg.content;
407
+ yield factory.messageDelta(currentMessageId, textBlock(msg.content));
408
+ }
484
409
 
485
- const msg = chunk.message;
486
-
487
- // 处理 content delta
488
- if (msg.content) {
489
- if (!hasMessageStarted) {
490
- currentMessageId = `msg-${chunk.created_at}`;
491
- hasMessageStarted = true;
492
- yield factory.messageStarted(currentMessageId);
493
- }
494
- accumulatedContent += msg.content;
495
- yield factory.messageDelta(currentMessageId, textBlock(msg.content));
410
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
411
+ for (const tc of msg.tool_calls) {
412
+ const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
413
+ const argsText = JSON.stringify(tc.function.arguments);
414
+ pendingToolCalls.push({
415
+ id: tcId,
416
+ name: tc.function.name,
417
+ argumentsText: argsText,
418
+ });
496
419
  }
420
+ }
497
421
 
498
- // 处理 tool_calls (整块到达,在最终 chunk 中)
499
- if (msg.tool_calls && msg.tool_calls.length > 0) {
500
- for (const tc of msg.tool_calls) {
501
- const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
502
- const argsText = JSON.stringify(tc.function.arguments);
503
- pendingToolCalls.push({
504
- id: tcId,
505
- name: tc.function.name,
506
- argumentsText: argsText,
507
- argumentsJson: tc.function.arguments,
508
- });
509
- }
422
+ if (chunk.done) {
423
+ if (accumulatedContent === "" && pendingToolCalls.length > 0 && !hasMessageStarted) {
424
+ currentMessageId = `msg-${chunk.created_at}`;
425
+ hasMessageStarted = true;
426
+ yield factory.messageStarted(currentMessageId);
510
427
  }
511
428
 
512
- // 处理 done_reason (final chunk)
513
- if (chunk.done) {
514
- // 如果有未开始的 message 但没内容,发一个空消息启动
515
- if (accumulatedContent === "" && pendingToolCalls.length > 0 && !hasMessageStarted) {
516
- currentMessageId = `msg-${chunk.created_at}`;
517
- hasMessageStarted = true;
518
- yield factory.messageStarted(currentMessageId);
519
- }
520
-
521
- // 完成消息(如果有累积的内容或正在进行的消息)
522
- if (hasMessageStarted) {
523
- const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
524
- yield factory.messageCompleted(currentMessageId);
525
- if (accumulatedContent) {
526
- output.push(message);
527
- }
528
- }
529
-
530
- if (pendingToolCalls.length > 0) {
531
- yield factory.responseWarning(
532
- `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
533
- WarningCode.TOOL_CALL_BATCHED,
534
- );
535
- }
536
-
537
- // 发出 tool_call 完成事件
538
- for (const pending of pendingToolCalls) {
539
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
540
- yield factory.toolCallStarted(pending.id, pending.name);
541
- yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
542
- yield factory.toolCallCompleted(pending.id);
543
- output.push(toolCall);
544
- }
545
-
546
- // 提取 usage
547
- if (
548
- request.include?.usage !== "off" &&
549
- (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined)
550
- ) {
551
- auxiliary.recordUsage(
552
- usageFromOllama({
553
- prompt_eval_count: chunk.prompt_eval_count,
554
- eval_count: chunk.eval_count,
555
- }),
556
- "final",
557
- {
558
- prompt_eval_count: chunk.prompt_eval_count,
559
- eval_count: chunk.eval_count,
560
- },
561
- );
429
+ if (hasMessageStarted) {
430
+ const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
431
+ yield factory.messageCompleted(currentMessageId);
432
+ if (accumulatedContent) {
433
+ output.push(message);
562
434
  }
435
+ }
563
436
 
564
- // 构建 stop reason
565
- const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
437
+ if (pendingToolCalls.length > 0) {
438
+ yield factory.responseWarning(
439
+ `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
440
+ WarningCode.TOOL_CALL_BATCHED,
441
+ );
442
+ }
566
443
 
567
- yield* emitCompleted(stopReason, chunk.created_at);
444
+ for (const pending of pendingToolCalls) {
445
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
446
+ yield factory.toolCallStarted(pending.id, pending.name);
447
+ yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
448
+ yield factory.toolCallCompleted(pending.id);
449
+ output.push(toolCall);
450
+ }
568
451
 
569
- // 重置累积状态
570
- accumulatedContent = "";
571
- currentMessageId = "";
572
- hasMessageStarted = false;
573
- pendingToolCalls = [];
452
+ if (
453
+ request.include?.usage !== "off" &&
454
+ (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined)
455
+ ) {
456
+ auxiliary.recordUsage(
457
+ usageFromOllama({
458
+ prompt_eval_count: chunk.prompt_eval_count,
459
+ eval_count: chunk.eval_count,
460
+ }),
461
+ "final",
462
+ {
463
+ prompt_eval_count: chunk.prompt_eval_count,
464
+ eval_count: chunk.eval_count,
465
+ },
466
+ );
574
467
  }
575
- }
576
468
 
577
- if (done) {
578
- streamDone = true;
579
- break;
469
+ const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
470
+ yield* emitCompleted(stopReason, chunk.created_at);
471
+
472
+ accumulatedContent = "";
473
+ currentMessageId = "";
474
+ hasMessageStarted = false;
475
+ pendingToolCalls = [];
580
476
  }
581
477
  }
582
- } finally {
583
- try {
584
- if (!streamDone) await reader.cancel().catch(() => undefined);
585
- } finally {
586
- reader.releaseLock();
587
- }
588
- }
589
-
590
- if (parser.getRemaining().trim().length > 0) {
591
- yield factory.responseWarning("Stream ended with an incomplete Ollama NDJSON line", "STREAM_ERROR");
592
478
  }
593
479
 
594
- // 流结束但无 done=true(断流保护)
595
- if (!completedEmitted && (hasMessageStarted || pendingToolCalls.length > 0)) {
480
+ if (!gate.completed && (hasMessageStarted || pendingToolCalls.length > 0)) {
596
481
  yield factory.responseWarning("Stream ended without a done signal", "INCOMPLETE_STREAM");
597
482
 
598
483
  if (hasMessageStarted) {
@@ -611,7 +496,7 @@ export class OllamaAdapter extends AdapterBase {
611
496
  }
612
497
 
613
498
  for (const pending of pendingToolCalls) {
614
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
499
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
615
500
  yield factory.toolCallStarted(pending.id, pending.name);
616
501
  yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
617
502
  yield factory.toolCallCompleted(pending.id);