@codehz/ai 0.4.6 → 0.7.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.
Files changed (46) hide show
  1. package/README.md +221 -75
  2. package/dist/index.d.mts +670 -523
  3. package/dist/index.mjs +3677 -2207
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +19 -8
  6. package/.github/workflows/publish.yml +0 -56
  7. package/.oxfmtrc.json +0 -12
  8. package/.oxlintrc.json +0 -34
  9. package/AGENTS.md +0 -37
  10. package/src/adapters/chat-completions.ts +0 -624
  11. package/src/adapters/index.ts +0 -44
  12. package/src/adapters/messages.ts +0 -635
  13. package/src/adapters/mock.ts +0 -934
  14. package/src/adapters/ollama.ts +0 -526
  15. package/src/adapters/responses.ts +0 -818
  16. package/src/core/aggregator.ts +0 -428
  17. package/src/core/client.ts +0 -36
  18. package/src/core/collect-stream.ts +0 -19
  19. package/src/core/errors.ts +0 -105
  20. package/src/core/event-factory.ts +0 -151
  21. package/src/core/index.ts +0 -18
  22. package/src/core/merge-auxiliary.ts +0 -22
  23. package/src/core/normalize.ts +0 -65
  24. package/src/core/validation.ts +0 -404
  25. package/src/helpers/adapter-auxiliary.ts +0 -155
  26. package/src/helpers/adapter-base.ts +0 -218
  27. package/src/helpers/adapter-security.ts +0 -126
  28. package/src/helpers/auxiliary-collector.ts +0 -166
  29. package/src/helpers/incremental-stream-parser.ts +0 -142
  30. package/src/helpers/index.ts +0 -87
  31. package/src/helpers/mapping.ts +0 -192
  32. package/src/helpers/provider-request-options.ts +0 -25
  33. package/src/helpers/provider-stream.ts +0 -147
  34. package/src/helpers/reasoning-level.ts +0 -86
  35. package/src/helpers/request-mapper.ts +0 -94
  36. package/src/helpers/synthetic-stream.ts +0 -188
  37. package/src/helpers/usage-mapping.ts +0 -110
  38. package/src/index.ts +0 -17
  39. package/src/types/adapter.ts +0 -42
  40. package/src/types/content.ts +0 -15
  41. package/src/types/events.ts +0 -138
  42. package/src/types/index.ts +0 -49
  43. package/src/types/items.ts +0 -57
  44. package/src/types/request.ts +0 -52
  45. package/src/types/response.ts +0 -68
  46. package/tsdown.config.ts +0 -10
@@ -1,526 +0,0 @@
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, WarningCode } 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 { assertOpaqueReplayEnvelope } from "../helpers/adapter-security.js";
30
- import { usageFromOllama } from "../helpers/usage-mapping.js";
31
- import {
32
- NormalizedRequestMapper,
33
- createNdjsonLineParser,
34
- openProviderJsonStream,
35
- iterateProviderStreamBatches,
36
- createCompletionGate,
37
- mergeProviderHeaders,
38
- applyExtraBody,
39
- mapOllamaThink,
40
- } from "../helpers/index.js";
41
-
42
- import type { NormalizedRequest, AIStreamEvent, EventFactory, OutputItem, FetchFn, StopReason } from "../index.js";
43
-
44
- // ── 选项类型 ──────────────────────────────────────────────────
45
-
46
- export type OllamaAdapterOptions = {
47
- /** Ollama 服务地址,默认 http://localhost:11434 */
48
- baseUrl?: string;
49
- /** 可选 API key(用于需要认证的代理场景) */
50
- apiKey?: string;
51
- /** 可注入自定义 fetch 实现 */
52
- fetch?: FetchFn;
53
- /** 额外请求头;后写覆盖内置 Content-Type / Authorization */
54
- headers?: Record<string, string>;
55
- /** 额外 body 顶层字段;浅层合并,同名键可覆盖 */
56
- extraBody?: Record<string, unknown>;
57
- };
58
-
59
- // ── Ollama Chat API 类型 ──────────────────────────────────────
60
-
61
- type OllamaChatRequest = {
62
- model: string;
63
- messages: OllamaMessage[];
64
- stream: true;
65
- tools?: OllamaTool[];
66
- /** Portable reasoningLevel → think;minimal/xhigh/max 不支持 */
67
- think?: boolean | "low" | "medium" | "high";
68
- options?: {
69
- temperature?: number;
70
- num_predict?: number;
71
- [key: string]: unknown;
72
- };
73
- };
74
-
75
- type OllamaMessage = {
76
- role: "system" | "user" | "assistant" | "tool";
77
- content: string;
78
- images?: string[];
79
- tool_calls?: OllamaToolCall[];
80
- };
81
-
82
- type OllamaToolCall = {
83
- function: {
84
- name: string;
85
- arguments: Record<string, unknown>;
86
- };
87
- };
88
-
89
- type OllamaTool = {
90
- type: "function";
91
- function: {
92
- name: string;
93
- description?: string;
94
- parameters: Record<string, unknown>;
95
- };
96
- };
97
-
98
- const mapper = new NormalizedRequestMapper("ollama");
99
-
100
- // ── Ollama 流式 chunk ─────────────────────────────────────────
101
-
102
- type OllamaChatChunk = {
103
- model: string;
104
- created_at: string;
105
- message: {
106
- role: string;
107
- content: string;
108
- tool_calls?: OllamaToolCall[];
109
- };
110
- done: boolean;
111
- done_reason?: string;
112
- // 计时与用量(仅 final chunk 有值)
113
- total_duration?: number;
114
- load_duration?: number;
115
- prompt_eval_count?: number;
116
- prompt_eval_duration?: number;
117
- eval_count?: number;
118
- eval_duration?: number;
119
- };
120
-
121
- /** Opaque replay may carry optional local `id`s; wire tool_calls never include them. */
122
- type OllamaReplayToolCall = OllamaToolCall & { id?: string };
123
-
124
- function isOllamaReplayToolCalls(value: unknown): value is OllamaReplayToolCall[] {
125
- return (
126
- Array.isArray(value) &&
127
- value.every((entry) => {
128
- if (!entry || typeof entry !== "object" || !("function" in entry)) return false;
129
- const fn = (entry as { function?: unknown }).function;
130
- const id = (entry as { id?: unknown }).id;
131
- if (id !== undefined && typeof id !== "string") return false;
132
- return (
133
- !!fn &&
134
- typeof fn === "object" &&
135
- "name" in fn &&
136
- typeof (fn as { name?: unknown }).name === "string" &&
137
- "arguments" in fn &&
138
- typeof (fn as { arguments?: unknown }).arguments === "object" &&
139
- (fn as { arguments?: unknown }).arguments !== null
140
- );
141
- })
142
- );
143
- }
144
-
145
- function toWireOllamaToolCalls(toolCalls: OllamaReplayToolCall[]): OllamaToolCall[] {
146
- return toolCalls.map((tc) => ({
147
- function: {
148
- name: tc.function.name,
149
- arguments: tc.function.arguments,
150
- },
151
- }));
152
- }
153
-
154
- // ── Adapter ───────────────────────────────────────────────────
155
-
156
- export class OllamaAdapter extends AdapterBase {
157
- readonly kind = "ollama" as const;
158
- readonly isSyntheticStream = false;
159
-
160
- private baseUrl: string;
161
- private apiKey: string | undefined;
162
- private fetchFn: FetchFn;
163
- private headers: Record<string, string> | undefined;
164
- private extraBody: Record<string, unknown> | undefined;
165
-
166
- constructor(options: OllamaAdapterOptions = {}) {
167
- super();
168
- this.baseUrl = options.baseUrl ?? "http://localhost:11434";
169
- this.apiKey = options.apiKey;
170
- this.fetchFn = options.fetch ?? globalThis.fetch;
171
- this.headers = options.headers;
172
- this.extraBody = options.extraBody;
173
- }
174
-
175
- // ── buildRequest ──────────────────────────────────────────
176
-
177
- protected buildRequest(request: NormalizedRequest): OllamaChatRequest {
178
- const messages: OllamaMessage[] = [];
179
- /** Local-only name → call id queue for best-effort tool_result association (not sent to Ollama). */
180
- const callIdsByName = new Map<string, string[]>();
181
-
182
- // handle instructions → system message
183
- if (request.instructions) {
184
- messages.push({ role: "system", content: mapper.mapInstructions(request.instructions) });
185
- }
186
-
187
- for (const item of request.input) {
188
- switch (item.type) {
189
- case "message": {
190
- const role = item.role;
191
- messages.push({
192
- role,
193
- content: mapper.textFromBlocks(item.content, `input message (${item.role}) content`),
194
- });
195
- break;
196
- }
197
- case "tool_call": {
198
- // Ollama expects tool_calls on the last assistant message
199
- const lastAssistant = messages.findLast((m) => m.role === "assistant");
200
- const tc: OllamaToolCall = {
201
- function: {
202
- name: item.name,
203
- arguments: mapper.parseToolArguments(item),
204
- },
205
- };
206
- const queue = callIdsByName.get(item.name) ?? [];
207
- queue.push(item.id);
208
- callIdsByName.set(item.name, queue);
209
- if (lastAssistant) {
210
- lastAssistant.tool_calls = [...(lastAssistant.tool_calls ?? []), tc];
211
- } else {
212
- messages.push({ role: "assistant", content: "", tool_calls: [tc] });
213
- }
214
- break;
215
- }
216
- case "tool_result": {
217
- // Best-effort: consume matching id from name queue when present (no wire call_id)
218
- const queue = callIdsByName.get(item.toolName);
219
- if (queue && queue.length > 0) {
220
- queue.shift();
221
- }
222
- messages.push({
223
- role: "tool",
224
- content: mapper.textFromBlocks(item.content, `tool_result ${item.callId} content`),
225
- });
226
- break;
227
- }
228
- case "reasoning": {
229
- // Ollama doesn't support reasoning in input; convert to text message
230
- messages.push({
231
- role: "assistant",
232
- content: contentBlocksToText(mapper.ensureReasoningBlocks(item.content, "reasoning content")),
233
- });
234
- break;
235
- }
236
- case "opaque": {
237
- // Best-effort restore from opaque replay (local ids stripped before wire)
238
- if (item.source !== "ollama" || item.purpose !== "replay") break;
239
- assertOpaqueReplayEnvelope(item.payload);
240
- const payload = item.payload as Record<string, unknown>;
241
- if (payload.role === "assistant" && typeof payload.content === "string") {
242
- mapper.rollbackTrailingAssistantMessages(messages);
243
- let replayToolCalls: OllamaReplayToolCall[] | undefined;
244
- if ("tool_calls" in payload && payload.tool_calls !== undefined) {
245
- if (!isOllamaReplayToolCalls(payload.tool_calls)) {
246
- throw new AIRequestError(
247
- "Invalid opaque replay payload: tool_calls is not a valid ollama tool_calls array",
248
- "INVALID_OPAQUE_REPLAY",
249
- );
250
- }
251
- replayToolCalls = payload.tool_calls;
252
- }
253
- // Record name → id order for best-effort tool_result correlation (local only)
254
- if (replayToolCalls) {
255
- for (const tc of replayToolCalls) {
256
- if (tc.id) {
257
- const queue = callIdsByName.get(tc.function.name) ?? [];
258
- queue.push(tc.id);
259
- callIdsByName.set(tc.function.name, queue);
260
- }
261
- }
262
- }
263
- messages.push({
264
- role: "assistant",
265
- content: payload.content,
266
- tool_calls: replayToolCalls ? toWireOllamaToolCalls(replayToolCalls) : undefined,
267
- });
268
- }
269
- break;
270
- }
271
- }
272
- }
273
-
274
- const body: OllamaChatRequest = {
275
- model: request.model,
276
- messages,
277
- stream: true,
278
- };
279
-
280
- const toolChoice = request.toolChoice;
281
- const selectedTools =
282
- toolChoice === "none"
283
- ? []
284
- : toolChoice && typeof toolChoice === "object"
285
- ? request.tools?.filter((tool) => tool.name === toolChoice.name)
286
- : request.tools;
287
-
288
- if (selectedTools && selectedTools.length > 0) {
289
- body.tools = selectedTools.map(
290
- (t): OllamaTool => ({
291
- type: "function",
292
- function: {
293
- name: t.name,
294
- description: t.description,
295
- parameters: t.inputSchema as Record<string, unknown>,
296
- },
297
- }),
298
- );
299
- }
300
-
301
- if (request.temperature !== undefined || request.maxOutputTokens !== undefined) {
302
- body.options = {};
303
- if (request.temperature !== undefined) body.options.temperature = request.temperature;
304
- if (request.maxOutputTokens !== undefined) body.options.num_predict = request.maxOutputTokens;
305
- }
306
-
307
- if (request.reasoningLevel !== undefined) {
308
- body.think = mapOllamaThink(request.reasoningLevel);
309
- }
310
-
311
- return applyExtraBody(body, this.extraBody);
312
- }
313
-
314
- // ── runStream ─────────────────────────────────────────────
315
-
316
- protected async *runStream(
317
- providerRequest: OllamaChatRequest,
318
- factory: EventFactory,
319
- request: NormalizedRequest,
320
- ): AsyncIterable<AIStreamEvent> {
321
- const auxiliary = this.createAuxiliaryState(request);
322
- const gate = createCompletionGate();
323
-
324
- if (request.toolChoice && request.toolChoice !== "auto") {
325
- yield factory.responseWarning(
326
- request.toolChoice === "none"
327
- ? "Ollama toolChoice none was mapped by omitting tools"
328
- : `Ollama cannot force tool choice; only tool "${request.toolChoice.name}" was provided as a best-effort constraint`,
329
- WarningCode.CAPABILITY_DOWNGRADE,
330
- );
331
- }
332
- if (request.metadata) {
333
- yield factory.responseWarning("Request metadata is not supported by the Ollama adapter", "UNSUPPORTED_METADATA");
334
- }
335
-
336
- const headers: Record<string, string> = {
337
- "Content-Type": "application/json",
338
- };
339
- if (this.apiKey) {
340
- headers.Authorization = `Bearer ${this.apiKey}`;
341
- }
342
-
343
- const { reader } = await openProviderJsonStream({
344
- fetchFn: this.fetchFn,
345
- url: `${this.baseUrl}/api/chat`,
346
- headers: mergeProviderHeaders(headers, this.headers),
347
- body: providerRequest,
348
- signal: request.signal,
349
- });
350
-
351
- const parser = createNdjsonLineParser<OllamaChatChunk>(
352
- (value): value is OllamaChatChunk => !!value && typeof value === "object" && "message" in value,
353
- );
354
-
355
- const output: OutputItem[] = [];
356
- let responseId: string | undefined;
357
- let accumulatedContent = "";
358
- let currentMessageId = "";
359
- let hasMessageStarted = false;
360
- let pendingToolCalls: Array<{ id: string; name: string; argumentsText: string }> = [];
361
- let toolCallIndex = 0;
362
-
363
- const emitCompleted = async function* (
364
- this: OllamaAdapter,
365
- stopReason: StopReason | undefined,
366
- rawResponseId: string | undefined,
367
- ): AsyncIterable<AIStreamEvent> {
368
- if (!gate.tryComplete()) {
369
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
370
- return;
371
- }
372
-
373
- const replay = replayFromOutput(output);
374
- if (accumulatedContent || pendingToolCalls.length > 0) {
375
- replay.push(
376
- opaqueItem("ollama", "replay", {
377
- role: "assistant",
378
- content: accumulatedContent,
379
- tool_calls: pendingToolCalls.map((tc) => ({
380
- id: tc.id,
381
- function: { name: tc.name, arguments: JSON.parse(tc.argumentsText) as Record<string, unknown> },
382
- })),
383
- }),
384
- );
385
- }
386
-
387
- yield* this.emitStreamCompleted(factory, request, auxiliary, {
388
- output,
389
- replay,
390
- stopReason,
391
- rawResponseId,
392
- });
393
- }.bind(this);
394
-
395
- for await (const batch of iterateProviderStreamBatches({
396
- reader,
397
- parser,
398
- factory,
399
- providerLabel: "Ollama",
400
- transportLabel: "NDJSON line(s)",
401
- incompleteMessage: "Stream ended with an incomplete Ollama NDJSON line",
402
- })) {
403
- for (const warning of batch.warnings) yield warning;
404
-
405
- for (const chunk of batch.items) {
406
- responseId = chunk.created_at;
407
-
408
- if (gate.completed) {
409
- if (chunk.done) {
410
- yield factory.responseWarning("Duplicate finish signal ignored", "DUPLICATE_FINISH");
411
- }
412
- continue;
413
- }
414
-
415
- const msg = chunk.message;
416
-
417
- if (msg.content) {
418
- if (!hasMessageStarted) {
419
- currentMessageId = `msg-${chunk.created_at}`;
420
- hasMessageStarted = true;
421
- yield factory.messageStarted(currentMessageId);
422
- }
423
- accumulatedContent += msg.content;
424
- yield factory.messageDelta(currentMessageId, textBlock(msg.content));
425
- }
426
-
427
- if (msg.tool_calls && msg.tool_calls.length > 0) {
428
- for (const tc of msg.tool_calls) {
429
- const tcId = `ollama-tc-${request.requestId}-${toolCallIndex++}`;
430
- const argsText = JSON.stringify(tc.function.arguments);
431
- pendingToolCalls.push({
432
- id: tcId,
433
- name: tc.function.name,
434
- argumentsText: argsText,
435
- });
436
- }
437
- }
438
-
439
- if (chunk.done) {
440
- if (accumulatedContent === "" && pendingToolCalls.length > 0 && !hasMessageStarted) {
441
- currentMessageId = `msg-${chunk.created_at}`;
442
- hasMessageStarted = true;
443
- yield factory.messageStarted(currentMessageId);
444
- }
445
-
446
- if (hasMessageStarted) {
447
- const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
448
- yield factory.messageCompleted(currentMessageId);
449
- if (accumulatedContent) {
450
- output.push(message);
451
- }
452
- }
453
-
454
- if (pendingToolCalls.length > 0) {
455
- yield factory.responseWarning(
456
- `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
457
- WarningCode.TOOL_CALL_BATCHED,
458
- );
459
- }
460
-
461
- for (const pending of pendingToolCalls) {
462
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
463
- yield factory.toolCallStarted(pending.id, pending.name);
464
- yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
465
- yield factory.toolCallCompleted(pending.id);
466
- output.push(toolCall);
467
- }
468
-
469
- if (
470
- request.include?.usage !== "off" &&
471
- (chunk.prompt_eval_count !== undefined || chunk.eval_count !== undefined)
472
- ) {
473
- auxiliary.recordUsage(
474
- usageFromOllama({
475
- prompt_eval_count: chunk.prompt_eval_count,
476
- eval_count: chunk.eval_count,
477
- }),
478
- "final",
479
- {
480
- prompt_eval_count: chunk.prompt_eval_count,
481
- eval_count: chunk.eval_count,
482
- },
483
- );
484
- }
485
-
486
- const stopReason = chunk.done_reason ? mapStopReason(chunk.done_reason) : undefined;
487
- yield* emitCompleted(stopReason, chunk.created_at);
488
-
489
- accumulatedContent = "";
490
- currentMessageId = "";
491
- hasMessageStarted = false;
492
- pendingToolCalls = [];
493
- }
494
- }
495
- }
496
-
497
- if (!gate.completed && (hasMessageStarted || pendingToolCalls.length > 0)) {
498
- yield factory.responseWarning("Stream ended without a done signal", WarningCode.STREAM_INCOMPLETE);
499
-
500
- if (hasMessageStarted) {
501
- const message = messageItem([textBlock(accumulatedContent)], { id: currentMessageId });
502
- yield factory.messageCompleted(currentMessageId);
503
- if (accumulatedContent) {
504
- output.push(message);
505
- }
506
- }
507
-
508
- if (pendingToolCalls.length > 0) {
509
- yield factory.responseWarning(
510
- `Ollama delivered ${pendingToolCalls.length} tool call(s) as a batch; tool_call streaming is not supported`,
511
- WarningCode.TOOL_CALL_BATCHED,
512
- );
513
- }
514
-
515
- for (const pending of pendingToolCalls) {
516
- const toolCall = toolCallItem(pending.id, pending.name, pending.argumentsText);
517
- yield factory.toolCallStarted(pending.id, pending.name);
518
- yield factory.toolCallDelta(pending.id, { argumentsText: pending.argumentsText });
519
- yield factory.toolCallCompleted(pending.id);
520
- output.push(toolCall);
521
- }
522
-
523
- yield* emitCompleted(undefined, responseId);
524
- }
525
- }
526
- }