@codehz/ai 0.3.0 → 0.4.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.
@@ -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,12 +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";
31
+ import {
32
+ NormalizedRequestMapper,
33
+ createNdjsonLineParser,
34
+ openProviderJsonStream,
35
+ iterateProviderStreamBatches,
36
+ createCompletionGate,
37
+ } from "../helpers/index.js";
33
38
 
34
- import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
39
+ import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn, StopReason } from "../index.js";
35
40
 
36
41
  // ── 选项类型 ──────────────────────────────────────────────────
37
42
 
@@ -172,7 +177,7 @@ export class OllamaAdapter extends AdapterBase {
172
177
  const role = item.role;
173
178
  messages.push({
174
179
  role,
175
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `input message (${item.role}) content`)),
180
+ content: mapper.textFromBlocks(item.content, `input message (${item.role}) content`),
176
181
  });
177
182
  break;
178
183
  }
@@ -203,7 +208,7 @@ export class OllamaAdapter extends AdapterBase {
203
208
  }
204
209
  messages.push({
205
210
  role: "tool",
206
- content: contentBlocksToText(mapper.ensureTextBlocks(item.content, `tool_result ${item.callId} content`)),
211
+ content: mapper.textFromBlocks(item.content, `tool_result ${item.callId} content`),
207
212
  });
208
213
  break;
209
214
  }
