@codehz/ai 0.1.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.
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Ollama Adapter
3
+ *
4
+ * 接入 Ollama 原生 Chat API (/api/chat)。
5
+ * 与 Chat Completions 兼容层不同,此处直接使用 Ollama 的 NDJSON 流格式。
6
+ *
7
+ * 能力:
8
+ * - 消息流(完整 content 逐块到达)
9
+ * - 工具调用(整块到达,非逐 token)
10
+ * - 用量信息(仅 prompt_eval_count / eval_count)
11
+ *
12
+ * 限制:
13
+ * - 不流式输出 reasoning(Ollama 原生 API 无独立思考字段)
14
+ * - tool_call 不支持逐 token 流式
15
+ * - replay 保真度低(无 opaque continuation 机制)
16
+ */
17
+
18
+ import { AdapterBase } from "../helpers/adapter-base.js";
19
+ import { AIRequestError } from "../core/errors.js";
20
+ import {
21
+ textBlock,
22
+ messageItem,
23
+ toolCallItem,
24
+ opaqueItem,
25
+ replayFromOutput,
26
+ mapStopReason,
27
+ contentBlocksToText,
28
+ } from "../helpers/mapping.js";
29
+ import { emitMalformedStreamWarning } from "../helpers/adapter-auxiliary.js";
30
+
31
+ import type { AdapterCapabilities, NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn } from "../index.js";
32
+
33
+ // ── 选项类型 ──────────────────────────────────────────────────
34
+
35
+ export type OllamaAdapterOptions = {
36
+ /** Ollama 服务地址,默认 http://localhost:11434 */
37
+ baseUrl?: string;
38
+ /** 可选 API key(用于需要认证的代理场景) */
39
+ apiKey?: string;
40
+ /** 可注入自定义 fetch 实现 */
41
+ fetch?: FetchFn;
42
+ };
43
+
44
+ // ── Ollama Chat API 类型 ──────────────────────────────────────
45
+
46
+ type OllamaChatRequest = {
47
+ model: string;
48
+ messages: OllamaMessage[];
49
+ stream: true;
50
+ tools?: OllamaTool[];
51
+ options?: {
52
+ temperature?: number;
53
+ num_predict?: number;
54
+ [key: string]: unknown;
55
+ };
56
+ };
57
+
58
+ type OllamaMessage = {
59
+ role: "system" | "user" | "assistant" | "tool";
60
+ content: string;
61
+ images?: string[];
62
+ tool_calls?: OllamaToolCall[];
63
+ };
64
+
65
+ type OllamaToolCall = {
66
+ function: {
67
+ name: string;
68
+ arguments: Record<string, unknown>;
69
+ };
70
+ };
71
+
72
+ type OllamaTool = {
73
+ type: "function";
74
+ function: {
75
+ name: string;
76
+ description?: string;
77
+ parameters: Record<string, unknown>;
78
+ };
79
+ };
80
+
81
+ function ensureOllamaTextBlocks(
82
+ blocks: import("../index.js").ContentBlock[],
83
+ field: string,
84
+ ): import("../index.js").ContentBlock[] {
85
+ for (let i = 0; i < blocks.length; i++) {
86
+ const block = blocks[i];
87
+ if (block.type !== "text" && block.type !== "json") {
88
+ throw new AIRequestError(
89
+ `ollama does not support ${field}[${i}] of type "${block.type}"; only text/json blocks are supported`,
90
+ "UNSUPPORTED_CONTENT_BLOCK",
91
+ );
92
+ }
93
+ }
94
+
95
+ return blocks;
96
+ }
97
+
98
+ function ensureOllamaReasoningBlocks(
99
+ blocks: import("../index.js").ContentBlock[],
100
+ field: string,
101
+ ): Array<Extract<import("../index.js").ContentBlock, { type: "text" }>> {
102
+ return blocks.map((block, index) => {
103
+ if (block.type !== "text") {
104
+ throw new AIRequestError(
105
+ `ollama does not support ${field}[${index}] of type "${block.type}"; reasoning only supports text blocks`,
106
+ "UNSUPPORTED_CONTENT_BLOCK",
107
+ );
108
+ }
109
+
110
+ return block;
111
+ });
112
+ }
113
+
114
+ function instructionsToOllamaText(instructions: string | import("../index.js").ContentBlock[]): string {
115
+ return typeof instructions === "string"
116
+ ? instructions
117
+ : contentBlocksToText(ensureOllamaTextBlocks(instructions, "instructions"));
118
+ }
119
+
120
+ function parseOllamaToolArguments(item: import("../index.js").ToolCallItem): Record<string, unknown> {
121
+ if (item.argumentsJson && typeof item.argumentsJson === "object" && item.argumentsJson !== null) {
122
+ return item.argumentsJson as Record<string, unknown>;
123
+ }
124
+
125
+ try {
126
+ const parsed = JSON.parse(item.argumentsText);
127
+ if (parsed && typeof parsed === "object") {
128
+ return parsed as Record<string, unknown>;
129
+ }
130
+ } catch {
131
+ // fall through
132
+ }
133
+
134
+ throw new AIRequestError(
135
+ "ollama tool_call argumentsText must be valid JSON object when argumentsJson is absent",
136
+ "TOOL_CALL_ARGUMENTS_INVALID",
137
+ );
138
+ }
139
+
140
+ function assertOllamaToolResultOutcome(outcome: import("../index.js").ToolResultItem["outcome"]): void {
141
+ if (outcome !== "success") {
142
+ throw new AIRequestError(
143
+ `ollama does not preserve tool_result outcome "${outcome}"; only "success" is supported`,
144
+ "UNSUPPORTED_TOOL_RESULT_OUTCOME",
145
+ );
146
+ }
147
+ }
148
+
149
+ // ── Ollama 流式 chunk ─────────────────────────────────────────
150
+
151
+ type OllamaChatChunk = {
152
+ model: string;
153
+ created_at: string;
154
+ message: {
155
+ role: string;
156
+ content: string;
157
+ tool_calls?: OllamaToolCall[];
158
+ };
159
+ done: boolean;
160
+ done_reason?: string;
161
+ // 计时与用量(仅 final chunk 有值)
162
+ total_duration?: number;
163
+ load_duration?: number;
164
+ prompt_eval_count?: number;
165
+ prompt_eval_duration?: number;
166
+ eval_count?: number;
167
+ eval_duration?: number;
168
+ };
169
+
170
+ // ── NDJSON 解析 ───────────────────────────────────────────────
171
+
172
+ function parseOllamaNDJSON(buffer: string): { chunks: OllamaChatChunk[]; rest: string; malformedLines: number } {
173
+ const chunks: OllamaChatChunk[] = [];
174
+ let rest = buffer;
175
+ let malformedLines = 0;
176
+
177
+ while (true) {
178
+ const lineEnd = rest.indexOf("\n");
179
+ if (lineEnd === -1) break;
180
+
181
+ const line = rest.slice(0, lineEnd).trim();
182
+ rest = rest.slice(lineEnd + 1);
183
+
184
+ if (!line) continue;
185
+
186
+ try {
187
+ const parsed = JSON.parse(line);
188
+ // Ollama chunks have a "message" field in streaming mode
189
+ if (parsed && typeof parsed === "object" && "message" in parsed) {
190
+ chunks.push(parsed as OllamaChatChunk);
191
+ } else {
192
+ malformedLines++;
193
+ }
194
+ } catch {
195
+ malformedLines++;
196
+ }
197
+ }
198
+
199
+ return { chunks, rest, malformedLines };
200
+ }
201
+
202
+ function rollbackTrailingAssistantMessages(messages: OllamaMessage[]): void {
203
+ while (messages.length > 0 && messages[messages.length - 1]?.role === "assistant") {
204
+ messages.pop();
205
+ }
206
+ }
207
+
208
+ function isOllamaToolCalls(value: unknown): value is OllamaToolCall[] {
209
+ return Array.isArray(value) && value.every((entry) => {
210
+ if (!entry || typeof entry !== "object" || !("function" in entry)) return false;
211
+ const fn = (entry as { function?: unknown }).function;
212
+ return (
213
+ !!fn &&
214
+ typeof fn === "object" &&
215
+ "name" in fn &&
216
+ typeof (fn as { name?: unknown }).name === "string" &&
217
+ "arguments" in fn &&
218
+ typeof (fn as { arguments?: unknown }).arguments === "object" &&
219
+ (fn as { arguments?: unknown }).arguments !== null
220
+ );
221
+ });
222
+ }
223
+
224
+ // ── Adapter ───────────────────────────────────────────────────
225
+
226
+ export class OllamaAdapter extends AdapterBase {
227
+ readonly kind = "ollama" as const;
228
+ readonly capabilities: AdapterCapabilities = {
229
+ nativeStreaming: true,
230
+ messageStreaming: true,
231
+ reasoningStreaming: false,
232
+ toolCallStreaming: false,
233
+ hiddenReasoningReplay: "none" as const,
234
+ replayFidelity: "low" as const,
235
+ tools: true,
236
+ usage: "partial" as const,
237
+ billing: "none" as const,
238
+ providerMetadata: false,
239
+ };
240
+
241
+ private baseUrl: string;
242
+ private apiKey: string | undefined;
243
+ private fetchFn: FetchFn;
244
+
245
+ constructor(options: OllamaAdapterOptions = {}) {
246
+ super();
247
+ this.baseUrl = options.baseUrl ?? "http://localhost:11434";
248
+ this.apiKey = options.apiKey;
249
+ this.fetchFn = options.fetch ?? globalThis.fetch;
250
+ }
251
+
252
+ // ── buildRequest ──────────────────────────────────────────
253
+
254
+ protected buildRequest(request: NormalizedRequest): OllamaChatRequest {
255
+ if (request.toolChoice && request.toolChoice !== "auto") {
256
+ throw new AIRequestError("ollama does not support explicit toolChoice", "UNSUPPORTED_TOOL_CHOICE");
257
+ }
258
+
259
+ const messages: OllamaMessage[] = [];
260
+
261
+ // handle instructions → system message
262
+ if (request.instructions) {
263
+ messages.push({ role: "system", content: instructionsToOllamaText(request.instructions) });
264
+ }
265
+
266
+ for (const item of request.input) {
267
+ switch (item.type) {
268
+ case "message": {
269
+ const role =
270
+ item.role === "developer"
271
+ ? "system"
272
+ : item.role === "system"
273
+ ? "system"
274
+ : item.role === "user"
275
+ ? "user"
276
+ : "assistant";
277
+ messages.push({ role, content: contentBlocksToText(ensureOllamaTextBlocks(item.content, `input message (${item.role}) content`)) });
278
+ break;
279
+ }
280
+ case "tool_call": {
281
+ // Ollama expects tool_calls on the last assistant message
282
+ const lastAssistant = messages.findLast((m) => m.role === "assistant");
283
+ const tc: OllamaToolCall = {
284
+ function: {
285
+ name: item.name,
286
+ arguments: parseOllamaToolArguments(item),
287
+ },
288
+ };
289
+ if (lastAssistant) {
290
+ lastAssistant.tool_calls = [...(lastAssistant.tool_calls ?? []), tc];
291
+ } else {
292
+ messages.push({ role: "assistant", content: "", tool_calls: [tc] });
293
+ }
294
+ break;
295
+ }
296
+ case "tool_result": {
297
+ assertOllamaToolResultOutcome(item.outcome);
298
+ messages.push({
299
+ role: "tool",
300
+ content: contentBlocksToText(ensureOllamaTextBlocks(item.content, `tool_result ${item.callId} content`)),
301
+ });
302
+ break;
303
+ }
304
+ case "reasoning": {
305
+ // Ollama doesn't support reasoning in input; convert to text message
306
+ messages.push({
307
+ role: "assistant",
308
+ content: contentBlocksToText(ensureOllamaReasoningBlocks(item.content, "reasoning content")),
309
+ });
310
+ break;
311
+ }
312
+ case "opaque": {
313
+ // Best-effort restore from opaque replay
314
+ if (item.source === "ollama" && item.purpose === "replay" && typeof item.payload === "object" && item.payload !== null) {
315
+ const payload = item.payload as Record<string, unknown>;
316
+ if (payload.role === "assistant" && typeof payload.content === "string") {
317
+ rollbackTrailingAssistantMessages(messages);
318
+ messages.push({
319
+ role: "assistant",
320
+ content: payload.content,
321
+ tool_calls: isOllamaToolCalls(payload.tool_calls) ? payload.tool_calls : undefined,
322
+ });
323
+ }
324
+ }
325
+ break;
326
+ }
327
+ }
328
+ }
329
+
330
+ const body: OllamaChatRequest = {
331
+ model: request.model,
332
+ messages,
333
+ stream: true,
334
+ };
335
+
336
+ if (request.tools && request.tools.length > 0) {
337
+ body.tools = request.tools.map(
338
+ (t): OllamaTool => ({
339
+ type: "function",
340
+ function: {
341
+ name: t.name,
342
+ description: t.description,
343
+ parameters: t.inputSchema as Record<string, unknown>,
344
+ },
345
+ }),
346
+ );
347
+ }
348
+
349
+ if (request.temperature !== undefined || request.maxOutputTokens !== undefined) {
350
+ body.options = {};
351
+ if (request.temperature !== undefined) body.options.temperature = request.temperature;
352
+ if (request.maxOutputTokens !== undefined) body.options.num_predict = request.maxOutputTokens;
353
+ }
354
+
355
+ return body;
356
+ }
357
+
358
+ // ── runStream ─────────────────────────────────────────────
359
+
360
+ protected async *runStream(
361
+ providerRequest: OllamaChatRequest,
362
+ factory: EventFactory,
363
+ request: NormalizedRequest,
364
+ ): AsyncIterable<AIStreamEvent> {
365
+ const auxiliary = this.createAuxiliaryState(request);
366
+ if (request.metadata) {
367
+ yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
368
+ }
369
+
370
+ const headers: Record<string, string> = {
371
+ "Content-Type": "application/json",
372
+ };
373
+ if (this.apiKey) {
374
+ headers.Authorization = `Bearer ${this.apiKey}`;
375
+ }
376
+
377
+ const response = await this.fetchFn(`${this.baseUrl}/api/chat`, {
378
+ method: "POST",
379
+ headers,
380
+ body: JSON.stringify(providerRequest),
381
+ });
382
+
383
+ if (!response.ok) {
384
+ const errorText = await response.text().catch(() => "unknown error");
385
+ throw new Error(`Ollama API error ${response.status}: ${errorText}`);
386
+ }
387
+
388
+ const reader = response.body?.getReader();
389
+ if (!reader) {
390
+ throw new Error("Response body is not readable");
391
+ }
392
+
393
+ const output: OutputItem[] = [];
394
+ const decoder = new TextDecoder();
395
+ let buffer = "";
396
+
397
+ // 累积状态
398
+ let responseId: string | undefined;
399
+ let accumulatedContent = "";
400
+ let currentMessageId = "";
401
+ let hasMessageStarted = false;
402
+
403
+ // tool_calls 累积(于 final chunk 到达)
404
+ let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string; argumentsJson?: unknown }> = [];
405
+
406
+ try {
407
+ while (true) {
408
+ // oxlint-disable-next-line no-await-in-loop
409
+ const { done, value } = await reader.read();
410
+ if (done) break;
411
+
412
+ buffer += decoder.decode(value, { stream: true });
413
+ const { chunks, rest, malformedLines } = parseOllamaNDJSON(buffer);
414
+ buffer = rest;
415
+
416
+ const malformedWarning = emitMalformedStreamWarning(factory, {
417
+ count: malformedLines,
418
+ providerLabel: "Ollama",
419
+ transportLabel: "NDJSON line(s)",
420
+ });
421
+ if (malformedWarning) {
422
+ yield malformedWarning;
423
+ }
424
+
425
+ for (const chunk of chunks) {
426
+ responseId = chunk.created_at;
427
+
428
+ const msg = chunk.message;
429
+
430
+ // 处理 content delta
431
+ if (msg.content) {
432
+ if (!hasMessageStarted) {
433
+ currentMessageId = `msg-${chunk.created_at}`;
434
+ hasMessageStarted = true;
435
+ yield factory.messageStarted(currentMessageId);
436
+ }
437
+ accumulatedContent += msg.content;
438
+ yield factory.messageDelta(currentMessageId, msg.content);
439
+ }
440
+
441
+ // 处理 tool_calls (整块到达,在最终 chunk 中)
442
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
443
+ for (const tc of msg.tool_calls) {
444
+ const tcId = `tc-${chunk.created_at}-${tc.function.name}`;
445
+ const argsText = JSON.stringify(tc.function.arguments);
446
+ pendingToolCalls.push({
447
+ id: tcId,
448
+ name: tc.function.name,
449
+ argumentsText: argsText,
450
+ argumentsJson: tc.function.arguments,
451
+ });
452
+ }
453
+ }
454
+
455
+ // 处理 done_reason (final chunk)
456
+ if (chunk.done) {
457
+ // 如果有未开始的 message 但没内容,发一个空消息启动
458
+ if (accumulatedContent === "" && pendingToolCalls.length > 0 && !hasMessageStarted) {
459
+ currentMessageId = `msg-${chunk.created_at}`;
460
+ hasMessageStarted = true;
461
+ yield factory.messageStarted(currentMessageId);
462
+ }
463
+
464
+ // 完成消息(如果有累积的内容或正在进行的消息)
465
+ if (hasMessageStarted) {
466
+ const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
467
+ yield factory.messageCompleted(message);
468
+ if (accumulatedContent) {
469
+ output.push(message);
470
+ }
471
+ }
472
+
473
+ // 发出 tool_call 完成事件
474
+ for (const pending of pendingToolCalls) {
475
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
476
+ yield factory.toolCallStarted(pending.id, pending.name);
477
+ yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
478
+ yield factory.toolCallCompleted(toolCall);
479
+ output.push(toolCall);
480
+ }
481
+
482
+ // 提取 usage
483
+ if (
484
+ request.include?.usage !== "off" &&
485
+ (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined)
486
+ ) {
487
+ auxiliary.recordUsage(
488
+ {
489
+ inputTokens: chunk.prompt_eval_count,
490
+ outputTokens: chunk.eval_count,
491
+ totalTokens: chunk.prompt_eval_count !== undefined && chunk.eval_count !== undefined
492
+ ? chunk.prompt_eval_count + chunk.eval_count
493
+ : undefined,
494
+ },
495
+ "final",
496
+ {
497
+ prompt_eval_count: chunk.prompt_eval_count,
498
+ eval_count: chunk.eval_count,
499
+ },
500
+ );
501
+ }
502
+
503
+ // 构建 stop reason
504
+ const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
505
+
506
+ // 构建 replay
507
+ const replay = replayFromOutput(output);
508
+
509
+ // 附加 opaque replay(若有关联的 assistant 消息)
510
+ if (accumulatedContent || pendingToolCalls.length > 0) {
511
+ replay.push(
512
+ opaqueItem("ollama", "replay", {
513
+ role: "assistant",
514
+ content: accumulatedContent,
515
+ tool_calls: pendingToolCalls.map((tc) => ({
516
+ function: { name: tc.name, arguments: tc.argumentsJson },
517
+ })),
518
+ }),
519
+ );
520
+ }
521
+
522
+ const auxiliaryResult = await auxiliary.finalize(factory);
523
+ for (const event of auxiliaryResult.events) {
524
+ yield event;
525
+ }
526
+
527
+ yield factory.responseCompleted(
528
+ this.buildResponse(
529
+ request,
530
+ {
531
+ output,
532
+ replay,
533
+ stopReason,
534
+ usage: auxiliaryResult.usage,
535
+ billing: auxiliaryResult.billing,
536
+ auxiliary: auxiliaryResult.auxiliary,
537
+ warnings: auxiliaryResult.warnings,
538
+ metadataSources: auxiliaryResult.metadataSources,
539
+ rawResponseId: chunk.created_at,
540
+ },
541
+ factory,
542
+ ),
543
+ );
544
+
545
+ // 重置累积状态
546
+ accumulatedContent = "";
547
+ currentMessageId = "";
548
+ hasMessageStarted = false;
549
+ pendingToolCalls = [];
550
+ }
551
+ }
552
+ }
553
+ } finally {
554
+ reader.releaseLock();
555
+ }
556
+
557
+ if (buffer.trim().length > 0) {
558
+ yield factory.responseWarning("Stream ended with an incomplete Ollama NDJSON line", "STREAM_ERROR");
559
+ }
560
+
561
+ // 流结束但无 done=true(断流保护)
562
+ if (hasMessageStarted || pendingToolCalls.length > 0) {
563
+ yield factory.responseWarning("Stream ended without a done signal", "INCOMPLETE_STREAM");
564
+
565
+ if (hasMessageStarted) {
566
+ const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
567
+ yield factory.messageCompleted(message);
568
+ if (accumulatedContent) {
569
+ output.push(message);
570
+ }
571
+ }
572
+
573
+ for (const pending of pendingToolCalls) {
574
+ const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText, pending.argumentsJson);
575
+ yield factory.toolCallStarted(pending.id, pending.name);
576
+ yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
577
+ yield factory.toolCallCompleted(toolCall);
578
+ output.push(toolCall);
579
+ }
580
+
581
+ const replay = replayFromOutput(output);
582
+ const auxiliaryResult = await auxiliary.finalize(factory);
583
+ for (const event of auxiliaryResult.events) {
584
+ yield event;
585
+ }
586
+ yield factory.responseCompleted(
587
+ this.buildResponse(
588
+ request,
589
+ {
590
+ output,
591
+ replay,
592
+ usage: auxiliaryResult.usage,
593
+ billing: auxiliaryResult.billing,
594
+ auxiliary: auxiliaryResult.auxiliary,
595
+ warnings: auxiliaryResult.warnings,
596
+ metadataSources: auxiliaryResult.metadataSources,
597
+ rawResponseId: responseId,
598
+ },
599
+ factory,
600
+ ),
601
+ );
602
+ }
603
+ }
604
+ }