@nemesis-oss/ollama-sdk 1.1.0 → 1.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +43 -92
  2. package/README.md +749 -11
  3. package/dist/compose-BR70yb4W.d.cts +164 -0
  4. package/dist/compose-DfAqKAFj.d.ts +164 -0
  5. package/dist/generated-api.cjs +454 -0
  6. package/dist/generated-api.cjs.map +1 -0
  7. package/dist/generated-api.d.cts +1651 -0
  8. package/dist/generated-api.d.ts +1651 -0
  9. package/dist/generated-api.js +428 -0
  10. package/dist/generated-api.js.map +1 -0
  11. package/dist/generated-models-schemas.cjs +328 -0
  12. package/dist/generated-models-schemas.cjs.map +1 -0
  13. package/dist/generated-models-schemas.d.cts +463 -0
  14. package/dist/generated-models-schemas.d.ts +463 -0
  15. package/dist/generated-models-schemas.js +291 -0
  16. package/dist/generated-models-schemas.js.map +1 -0
  17. package/dist/generated-runtime.cjs +145 -0
  18. package/dist/generated-runtime.cjs.map +1 -0
  19. package/dist/generated-runtime.d.cts +2 -0
  20. package/dist/generated-runtime.d.ts +2 -0
  21. package/dist/generated-runtime.js +143 -0
  22. package/dist/generated-runtime.js.map +1 -0
  23. package/dist/index-2RIErNT5.d.cts +583 -0
  24. package/dist/index-DIQ8NUbU.d.ts +583 -0
  25. package/dist/index.cjs +2308 -184
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.d.cts +1123 -421
  28. package/dist/index.d.ts +1123 -421
  29. package/dist/index.js +2293 -185
  30. package/dist/index.js.map +1 -1
  31. package/dist/mcp/tools.json +1058 -0
  32. package/dist/mcp-generated.cjs +386 -0
  33. package/dist/mcp-generated.cjs.map +1 -0
  34. package/dist/mcp-generated.d.cts +35 -0
  35. package/dist/mcp-generated.d.ts +35 -0
  36. package/dist/mcp-generated.js +381 -0
  37. package/dist/mcp-generated.js.map +1 -0
  38. package/dist/mcp-http.cjs +100 -0
  39. package/dist/mcp-http.cjs.map +1 -0
  40. package/dist/mcp-http.d.cts +48 -0
  41. package/dist/mcp-http.d.ts +48 -0
  42. package/dist/mcp-http.js +96 -0
  43. package/dist/mcp-http.js.map +1 -0
  44. package/dist/mcp-stdio.cjs +49 -0
  45. package/dist/mcp-stdio.cjs.map +1 -0
  46. package/dist/mcp-stdio.d.cts +34 -0
  47. package/dist/mcp-stdio.d.ts +34 -0
  48. package/dist/mcp-stdio.js +47 -0
  49. package/dist/mcp-stdio.js.map +1 -0
  50. package/dist/skills.cjs.map +1 -1
  51. package/dist/skills.d.cts +3 -2
  52. package/dist/skills.d.ts +3 -2
  53. package/dist/skills.js.map +1 -1
  54. package/dist/types-B9_axjum.d.cts +66 -0
  55. package/dist/types-B9_axjum.d.ts +66 -0
  56. package/dist/{compose-DJjVpDrk.d.cts → types-BQhgW-du.d.cts} +118 -172
  57. package/dist/{compose-DJjVpDrk.d.ts → types-BQhgW-du.d.ts} +118 -172
  58. package/package.json +96 -5
package/README.md CHANGED
@@ -11,19 +11,140 @@
11
11
  ## Key Features
12
12
 
13
13
  - 🚀 **Native Web Standards**: Built on native `fetch` and Web Streams. Zero external HTTP dependencies.
14
- - 🧠 **Reasoning & Thinking Tokens**: First-class support for reasoning models (`qwen3:8b`, `deepseek-r1:8b`) with discrete `thinking` and `token` streaming events.
14
+ - 🧠 **Reasoning & Thinking Tokens**: First-class support for reasoning models (`qwen3:8b`, `deepseek-r1:8b`) via the native `think` parameter, with discrete `thinking` and `token` streaming events, plus `logprobs`/`top_logprobs` for token-level confidence scoring.
15
+ - 🖼️ **Multimodal / Vision Input**: `images` on `Message`/`generate()` accepts base64 strings or raw `Uint8Array` bytes (auto-encoded) for vision models like `llava` or `qwen2.5vl`.
15
16
  - 🎯 **Zod-Powered Structured Outputs**: Strictly typed schema enforcement via `chatWithSchema` and `generateWithSchema` with resilient markdown JSON parsing.
16
17
  - 🛠️ **Autonomous Agent & Tool Calling**: Multi-turn agent loop (`Agent`) with automated tool execution, parameter validation, and self-correcting error recovery.
17
- - 🌐 **High Availability & Failover**: Multi-endpoint registry with priority routing, circuit breaker failover, and active health checks.
18
- - 🔌 **Model Context Protocol (MCP)**: Native adapters to convert MCP tools into Ollama-compatible function schemas.
18
+ - 🌐 **High Availability & Failover**: Multi-endpoint registry with priority routing, circuit breaker failover, active health checks, and per-endpoint `models` allow-lists for routing several model-specific API keys through one client.
19
+ - 🔌 **Model Context Protocol (MCP)**: First-class, transport-neutral `McpBridge` for converting MCP tool descriptors into Ollama function definitions and registering executable MCP-backed tools.
20
+ - 📚 **Full Model Lifecycle**: `pullModel`, `pushModel`, `createModel`, `copyModel`, `deleteModel`, `listModels`, `showModel`, and `ps()` (currently loaded models) — full parity with Ollama's model management API.
21
+ - 🔎 **Ollama Cloud Web Tools**: `webSearch`/`webFetch` wrap Ollama's hosted `/api/web_search` and `/api/web_fetch` tools at `ollama.com` (requires an `OLLAMA_API_KEY`), independent of any local `baseUrl`.
19
22
  - 🌉 **OpenAI & Anthropic Compatibility Bridges**: Built-in clients for `/v1/chat/completions`, `/v1/responses`, `/v1/models`, and `/v1/messages`, including `reasoning_effort`/`reasoning.effort` for thinking models.
20
23
  - 🌊 **Web Stream Adapters**: Drop-in adapters (`toTextStream`, `toDataStream`, `toResponse`) for Next.js Route Handlers and Vercel AI SDK.
21
24
  - 📈 **OpenTelemetry Instrumentation**: Automatic spans for HTTP requests, endpoint failover, chat/generate calls, and agent runs — zero-cost when OpenTelemetry isn't installed.
25
+ - 📊 **Client-Side Quota Monitoring**: `QuotaManager` tracks token/request usage against budgets you configure across rolling windows (e.g. Ollama Cloud's 5-hour session / 7-day weekly resets) and fails fast with `OllamaQuotaExceededError` before a request is sent.
22
26
  - ⚡ **Edge Runtime Verified**: CI bundles and runs the client in a real Edge Runtime sandbox (Cloudflare Workers/Vercel Edge-compatible) with zero Node.js APIs.
23
27
  - 📦 **Dual ESM & CJS Build**: Full module support with clean TypeScript `.d.ts` declaration maps.
28
+ - 🧩 **Contract-First Architecture**: A single canonical IR (`contracts/ir/ollama.ir.json`) drives TypeScript interfaces, generated API classes (`NativeApi`/`OpenAIApi`/`AnthropicApi`), MCP tool descriptors, Zod schemas, and field-level parity verification. New Ollama endpoints (like `/v1/systemone`) are caught automatically by bidirectional endpoint discovery. See [ADRs 0013-0019](./docs/adr/README.md).
24
29
 
25
30
  ---
26
31
 
32
+ ### Contract parity verification
33
+
34
+ The repository keeps the Ollama compatibility contract in the canonical IR at
35
+ `contracts/ir/ollama.ir.json` (compiled from `contracts/sources/` + `contracts/overlays/`)
36
+ and checks it against the current official documentation with
37
+ `npm run verify:contract-parity`. Each operation's `parity:` block distinguishes
38
+ **supported**, **explicitly unsupported**, and **SDK-only** fields, and also tracks
39
+ documented response fields plus the public streaming-event union for compatibility
40
+ adapters. This prevents a field merely being mentioned in upstream documentation
41
+ from being mistaken for a supported Ollama feature.
42
+
43
+ For callers who want compile-time enforcement of the documented Ollama subset, the package
44
+ also exports strict request types such as `OllamaOpenAIChatCompletionRequest`,
45
+ `OllamaOpenAIResponsesRequest`, `OllamaOpenAIEmbeddingRequest`, and
46
+ `OllamaAnthropicMessagesRequest`. The broader compatibility request types remain
47
+ available for pass-through interoperability and vendor-specific fields.
48
+
49
+ ### SSE Streaming Foundation
50
+
51
+ Compatibility endpoints use Server-Sent Events when `stream: true`. The SDK now exposes a provider-neutral `parseSseStream()` and `HttpClient.requestSseStream()`, plus typed adapters for OpenAI Chat/Completions/Responses and Anthropic Messages. Native Ollama NDJSON streaming remains separate.
52
+
53
+ ```typescript
54
+ const stream = await client.openai.chatCompletions({
55
+ model: 'qwen3',
56
+ messages: [{ role: 'user', content: 'Explain SSE.' }],
57
+ stream: true,
58
+ });
59
+
60
+ for await (const chunk of stream) {
61
+ process.stdout.write(chunk.choices[0]?.delta.content ?? '');
62
+ }
63
+
64
+ const final = await stream.finalResult;
65
+ console.log(final.usage);
66
+ ```
67
+
68
+ ## MCP bridge
69
+
70
+ The SDK exposes a transport-neutral `McpBridge` that converts MCP `tools/list` descriptors into native Ollama function tools and registers executable MCP-backed tools. MCP tool arguments are validated against the advertised JSON Schema at the `ToolRegistry` boundary (including object, array, scalar, required, enum, and common numeric/string constraints).
71
+
72
+ - Paginated `tools/list` discovery follows `nextCursor`, with repeated-cursor and page-limit protection.
73
+ - MCP JSON Schema is preserved in the generated Ollama tool definition.
74
+ - `structuredContent` and non-text MCP content blocks are retained in the model-visible tool result.
75
+ - MCP `isError: true` results remain model-readable; they are not treated as transport failures.
76
+
77
+ `loadMcpTools()` keeps the historical model-oriented string result by default. Set `resultMode: 'structured'` when application code needs the raw MCP `CallToolResult`, including `structuredContent`, content blocks, `isError`, and `_meta`:
78
+
79
+ ```typescript
80
+ const tools = await loadMcpTools(mcpClient, { resultMode: 'structured' });
81
+ ```
82
+
83
+ - Input JSON Schema validation happens before the MCP server is called, preventing invalid model-generated arguments from crossing the protocol boundary.
84
+ - `resultMode: 'structured'` is available for lossless programmatic access to raw MCP tool results while preserving the legacy text mode by default.
85
+ - `AbortSignal` propagates through discovery and tool execution.
86
+ - The optional Node-only `@nemesis-oss/ollama-sdk/mcp/stdio` subpath uses the official MCP v2 stdio transport without importing Node-only code from the root package.
87
+
88
+ For remote MCP servers, the optional `@nemesis-oss/ollama-sdk/mcp/http` subpath uses the official MCP client v2 transport. Streamable HTTP is the default; legacy SSE can be selected explicitly, or `transport: 'auto'` can try Streamable HTTP first and fall back to SSE only on a compatible non-authentication 4xx response.
89
+
90
+ ```typescript
91
+ import { Agent, McpBridge, OllamaClient, ToolRegistry } from '@nemesis-oss/ollama-sdk';
92
+ import { connectMcpHttpClient } from '@nemesis-oss/ollama-sdk/mcp/http';
93
+
94
+ const connection = await connectMcpHttpClient({
95
+ url: 'https://example.com/mcp',
96
+ transport: 'auto',
97
+ requestInit: {
98
+ headers: { Authorization: `Bearer ${process.env.MCP_TOKEN}` },
99
+ },
100
+ });
101
+
102
+ const bridge = new McpBridge(connection.client, { namePrefix: 'mcp_' });
103
+ const registry = new ToolRegistry();
104
+ await bridge.register(registry);
105
+
106
+ const agent = new Agent(new OllamaClient(), { tools: registry });
107
+ const result = await agent.run({
108
+ model: 'qwen3',
109
+ messages: [{ role: 'user', content: 'Use the MCP tools to complete this task.' }],
110
+ });
111
+
112
+ await connection.close();
113
+ console.log(result.finalMessage.content);
114
+ ```
115
+
116
+ Use `connection.terminateSession()` when the remote Streamable HTTP server exposes a session you want to terminate explicitly. Legacy SSE connections do not expose that method.
117
+
118
+ The MCP TypeScript SDK v2 treats Streamable HTTP as the preferred remote transport and keeps SSE as a legacy fallback during migration. citeturn514654search0turn514654search6
119
+
120
+ The MCP TypeScript SDK v2 implements the 2026-07-28 protocol revision. Its `listTools()` client path aggregates pagination, and `CallToolResult` represents tool failures as ordinary results with `isError`, while `structuredContent` is available for machine-readable output. citeturn0search0turn1search4turn0search6
121
+
122
+ ### MCP stdio example
123
+
124
+ ```typescript
125
+ import { connectStdioMcpClient } from '@nemesis-oss/ollama-sdk/mcp/stdio';
126
+ import { Agent, McpBridge, OllamaClient, ToolRegistry } from '@nemesis-oss/ollama-sdk';
127
+
128
+ const connection = await connectStdioMcpClient({
129
+ command: 'npx',
130
+ args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'],
131
+ });
132
+
133
+ const bridge = new McpBridge(connection.client, { namePrefix: 'mcp_' });
134
+ const registry = new ToolRegistry();
135
+ await bridge.register(registry);
136
+
137
+ const client = new OllamaClient();
138
+ const agent = new Agent(client, { tools: registry });
139
+ const result = await agent.run({
140
+ model: 'qwen3',
141
+ messages: [{ role: 'user', content: 'List the available files.' }],
142
+ });
143
+
144
+ await connection.close();
145
+ console.log(result.finalMessage.content);
146
+ ```
147
+
27
148
  ## Installation
28
149
 
29
150
  ```bash
@@ -53,10 +174,13 @@ console.log(answer);
53
174
 
54
175
  ### Thinking & Reasoning Token Streams
55
176
 
177
+ `think` is Ollama's native reasoning-effort parameter, exposed directly on `chat()`/`generate()`. It accepts `true`, `false`, `null` (model default), or a model-defined string. Use `client.capabilities(model).thinking` to discover the exact string values and default reported by `/api/show`. The OpenAI bridge's `reasoning_effort`/`reasoning.effort` follows the same model-defined convention.
178
+
56
179
  ```typescript
57
180
  const stream = await client.chatStream({
58
181
  model: 'qwen3:8b',
59
182
  messages: [{ role: 'user', content: 'What is 18 * 4?' }],
183
+ think: 'high',
60
184
  options: { temperature: 0 },
61
185
  });
62
186
 
@@ -72,6 +196,61 @@ const final = await stream.finalResult;
72
196
  console.log(`\nEval tokens/sec: ${final.usage?.tokensPerSecond}`);
73
197
  ```
74
198
 
199
+ ### Cached Prompt Tokens
200
+
201
+ Ollama reports `prompt_eval_cached_count` as the number of prompt tokens read from the KV cache. The raw value is preserved on native chat/generate responses, and normalized stream usage exposes it as `cachedPromptTokens`.
202
+
203
+ ```typescript
204
+ const res = await client.chat({
205
+ model: 'gpt-oss:20b',
206
+ messages: [{ role: 'user', content: 'hello' }],
207
+ });
208
+
209
+ console.log(res.prompt_eval_count, res.prompt_eval_cached_count);
210
+ console.log(res.prompt_eval_cached_count ?? 0);
211
+ ```
212
+
213
+ ### Token Log Probabilities (`logprobs`)
214
+
215
+ Set `logprobs: true` (optionally with `top_logprobs`) on `chat()`/`generate()` to get per-token log probabilities back — useful for confidence scoring, speculative decoding, or agent routing decisions.
216
+
217
+ ```typescript
218
+ const res = await client.chat({
219
+ model: 'llama3.2',
220
+ messages: [{ role: 'user', content: 'Is Paris the capital of France?' }],
221
+ logprobs: true,
222
+ top_logprobs: 3,
223
+ stream: false,
224
+ });
225
+
226
+ for (const entry of res.logprobs ?? []) {
227
+ console.log(entry.token, entry.logprob, entry.top_logprobs);
228
+ }
229
+ ```
230
+
231
+ ### Multimodal / Vision Input
232
+
233
+ `images` on a `Message` (or on `generate()`'s top-level request) accepts base64-encoded strings or raw `Uint8Array` bytes — `Uint8Array` entries are base64-encoded automatically before the request is sent.
234
+
235
+ ```typescript
236
+ import { readFile } from 'node:fs/promises';
237
+
238
+ const imageBytes = await readFile('./cat.png'); // Buffer, a Uint8Array subclass
239
+
240
+ const res = await client.chatText({
241
+ model: 'llava',
242
+ messages: [{ role: 'user', content: 'What is in this image?', images: [imageBytes] }],
243
+ });
244
+ console.log(res);
245
+
246
+ // Base64 strings work too, unchanged:
247
+ const base64Res = await client.generateText({
248
+ model: 'llava',
249
+ prompt: 'Describe this image.',
250
+ images: ['iVBORw0KGgoAAAANSUhEUgAA...'],
251
+ });
252
+ ```
253
+
75
254
  ### Structured Outputs with Zod
76
255
 
77
256
  ```typescript
@@ -112,6 +291,51 @@ console.log(
112
291
  );
113
292
  ```
114
293
 
294
+ `embed()` targets the modern `/api/embed` endpoint (batch `input`, `truncate`, and `dimensions` truncation are all supported). The older single-prompt `/api/embeddings` is still available as `client.embeddings()`, but it's `@deprecated` — Ollama's own docs consider it legacy in favor of `/api/embed`.
295
+
296
+ ### Model Lifecycle Management
297
+
298
+ Full parity with Ollama's model catalog and blob-store API — every method targets one specific endpoint's local state and deliberately does **not** cross-endpoint fail over (see [ADR 0008](./docs/adr/0008-endpoint-failover-scope.md)):
299
+
300
+ ```typescript
301
+ // What's currently loaded in VRAM right now
302
+ const running = await client.ps();
303
+ console.log(running.models.map((m) => `${m.name} (${m.size_vram} bytes VRAM)`));
304
+
305
+ // Create a custom model from an existing base, without hand-writing a Modelfile string
306
+ await client.createModel({
307
+ model: 'my-assistant',
308
+ from: 'llama3.2',
309
+ system: 'You are a terse, no-nonsense assistant.',
310
+ parameters: { temperature: 0.2 },
311
+ });
312
+
313
+ await client.copyModel({ source: 'my-assistant', destination: 'my-assistant-backup' });
314
+ await client.pushModel({ model: 'my-namespace/my-assistant' });
315
+ await client.deleteModel({ model: 'my-assistant-backup' });
316
+ ```
317
+
318
+ ### Web Search & Web Fetch (Ollama Cloud)
319
+
320
+ `webSearch`/`webFetch` wrap Ollama's **hosted** web tools (`POST https://ollama.com/api/web_search` and `/api/web_fetch`) — a fixed Ollama Cloud service, entirely separate from whatever local `baseUrl`/`endpoints` the client is configured with. They require an Ollama account API key (`apiKey` on the client, or the `OLLAMA_API_KEY` environment variable) regardless of where your inference traffic goes:
321
+
322
+ ```typescript
323
+ const client = new OllamaClient({
324
+ baseUrl: 'http://localhost:11434', // local inference — unrelated to the calls below
325
+ apiKey: process.env.OLLAMA_API_KEY, // required for webSearch/webFetch specifically
326
+ });
327
+
328
+ const search = await client.webSearch({ query: 'latest Ollama release notes', max_results: 5 });
329
+ for (const result of search.results) {
330
+ console.log(result.title, result.url, result.content);
331
+ }
332
+
333
+ const page = await client.webFetch({ url: 'https://ollama.com/blog' });
334
+ console.log(page.title, page.content.slice(0, 200));
335
+ ```
336
+
337
+ These two methods don't participate in the multi-endpoint failover below — there's only ever the one cloud host to call — but they do use the same default `timeoutMs` and retry policy as everything else.
338
+
115
339
  ### Autonomous Agent & Tool Calling
116
340
 
117
341
  ```typescript
@@ -138,12 +362,59 @@ const response = await agent.run({
138
362
  console.log(response.finalMessage.content);
139
363
  ```
140
364
 
141
- Ollama's native tool-calling protocol has no OpenAI-style call ID, but the SDK
142
- synthesizes a stable one so results are still correlatable without tracking array
143
- position by hand: `response.turns[0].toolCalls[0].id` matches
144
- `response.turns[0].toolResults[0].toolCallId`, and the `role: 'tool'` message `Agent`
145
- appends to history carries the same value as `tool_call_id`. See
146
- [ADR 0007](./docs/adr/0007-synthetic-tool-call-ids.md).
365
+ Ollama's native tool-calling protocol has no OpenAI-style call ID, so the SDK
366
+ synthesizes a stable client-side ID for tracing and execution correlation:
367
+ `response.turns[0].toolCalls[0].id` matches
368
+ `response.turns[0].toolResults[0].toolCallId`. Native `role: 'tool'` history entries
369
+ use Ollama's documented `tool_name` field; the SDK-local `toolCallId` is not sent on
370
+ the wire. See [ADR 0007](./docs/adr/0007-synthetic-tool-call-ids.md).
371
+
372
+ Running several `Agent`s against different models/API keys for different roles (e.g. a
373
+ planning model, a coding model, a research model) is a single `OllamaClient` with
374
+ per-endpoint `models` allow-lists (see
375
+ ["Multiple API keys, each entitled to different models"](#multiple-api-keys-each-entitled-to-different-models)) —
376
+ the client resolves the right key from the model name, and `Agent` itself stays
377
+ unaware of credentials entirely. See
378
+ [Guide: Benchmarking Agent Models Across Multiple Ollama Cloud Keys](./docs/guides/multi-model-agent-benchmarking.md)
379
+ for a worked example and a runnable scenario.
380
+
381
+ ### MCP Bridge & Agent Capability Preflight
382
+
383
+ The SDK exposes `McpBridge` as a transport-neutral adapter between an MCP client and
384
+ Ollama's native function-tool format. The bridge does not own the MCP transport or spawn
385
+ processes; your application supplies an MCP client with `listTools()` and `callTool()`.
386
+ This keeps the package root Edge-runtime safe while still supporting Node-specific
387
+ transports such as stdio through your chosen MCP implementation.
388
+
389
+ ```typescript
390
+ import { McpBridge, ToolRegistry } from '@nemesis-oss/ollama-sdk';
391
+
392
+ const bridge = new McpBridge(mcpClient, { namePrefix: 'mcp_' });
393
+
394
+ const registry = new ToolRegistry();
395
+ await bridge.register(registry);
396
+
397
+ // The same MCP definitions can also be inspected before registration.
398
+ const definitions = await bridge.definitions();
399
+ console.log(definitions);
400
+ ```
401
+
402
+ For tool-enabled `Agent` runs, capability preflight is enabled by default when the
403
+ chat client exposes `capabilities()` (including `OllamaClient`). The agent queries
404
+ `/api/show` before the first model turn and throws `OllamaIncompatibleModelError` when
405
+ the selected model does not advertise `tools`.
406
+
407
+ The same metadata can size the tool context automatically: when `options.num_ctx` is
408
+ not supplied, the default is `32768`, clamped to the model-reported
409
+ `capabilities.contextLength` when available. An explicit `options.num_ctx` always
410
+ takes precedence.
411
+
412
+ For legacy or custom `AgentChatClient` implementations, set
413
+ `validateToolCapability: false` to skip capability discovery and the automatic
414
+ context-size override.
415
+
416
+ See [ADR 0011](./docs/adr/0011-mcp-boundary-and-agent-tool-preconditions.md) for the
417
+ MCP boundary and agent-precondition rationale.
147
418
 
148
419
  ### Tool Execution Safety & Sandboxing
149
420
 
@@ -271,6 +542,224 @@ etc.) and `capabilities()` target one specific endpoint's local state and delibe
271
542
  server doesn't retry the same operation, it silently acts on a different model catalog.
272
543
  See [ADR 0008](./docs/adr/0008-endpoint-failover-scope.md).
273
544
 
545
+ #### Multiple API keys, each entitled to different models
546
+
547
+ A common Ollama Cloud shape: several API keys, each unlocking a different set of models
548
+ under your plan (e.g. one free-tier key per model family). Give each endpoint a `models`
549
+ allow-list and the client resolves the right credential from the `model` you request —
550
+ cross-endpoint failover only ever considers endpoints actually authorized for that model,
551
+ so it never burns a request retrying an unrelated key:
552
+
553
+ ```typescript
554
+ const client = new OllamaClient({
555
+ baseUrl: 'https://ollama.com',
556
+ endpoints: [
557
+ {
558
+ name: 'gpt-oss-key',
559
+ apiKey: process.env.OLLAMA_KEY_1!,
560
+ baseUrl: 'https://ollama.com',
561
+ models: ['gpt-oss:120b'],
562
+ },
563
+ {
564
+ name: 'minimax-key',
565
+ apiKey: process.env.OLLAMA_KEY_2!,
566
+ baseUrl: 'https://ollama.com',
567
+ models: ['minimax-m3'],
568
+ },
569
+ {
570
+ name: 'nemotron-key',
571
+ apiKey: process.env.OLLAMA_KEY_3!,
572
+ baseUrl: 'https://ollama.com',
573
+ models: ['nemotron-3-super'],
574
+ },
575
+ ],
576
+ });
577
+
578
+ // Routed to KEY_1 automatically:
579
+ await client.chat({ model: 'gpt-oss:120b', messages });
580
+ // Routed to KEY_2 automatically:
581
+ await client.chat({ model: 'minimax-m3', messages });
582
+ ```
583
+
584
+ An endpoint with no `models` field stays eligible for every model (the pre-existing
585
+ behavior), so this is fully opt-in and mixes freely with unscoped endpoints. Requesting a
586
+ model no configured endpoint is scoped to throws `OllamaModelRoutingError` immediately —
587
+ no network call, no probing every key to see which one happens to work. Two or more
588
+ endpoints can share the same model in their `models` list to get ordinary failover
589
+ between multiple keys/replicas for that one model. See
590
+ [Guide: Benchmarking Agent Models Across Multiple Ollama Cloud Keys](./docs/guides/multi-model-agent-benchmarking.md)
591
+ for the full multi-key/multi-role pattern this was built for.
592
+
593
+ **`credentials` + `modelBindings`** is an equivalent, map-based way to write the same
594
+ config, if you prefer keying by an id you choose over an array of endpoint objects — both
595
+ compile down to the same `endpoints`/`models` routing underneath, so pick whichever reads
596
+ better in your codebase:
597
+
598
+ ```typescript
599
+ const client = new OllamaClient({
600
+ baseUrl: 'https://ollama.com',
601
+ credentials: {
602
+ supervisor: { apiKey: process.env.OLLAMA_KEY_1! },
603
+ coder: { apiKey: process.env.OLLAMA_KEY_2! },
604
+ researcher: { apiKey: process.env.OLLAMA_KEY_3! },
605
+ },
606
+ modelBindings: {
607
+ 'gpt-oss:120b': 'supervisor',
608
+ 'minimax-m3': 'coder',
609
+ 'nemotron-3-super': 'researcher',
610
+ // A model can also be bound to several credentials — failover applies between them:
611
+ // 'gpt-oss:120b': ['supervisor', 'supervisor-backup'],
612
+ },
613
+ // Optional: serves any model with no entry above, at lower priority than an explicit binding.
614
+ // defaultCredential: 'supervisor',
615
+ });
616
+ ```
617
+
618
+ `modelBindings` referencing a `credentials` id that doesn't exist throws immediately at
619
+ construction — a typo in this config fails loudly, not by silently routing nowhere.
620
+ `credentials`/`modelBindings` merge additively with an `endpoints` array if you pass both.
621
+
622
+ #### A free pool of interchangeable keys — and spreading load across it
623
+
624
+ Register several keys as `credentials` and skip `modelBindings` for them entirely: an
625
+ unbound credential is eligible for every model, so any of them can serve any request:
626
+
627
+ ```typescript
628
+ const client = new OllamaClient({
629
+ baseUrl: 'https://ollama.com',
630
+ credentials: {
631
+ key1: { apiKey: process.env.OLLAMA_KEY_1! },
632
+ key2: { apiKey: process.env.OLLAMA_KEY_2! },
633
+ key3: { apiKey: process.env.OLLAMA_KEY_3! },
634
+ },
635
+ });
636
+
637
+ await client.chat({ model: 'any-model-you-like', messages });
638
+ ```
639
+
640
+ By default, candidates at the same priority (the case here — none of these keys were
641
+ given an explicit `priority`) are tried in registration order every time: `key1` first,
642
+ falling over to `key2`/`key3` only if `key1` fails. To spread consecutive requests across
643
+ the pool instead — so `key1`, `key2`, `key3` each take a turn rather than `key1` always
644
+ going first — set `endpointHealth: { strategy: 'round-robin' }`:
645
+
646
+ ```typescript
647
+ const client = new OllamaClient({
648
+ baseUrl: 'https://ollama.com',
649
+ credentials: {
650
+ key1: { apiKey: process.env.OLLAMA_KEY_1! },
651
+ key2: { apiKey: process.env.OLLAMA_KEY_2! },
652
+ key3: { apiKey: process.env.OLLAMA_KEY_3! },
653
+ },
654
+ endpointHealth: { strategy: 'round-robin' },
655
+ });
656
+ ```
657
+
658
+ Each `chat`/`generate`/`embed`/etc. call rotates the starting candidate by one position
659
+ within its priority tier — `key1, key2, key3, key1, key2, key3, ...` — while failover
660
+ still applies if whichever key ends up first happens to fail. Round-robin never lets a
661
+ lower-priority candidate jump ahead of a higher-priority one; it only reorders candidates
662
+ that were already tied. This works identically with plain `endpoints` (no `credentials`
663
+ required) and composes with `models`/`modelBindings` scoping — a scoped credential's tier
664
+ of one is unaffected, only an actual multi-candidate tier rotates.
665
+
666
+ #### Concurrent requests across single-slot accounts (least-connections)
667
+
668
+ Round-robin spreads requests over time, but it doesn't track whether a previous request
669
+ on a given account is still running — with uneven request durations, two round-robin
670
+ picks can still land on the same still-busy account back to back. That matters
671
+ specifically for Ollama Cloud's free tier, which caps each account at **1 concurrent
672
+ request**: if your application fires several requests at once (e.g. `Promise.all` across
673
+ different models) using $N$ free-tier accounts, you want a guarantee that no two land on
674
+ the same account while it's still busy — not just "spread out on average".
675
+
676
+ `strategy: 'least-connections'` provides that guarantee. Each request is routed to
677
+ whichever candidate currently has the fewest requests still in flight:
678
+
679
+ ```typescript
680
+ const client = new OllamaClient({
681
+ baseUrl: 'https://ollama.com',
682
+ credentials: {
683
+ account1: { apiKey: process.env.OLLAMA_KEY_1! },
684
+ account2: { apiKey: process.env.OLLAMA_KEY_2! },
685
+ account3: { apiKey: process.env.OLLAMA_KEY_3! },
686
+ },
687
+ endpointHealth: { strategy: 'least-connections' },
688
+ });
689
+
690
+ // Each of these lands on a different account — none has to wait on another's
691
+ // in-flight request, and no single free-tier account gets hit with a 2nd concurrent
692
+ // request while its 1st is still running.
693
+ const [llama, qwen, mistral] = await Promise.all([
694
+ client.chat({ model: 'llama3', messages: llamaMessages }),
695
+ client.chat({ model: 'qwen2.5', messages: qwenMessages }),
696
+ client.chat({ model: 'mistral', messages: mistralMessages }),
697
+ ]);
698
+ ```
699
+
700
+ This is deterministic, not probabilistic: the endpoint chosen for one request and the
701
+ `acquire()` that marks it in-flight happen synchronously with no `await` in between, and
702
+ JS's single-threaded execution means no two concurrent calls can ever observe the same
703
+ "0 active" snapshot for the same candidate — so `N` concurrent calls against `N`
704
+ same-priority candidates always land on `N` distinct ones, regardless of how the calls
705
+ happen to interleave. In-flight counts are also exposed via `client.endpointStatus()[].activeRequests`
706
+ for observability, and release automatically on both success and failure (including when
707
+ an account's actual 429 forces failover to the next-least-busy candidate). Priority tiers
708
+ are respected the same way as `'round-robin'` — a higher-priority candidate is still
709
+ always tried first regardless of its active count.
710
+
711
+ #### Queueing past capacity, instead of overrunning an account
712
+
713
+ `'least-connections'` alone only guarantees no collision for up to `N` _simultaneous_
714
+ calls against `N` candidates — an `(N+1)`th concurrent call would still be routed to
715
+ whichever account looks least busy at that instant, which, once all `N` already have one
716
+ request each, means sending it to an account that's already at its real limit. Add
717
+ `maxConcurrentPerEndpoint` to cap that exactly and queue instead:
718
+
719
+ ```typescript
720
+ const client = new OllamaClient({
721
+ baseUrl: 'https://ollama.com',
722
+ credentials: {
723
+ account1: { apiKey: process.env.OLLAMA_KEY_1! },
724
+ account2: { apiKey: process.env.OLLAMA_KEY_2! },
725
+ },
726
+ endpointHealth: { strategy: 'least-connections', maxConcurrentPerEndpoint: 1 },
727
+ });
728
+
729
+ // With 2 accounts capped at 1 request each: the first two calls run immediately, one
730
+ // per account; the third waits (no fetch is made for it yet) until either finishes,
731
+ // then takes that freed slot.
732
+ const [a, b, c] = await Promise.all([
733
+ client.chat({ model: 'm1', messages: m1Messages }),
734
+ client.chat({ model: 'm2', messages: m2Messages }),
735
+ client.chat({ model: 'm3', messages: m3Messages }),
736
+ ]);
737
+ ```
738
+
739
+ Waiting is bounded by the same `timeoutMs`/`AbortSignal` as the rest of the request — a
740
+ queued call that times out or is cancelled is removed from the queue and rejects without
741
+ ever having been sent, rather than hanging indefinitely. Queue wake order is best-effort
742
+ FIFO (a slot that frees can, rarely, be won by a brand-new call instead of the
743
+ longest-waiting one) — the exact cap, not fairness, is the guarantee this exists for.
744
+
745
+ #### Slot lifecycle: streaming and tool-calling agents
746
+
747
+ Two details matter for the concurrency accounting (`'least-connections'`/
748
+ `maxConcurrentPerEndpoint`/`activeRequests`) to reflect reality rather than just the
749
+ initial HTTP round trip:
750
+
751
+ - **Streaming (`chatStream`/`generateStream`) holds the slot for as long as the stream is
752
+ actually being consumed**, not just until the response headers arrive. The promise
753
+ `chatStream` returns resolves as soon as the stream object exists (mirroring the real
754
+ HTTP connection, which is still open at that point); the slot releases only once the
755
+ stream is fully drained, errors, or is aborted. A returned stream that's never iterated
756
+ (or `.on()`'d) holds its slot indefinitely — same as the underlying HTTP connection
757
+ would stay open — so always consume or `stream.abort()` a stream you no longer need.
758
+ - **`Agent`'s tool-execution phase never holds a slot.** Each turn's `chat()` call
759
+ acquires and releases its own slot independently; tool execution happens entirely
760
+ between turns, outside any `chat()` call, so a slow tool never ties up one of your
761
+ scarce concurrent-request accounts.
762
+
274
763
  ---
275
764
 
276
765
  ### Observability with OpenTelemetry
@@ -304,6 +793,67 @@ for exactly which spans and attributes are emitted, and the tradeoffs behind tha
304
793
 
305
794
  ---
306
795
 
796
+ ### Quota Monitoring
797
+
798
+ Ollama Cloud doesn't expose account-level quota through the API — chat/generate
799
+ responses carry only per-request token counts, and there's no header or endpoint that
800
+ reports how much of your plan's session or weekly limit is left. The only way to see
801
+ that today is the [ollama.com dashboard](https://ollama.com) or the 90%-usage email (see
802
+ [ollama/ollama#15663](https://github.com/ollama/ollama/issues/15663)). Free-tier usage
803
+ resets on a session window (~5 hours) and a weekly window (7 days), and is measured in
804
+ compute, not a fixed token count, so a budget you set for one model won't transfer
805
+ exactly to another.
806
+
807
+ `QuotaManager` is a client-side safety net, not a mirror of Ollama's real limits: it
808
+ tracks usage you record against budgets _you_ configure over one or more rolling
809
+ windows, and fails fast — before a request is even sent — once a window's budget is
810
+ spent. Pair it with catching `OllamaRateLimitError` (the server's actual `429`) as the
811
+ authoritative signal.
812
+
813
+ ```typescript
814
+ import {
815
+ OllamaClient,
816
+ OllamaQuotaExceededError,
817
+ OllamaRateLimitError,
818
+ createOllamaCloudFreeTierQuota,
819
+ } from '@nemesis-oss/ollama-sdk';
820
+
821
+ const client = new OllamaClient({ apiKey: process.env.OLLAMA_API_KEY });
822
+
823
+ // Session (5h) and weekly (7d) windows, with budgets you choose empirically —
824
+ // Ollama doesn't publish the actual ceilings.
825
+ const quota = createOllamaCloudFreeTierQuota({
826
+ session: { maxTokens: 50_000 },
827
+ weekly: { maxTokens: 200_000 },
828
+ });
829
+
830
+ async function chatWithQuota(prompt: string) {
831
+ quota.assertCanProceed(); // throws OllamaQuotaExceededError if any window is spent
832
+
833
+ try {
834
+ const res = await client.chat({
835
+ model: 'qwen3:8b',
836
+ messages: [{ role: 'user', content: prompt }],
837
+ });
838
+ quota.recordUsage(res); // reads prompt_eval_count/eval_count off the raw response
839
+ return res.message.content;
840
+ } catch (error) {
841
+ if (error instanceof OllamaRateLimitError) {
842
+ console.warn('Server-side rate limit hit — pause until the session window resets.');
843
+ }
844
+ throw error;
845
+ }
846
+ }
847
+ ```
848
+
849
+ `quota.status()` returns per-window `tokensUsed`/`requestsMade`/`remainingTokens`/
850
+ `windowResetAt` for building your own usage dashboard, and `quota.reset(windowId?)` lets
851
+ you clear a window manually (e.g. after confirming a reset on the ollama.com dashboard).
852
+ For budgets that don't match the free tier's cadence, construct `new QuotaManager({
853
+ windows: [...] })` directly with your own `windowMs`/`maxTokens`/`maxRequests` per window.
854
+
855
+ ---
856
+
307
857
  ### Edge Runtime Compatibility
308
858
 
309
859
  The core client (`OllamaClient`, `Agent`, `ToolRegistry`, and everything exported from
@@ -322,6 +872,130 @@ real Edge Runtime sandbox exposing only Web Standard globals. See
322
872
 
323
873
  ---
324
874
 
875
+ ## Contract-First Architecture
876
+
877
+ Starting with v1.4.0, the SDK ships a **contract-first hybrid architecture** where a single
878
+ canonical IR (`contracts/ir/ollama.ir.json`) drives seven consumers:
879
+
880
+ 1. **TypeScript interfaces** — `src/generated/models/<name>.ts` (36 schemas)
881
+ 2. **Generated API classes** — `NativeApi` / `OpenAIApi` / `AnthropicApi` in `src/generated/api/`
882
+ 3. **MCP tool descriptors** — `src/generated/mcp/tools.json` (21 tools, one per documented operation)
883
+ 4. **Operation metadata** — `src/generated/metadata/operations.json`
884
+ 5. **Field-level parity** — overlay `parity:` blocks verified by `npm run verify:contract-parity`
885
+ 6. **Zod schemas** — `src/generated/models/<name>.schema.ts` (paired with every TypeScript interface)
886
+ 7. **Bidirectional endpoint discovery** — catches new Ollama endpoints like `/v1/systemone` that the OpenAPI spec doesn't yet cover
887
+
888
+ The IR is compiled from upstream OpenAPI + hand-maintained behavioral overlays by
889
+ `npm run contract:normalize`. Run `npm run contract:generate` to regenerate every TypeScript /
890
+ Zod / MCP artifact from the IR. See [ADRs 0013-0019](./docs/adr/README.md) for the full design.
891
+
892
+ ### Using the generated `NativeApi` (recommended for new code)
893
+
894
+ The generated surface is opt-in — existing `OllamaClient` callers don't need to change anything.
895
+ For new code, the generated API inherits every contract-layer guarantee (environment guards,
896
+ version guards, streaming defaults) automatically:
897
+
898
+ ```typescript
899
+ import { HttpClient } from '@nemesis-oss/ollama-sdk';
900
+ import { OllamaRuntime } from '@nemesis-oss/ollama-sdk/generated/runtime';
901
+ import { NativeApi } from '@nemesis-oss/ollama-sdk/generated/api';
902
+
903
+ const http = new HttpClient({ baseUrl: 'http://localhost:11434' });
904
+ const runtime = new OllamaRuntime({ http });
905
+ const api = new NativeApi(runtime);
906
+
907
+ // Non-streaming chat:
908
+ const res = await api.chat({
909
+ model: 'qwen3:8b',
910
+ messages: [{ role: 'user', content: 'Hello' }],
911
+ stream: false,
912
+ });
913
+
914
+ // Streaming chat — explicitly request a stream:
915
+ const stream = await api.chat({
916
+ model: 'qwen3:8b',
917
+ messages: [{ role: 'user', content: 'Hello' }],
918
+ stream: true,
919
+ });
920
+ for await (const chunk of stream) {
921
+ console.log(chunk.message?.content);
922
+ }
923
+ ```
924
+
925
+ ### Mixing the legacy client with the generated surface
926
+
927
+ `OllamaClient.runtime` returns a cached `OllamaRuntime` that shares the client's
928
+ transport (HttpClient + middleware + retry + telemetry), so you can mix both
929
+ surfaces in the same process without configuring two HttpClient instances:
930
+
931
+ ```typescript
932
+ import { OllamaClient } from '@nemesis-oss/ollama-sdk';
933
+ import { NativeApi } from '@nemesis-oss/ollama-sdk/generated/api';
934
+
935
+ const client = new OllamaClient({ baseUrl: 'http://localhost:11434' });
936
+
937
+ // Existing API:
938
+ const res = await client.chat({ model: 'qwen3:8b', messages });
939
+
940
+ // Generated API (shares transport):
941
+ const api = new NativeApi(client.runtime);
942
+ const res2 = await api.chat({ model: 'qwen3:8b', messages, stream: false });
943
+ ```
944
+
945
+ `OllamaClient` carries a deprecation notice pointing to `NativeApi` for new code, but
946
+ no method signatures have changed — existing callers continue to work.
947
+
948
+ ### Runtime validation with generated Zod schemas
949
+
950
+ Every TypeScript interface in `src/generated/models/<name>.ts` has a paired Zod schema
951
+ in `src/generated/models/<name>.schema.ts`. Use them for runtime validation of user
952
+ input, server responses, or anywhere you need to confirm a value matches the IR shape:
953
+
954
+ ```typescript
955
+ import { ChatRequestSchema } from '@nemesis-oss/ollama-sdk/generated/models/schemas';
956
+
957
+ const result = ChatRequestSchema.safeParse(userInput);
958
+ if (!result.success) {
959
+ console.error(result.error.issues);
960
+ } else {
961
+ // result.data is typed as ChatRequest
962
+ }
963
+ ```
964
+
965
+ ### Generated MCP tools
966
+
967
+ The IR also produces 21 MCP tool descriptors (one per documented Ollama operation) at
968
+ `src/generated/mcp/tools.json`. The runtime adapter at
969
+ `@nemesis-oss/ollama-sdk/mcp/generated` exposes them as a callable tool registry:
970
+
971
+ ```typescript
972
+ import { OllamaRuntime } from '@nemesis-oss/ollama-sdk/generated/runtime';
973
+ import {
974
+ listGeneratedOllamaTools,
975
+ callGeneratedOllamaTool,
976
+ } from '@nemesis-oss/ollama-sdk/mcp/generated';
977
+
978
+ const tools = listGeneratedOllamaTools();
979
+ // tools: [{ name: 'ollama_chat', inputSchema: {...}, annotations: {...} }, ...]
980
+
981
+ const result = await callGeneratedOllamaTool(runtime, 'ollama_version', {});
982
+ console.log(result.structuredContent); // { version: '0.5.0' }
983
+ ```
984
+
985
+ ### Contract maintenance commands
986
+
987
+ | Command | What it does |
988
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
989
+ | `npm run contract:fetch` | Re-pull the upstream OpenAPI spec into `contracts/sources/` |
990
+ | `npm run contract:normalize` | Compile sources + overlays → `contracts/ir/ollama.ir.json` |
991
+ | `npm run contract:validate` | Run schema + compatibility + bidirectional endpoint discovery validators |
992
+ | `npm run contract:diff` | Fail if the committed IR is stale (CI gate) |
993
+ | `npm run contract:generate` | Regenerate every TypeScript / Zod / MCP artifact from the IR (idempotent) |
994
+ | `npm run contract:drift` | Print drift report between generated types and `src/types.ts` (pass `--strict` to fail CI on unexpected drift) |
995
+ | `npm run verify:contract-parity` | Verify parity blocks against `src/types.ts` (structural) and `docs.ollama.com` (live-docs); pass `--skip-live-docs` for offline CI |
996
+
997
+ ---
998
+
325
999
  ## Error Handling
326
1000
 
327
1001
  Every failure thrown by the client is an `OllamaClientError` subclass, so you can catch the base
@@ -334,11 +1008,14 @@ class or narrow to a specific `code`:
334
1008
  | `OllamaAuthError` | `auth_error` | `false` | The endpoint returned `401`/`403`. |
335
1009
  | `OllamaNotFoundError` | `not_found` | `false` | The endpoint returned `404` (e.g. unknown model). |
336
1010
  | `OllamaRateLimitError` | `rate_limited` | `true` | The endpoint returned `429`. |
1011
+ | `OllamaQuotaExceededError` | `quota_exceeded` | `false` | `QuotaManager.assertCanProceed` was called and would exceed a configured usage budget. Thrown client-side, before any network call — see [Quota Monitoring](#quota-monitoring). |
1012
+ | `OllamaModelRoutingError` | `model_routing_error` | `false` | No configured `endpoints` entry's `models` allow-list includes the requested model. Thrown client-side, before any network call — see [Multiple API keys, each entitled to different models](#multiple-api-keys-each-entitled-to-different-models). |
337
1013
  | `OllamaServerError` | `server_error` | `true` | The endpoint returned `5xx`. |
338
1014
  | `OllamaAbortError` | `aborted` | `false` | The request was cancelled via `AbortSignal`. |
339
1015
  | `OllamaToolValidationError` | `tool_validation_error` | `false` | A tool call's arguments, or a `chatWithSchema`/`generateWithSchema` result, failed Zod validation. |
340
1016
  | `OllamaUnsupportedCapabilityError` | `unsupported_capability` | `false` | A `format` (structured output) request was made against an endpoint inferred as Ollama Cloud, which doesn't currently support it. Thrown before any network call; in `DEFAULT_FAILOVER_CODES`, so a multi-endpoint setup tries the next candidate first. |
341
- | `OllamaAgentMaxIterationsError` | `agent_max_iterations_exceeded` | `false` | An `Agent` run exceeded `maxTurns` without producing a final answer. |
1017
+ | `OllamaIncompatibleModelError` | `incompatible_model` | `false` | A tool-enabled `Agent` run was blocked by capability preflight because `/api/show` did not advertise `tools`. |
1018
+ | `OllamaAgentMaxIterationsError` | `agent_max_iterations_exceeded` | `false` | An `Agent` run exceeded `maxIterations` without producing a final answer. |
342
1019
  | `OllamaMcpError` | `mcp_error` | varies | An MCP `listTools`/`callTool` call failed. |
343
1020
  | `OllamaSkillNotFoundError` | `skill_not_found` | `false` | `applySkill` referenced a skill that isn't registered. |
344
1021
  | `OllamaSkillInvalidError` | `skill_invalid` | `false` | A skill's frontmatter or contents failed to parse. |
@@ -371,9 +1048,70 @@ observe per-endpoint circuit state directly.
371
1048
 
372
1049
  ---
373
1050
 
1051
+ ## Documentation
1052
+
1053
+ The repository maintains implementation-facing documentation alongside the package README:
1054
+
1055
+ - [Architecture Decision Records](./docs/adr/README.md) — rationale for durable API and architecture choices.
1056
+ - [Canonical Ollama IR](./contracts/ir/ollama.ir.json) — machine-readable Ollama contract compiled from sources + overlays, checked by CI.
1057
+ - [Multi-model agent benchmarking guide](./docs/guides/multi-model-agent-benchmarking.md) — running agent roles across multiple Ollama endpoints.
1058
+ - [Upstream compatibility notes](./docs/upstream/) — pinned OpenAI/Anthropic compatibility references and the upstream OpenAPI snapshot.
1059
+ - [Manual laboratory](./LAB_README.md) — runnable experiments for protocol, tool, streaming, and agent behavior.
1060
+
1061
+ ## Contract parity verification
1062
+
1063
+ `npm run verify:contract-parity` checks the canonical IR against the official Ollama
1064
+ documentation. The structural half verifies that every `parity.request.fields` /
1065
+ `parity.response.fields` entry exists on the hand-written TypeScript interfaces
1066
+ in `src/types.ts` and `src/integrations/*`. The live-docs half (when run without
1067
+ `--skip-live-docs`) fetches the rendered docs at `docs.ollama.com` and asserts each
1068
+ supported field is documented as supported, each unsupported field is explicitly
1069
+ marked as such, and each streaming event type is present.
1070
+
1071
+ The gate covers every documented native REST endpoint (`chat`, `generate`, `embed`,
1072
+ `tags`, `ps`, `show`, `create`, `copy`, `pull`, `push`, `delete`, and `version`)
1073
+ plus the OpenAI and Anthropic compatibility request surfaces. SDK-only fields
1074
+ that exist in `src/types.ts` but are absent from the OpenAPI snapshot are declared
1075
+ as `sdkOnlyFields` in the overlay parity blocks and treated as expected drift,
1076
+ not findings.
1077
+
1078
+ ## Middleware and request lifecycle
1079
+
1080
+ `OllamaClient` accepts `middleware` and `onLifecycleEvent` configuration. Middleware runs
1081
+ around the underlying HTTP request for native endpoints, OpenAI/Anthropic compatibility,
1082
+ model health checks, and the hosted web tools. Lifecycle events expose `start`, `success`,
1083
+ `retry`, and `error` events with a request id, timing, and status/error information.
1084
+
1085
+ ```ts
1086
+ const client = new OllamaClient({
1087
+ middleware: [
1088
+ async ({ request, next }) => {
1089
+ request.headers['X-Request-Source'] = 'my-app';
1090
+ return next();
1091
+ },
1092
+ ],
1093
+ onLifecycleEvent: (event) => {
1094
+ console.log(event.type, event.requestId);
1095
+ },
1096
+ });
1097
+ ```
1098
+
1099
+ Retry backoff is also cancellation-aware: passing an `AbortSignal` to `withRetry` or cancelling an `OllamaClient` request interrupts an in-progress backoff immediately instead of waiting for the next retry delay.
1100
+
1101
+ ## Compatibility routing and stream lifecycle
1102
+
1103
+ OpenAI and Anthropic compatibility requests use the same endpoint registry, model-scoped
1104
+ routing, failover, concurrency limits, and request cancellation as native inference calls.
1105
+ When `stream: true`, an endpoint capacity slot remains held until the compatibility stream
1106
+ finishes, errors, or is explicitly aborted.
1107
+
1108
+ Compatibility stream objects expose `.abort()` and `finalResult`. The configured request
1109
+ timeout remains active for the lifetime of the stream rather than ending when HTTP headers
1110
+ arrive.
1111
+
374
1112
  ## Testing
375
1113
 
376
- The test suite contains 114 automated tests across 4 testing tiers:
1114
+ The test suite is exercised across unit, integration, and functional coverage:
377
1115
 
378
1116
  ```bash
379
1117
  # Run unit, integration, and functional test suite