@@ -297,7 +302,8 @@ export class OllamaAdapter extends AdapterBase {
297
302
  request: NormalizedRequest,
298
303
  ): AsyncIterable<AIStreamEvent> {
299
304
  const auxiliary = this.createAuxiliaryState(request);
300
- let completedEmitted = false;
305
+ const gate = createCompletionGate();
306
+
301
307
  if (request.toolChoice && request.toolChoice !== "auto") {
302
308
  yield factory.responseWarning(
303
309
  request.toolChoice === "none"
@@ -317,70 +323,37 @@ export class OllamaAdapter extends AdapterBase {
317
323
  headers.Authorization = `Bearer ${this.apiKey}`;
318
324
  }
319
325
 
320
- let response: Response;
321
-
322
- try {
323
- response = await this.fetchFn(`${this.baseUrl}/api/chat`, {
324
- method: "POST",
325
- headers,
326
- body: JSON.stringify(providerRequest),
327
- signal: request.signal,
328
- });
329
- } catch (err) {
330
- throw new AIProviderError(err instanceof Error ? err.message : String(err), "PROVIDER_ERROR");
331
- }
332
-
333
- if (!response.ok) {
334
- const errorBody = await response.text().catch(() => "");
335
- throw providerHttpError(response.status, errorBody);
336
- }
337
-
338
- const reader = response.body?.getReader();
339
- if (!reader) {
340
- throw new AIStreamError("Response body is not readable", "STREAM_ERROR");
341
- }
342
-
343
- const parser = new IncrementalStreamParser<OllamaChatChunk>(splitLines, (item: string) => {
344
- const trimmed = item.trim();
345
- if (!trimmed) return { status: "ignored" };
346
- try {
347
- const parsed = JSON.parse(trimmed);
348
- if (parsed && typeof parsed === "object" && "message" in parsed) {
349
- return { status: "parsed", value: parsed as OllamaChatChunk };
350
- }
351
- return { status: "malformed" };
352
- } catch {
353
- return { status: "malformed" };
354
- }
326
+ const { reader } = await openProviderJsonStream({
327
+ fetchFn: this.fetchFn,
328
+ url: `${this.baseUrl}/api/chat`,
329
+ headers,
330
+ body: providerRequest,
331
+ signal: request.signal,
355
332
  });
356
333
 
357
- const output: OutputItem[] = [];
358
- let streamDone = false;
334
+ const parser = createNdjsonLineParser<OllamaChatChunk>(
335
+ (value): value is OllamaChatChunk => !!value && typeof value === "object" && "message" in value,
336
+ );
359
337
 
360
- // 累积状态
338
+ const output: OutputItem[] = [];
361
339
  let responseId: string | undefined;
362
340
  let accumulatedContent = "";
363
341
  let currentMessageId = "";
364
342
  let hasMessageStarted = false;
365
-
366
- // tool_calls 累积(于 final chunk 到达)
367
343
  let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string }> = [];
368
344
  let toolCallIndex = 0;
369
- const buildResponse = this.buildResponse.bind(this);
370
345
 
371
346
  const emitCompleted = async function* (
372
- stopReason: import("../index.js").StopReason | undefined,
347
+ this: OllamaAdapter,
348
+ stopReason: StopReason | undefined,
373
349
  rawResponseId: string | undefined,
374
350
  ): AsyncIterable<AIStreamEvent> {
375
- if (completedEmitted) {
351
+ if (!gate.tryComplete()) {
376
352
  yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
377
353
  return;
378
354
  }
379
355
 
380
- completedEmitted = true;
381
-
382
356
  const replay = replayFromOutput(output);
383
-
384
357
  if (accumulatedContent || pendingToolCalls.length > 0) {
385
358
  replay.push(
386
359
  opaqueItem("ollama", "replay", {
@@ -394,178 +367,118 @@ export class OllamaAdapter extends AdapterBase {
394
367
  );
395
368
  }
396
369
 
397
- const auxiliaryResult = await auxiliary.finalize(factory);
398
- for (const event of auxiliaryResult.events) {
399
- yield event;
400
- }
401
-
402
- const finalResponse = buildResponse(
403
- request,
404
- {
405
- output,
406
- replay,
407
- stopReason,
408
- usage: auxiliaryResult.usage,
409
- billing: auxiliaryResult.billing,
410
- auxiliary: auxiliaryResult.auxiliary,
411
- warnings: auxiliaryResult.warnings,
412
- metadataSources: auxiliaryResult.metadataSources,
413
- rawResponseId,
414
- },
415
- factory,
416
- );
417
- yield factory.responseCompleted({
418
- replay: finalResponse.replay,
419
- stopReason: finalResponse.stopReason,
420
- trace: finalResponse.backend,
421
- usage: finalResponse.usage,
422
- billing: finalResponse.billing,
423
- auxiliary: finalResponse.auxiliary,
424
- warnings: finalResponse.warnings,
370
+ yield* this.emitStreamCompleted(factory, request, auxiliary, {
371
+ output,
372
+ replay,
373
+ stopReason,
374
+ rawResponseId,
425
375
  });
426
- };
427
-
428
- try {
429
- while (true) {
430
- const readResult = await reader.read().catch((err: unknown) => {
431
- throw new AIStreamError(
432
- `Failed to read response stream: ${err instanceof Error ? err.message : String(err)}`,
433
- "STREAM_ERROR",
434
- );
435
- });
436
- const { done, value } = readResult;
437
- const { items: chunks, malformed: malformedLines } = done ? parser.flush() : parser.feed(value as Uint8Array);
438
-
439
- const malformedWarning = emitMalformedStreamWarning(factory, {
440
- count: malformedLines,
441
- providerLabel: "Ollama",
442
- transportLabel: "NDJSON line(s)",
443
- });
444
- if (malformedWarning) {
445
- 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;
446
396
  }
447
397
 
448
- for (const chunk of chunks) {
449
- responseId = chunk.created_at;
398
+ const msg = chunk.message;
450
399
 
451
- if (completedEmitted) {
452
- if (chunk.done) {
453
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
454
- }
455
- continue;
400
+ if (msg.content) {
401
+ if (!hasMessageStarted) {
402
+ currentMessageId = `msg-${chunk.created_at}`;
403
+ hasMessageStarted = true;
404
+ yield factory.messageStarted(currentMessageId);
456
405
  }
406
+ accumulatedContent += msg.content;
407
+ yield factory.messageDelta(currentMessageId, textBlock(msg.content));
408
+ }
457
409
 
458
- const msg = chunk.message;
459
-
460
- // 处理 content delta
461
- if (msg.content) {
462
- if (!hasMessageStarted) {
463
- currentMessageId = `msg-${chunk.created_at}`;
464
- hasMessageStarted = true;
465
- yield factory.messageStarted(currentMessageId);
466
- }
467
- accumulatedContent += msg.content;
468
- 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
+ });
469
419
  }
420
+ }
470
421
 
471
- // 处理 tool_calls (整块到达,在最终 chunk 中)
472
- if (msg.tool_calls && msg.tool_calls.length > 0) {
473
- for (const tc of msg.tool_calls) {
474
- const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
475
- const argsText = JSON.stringify(tc.function.arguments);
476
- pendingToolCalls.push({
477
- id: tcId,
478
- name: tc.function.name,
479
- argumentsText: argsText,
480
- });
481
- }
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);
482
427
  }
483
428
 
484
- // 处理 done_reason (final chunk)
485
- if (chunk.done) {
486
- // 如果有未开始的 message 但没内容,发一个空消息启动
487
- if (accumulatedContent === "" && pendingToolCalls.length > 0 && !hasMessageStarted) {
488
- currentMessageId = `msg-${chunk.created_at}`;
489
- hasMessageStarted = true;
490
- yield factory.messageStarted(currentMessageId);
491
- }
492
-
493
- // 完成消息(如果有累积的内容或正在进行的消息)
494
- if (hasMessageStarted) {
495
- const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
496
- yield factory.messageCompleted(currentMessageId);
497
- if (accumulatedContent) {
498
- output.push(message);
499
- }
500
- }
501
-
502
- if (pendingToolCalls.length > 0) {
503
- yield factory.responseWarning(
504
- `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
505
- WarningCode.TOOL_CALL_BATCHED,
506
- );
507
- }
508
-
509
- // 发出 tool_call 完成事件
510
- for (const pending of pendingToolCalls) {
511
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
512
- yield factory.toolCallStarted(pending.id, pending.name);
513
- yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
514
- yield factory.toolCallCompleted(pending.id);
515
- output.push(toolCall);
516
- }
517
-
518
- // 提取 usage
519
- if (
520
- request.include?.usage !== "off" &&
521
- (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined)
522
- ) {
523
- auxiliary.recordUsage(
524
- usageFromOllama({
525
- prompt_eval_count: chunk.prompt_eval_count,
526
- eval_count: chunk.eval_count,
527
- }),
528
- "final",
529
- {
530
- prompt_eval_count: chunk.prompt_eval_count,
531
- eval_count: chunk.eval_count,
532
- },
533
- );
429
+ if (hasMessageStarted) {
430
+ const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
431
+ yield factory.messageCompleted(currentMessageId);
432
+ if (accumulatedContent) {
433
+ output.push(message);
534
434
  }
435
+ }
535
436
 
536
- // 构建 stop reason
537
- 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
+ }
538
443
 
539
- 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
+ }
540
451
 
541
- // 重置累积状态
542
- accumulatedContent = "";
543
- currentMessageId = "";
544
- hasMessageStarted = false;
545
- 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
+ );
546
467
  }
547
- }
548
468
 
549
- if (done) {
550
- streamDone = true;
551
- 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 = [];
552
476
  }
553
477
  }
554
- } finally {
555
- try {
556
- if (!streamDone) await reader.cancel().catch(() => undefined);
557
- } finally {
558
- reader.releaseLock();
559
- }
560
- }
561
-
562
- if (parser.getRemaining().trim().length > 0) {
563
- yield factory.responseWarning("Stream ended with an incomplete Ollama NDJSON line", "STREAM_ERROR");
564
478
  }
565
479
 
566
- // 流结束但无 done=true(断流保护)
567
- if (!completedEmitted && (hasMessageStarted || pendingToolCalls.length > 0)) {
568
- yield factory.responseWarning("Stream ended without a done signal", "INCOMPLETE_STREAM");
480
+ if (!gate.completed && (hasMessageStarted || pendingToolCalls.length > 0)) {
481
+ yield factory.responseWarning("Stream ended without a done signal", WarningCode.STREAM_INCOMPLETE);
569
482
 
570
483
  if (hasMessageStarted) {
571
484
  const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });