@nemesis-oss/ollama-sdk 1.0.0 → 1.3.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nemesis-oss/ollama-sdk
2
2
 
3
- [![CI](https://github.com/shubhamtaywade82/ollama-client-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/shubhamtaywade82/ollama-client-ts/actions/workflows/ci.yml)
3
+ [![CI](https://github.com/shubhamtaywade82/ollama-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/shubhamtaywade82/ollama-sdk/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/@nemesis-oss/ollama-sdk.svg)](https://www.npmjs.com/package/@nemesis-oss/ollama-sdk)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
6
 
@@ -11,14 +11,18 @@
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.5:2b`, `deepseek-r1`) 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
+ - 🌐 **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.
18
19
  - 🔌 **Model Context Protocol (MCP)**: Native adapters to convert MCP tools into Ollama-compatible function schemas.
19
- - 🌉 **OpenAI & Anthropic Compatibility Bridges**: Built-in clients for `/v1/chat/completions`, `/v1/models`, and `/v1/messages`.
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`.
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.
24
28
 
@@ -45,7 +49,7 @@ const client = new OllamaClient();
45
49
 
46
50
  // Text helper
47
51
  const answer = await client.chatText({
48
- model: 'qwen3.5:2b',
52
+ model: 'qwen3:8b',
49
53
  messages: [{ role: 'user', content: 'Explain quantum computing in one sentence.' }],
50
54
  });
51
55
  console.log(answer);
@@ -53,10 +57,13 @@ console.log(answer);
53
57
 
54
58
  ### Thinking & Reasoning Token Streams
55
59
 
60
+ `think` is Ollama's native reasoning-effort parameter, exposed directly on `chat()`/`generate()` — no bridge translation needed. It accepts `true`/`false` or a level (`'low' | 'medium' | 'high' | 'max'`); the OpenAI bridge's `reasoning_effort` (see below) is a compatibility alias for the same underlying knob.
61
+
56
62
  ```typescript
57
63
  const stream = await client.chatStream({
58
- model: 'qwen3.5:2b',
64
+ model: 'qwen3:8b',
59
65
  messages: [{ role: 'user', content: 'What is 18 * 4?' }],
66
+ think: 'high',
60
67
  options: { temperature: 0 },
61
68
  });
62
69
 
@@ -72,6 +79,47 @@ const final = await stream.finalResult;
72
79
  console.log(`\nEval tokens/sec: ${final.usage?.tokensPerSecond}`);
73
80
  ```
74
81
 
82
+ ### Token Log Probabilities (`logprobs`)
83
+
84
+ 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.
85
+
86
+ ```typescript
87
+ const res = await client.chat({
88
+ model: 'llama3.2',
89
+ messages: [{ role: 'user', content: 'Is Paris the capital of France?' }],
90
+ logprobs: true,
91
+ top_logprobs: 3,
92
+ stream: false,
93
+ });
94
+
95
+ for (const entry of res.logprobs ?? []) {
96
+ console.log(entry.token, entry.logprob, entry.top_logprobs);
97
+ }
98
+ ```
99
+
100
+ ### Multimodal / Vision Input
101
+
102
+ `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.
103
+
104
+ ```typescript
105
+ import { readFile } from 'node:fs/promises';
106
+
107
+ const imageBytes = await readFile('./cat.png'); // Buffer, a Uint8Array subclass
108
+
109
+ const res = await client.chatText({
110
+ model: 'llava',
111
+ messages: [{ role: 'user', content: 'What is in this image?', images: [imageBytes] }],
112
+ });
113
+ console.log(res);
114
+
115
+ // Base64 strings work too, unchanged:
116
+ const base64Res = await client.generateText({
117
+ model: 'llava',
118
+ prompt: 'Describe this image.',
119
+ images: ['iVBORw0KGgoAAAANSUhEUgAA...'],
120
+ });
121
+ ```
122
+
75
123
  ### Structured Outputs with Zod
76
124
 
77
125
  ```typescript
@@ -86,7 +134,7 @@ const ProductSchema = z.object({
86
134
 
87
135
  const product = await client.chatWithSchema(
88
136
  {
89
- model: 'qwen3.5:2b',
137
+ model: 'qwen3:8b',
90
138
  messages: [{ role: 'user', content: 'Generate a gaming keyboard item.' }],
91
139
  },
92
140
  ProductSchema,
@@ -112,6 +160,51 @@ console.log(
112
160
  );
113
161
  ```
114
162
 
163
+ `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`.
164
+
165
+ ### Model Lifecycle Management
166
+
167
+ 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)):
168
+
169
+ ```typescript
170
+ // What's currently loaded in VRAM right now
171
+ const running = await client.ps();
172
+ console.log(running.models.map((m) => `${m.name} (${m.size_vram} bytes VRAM)`));
173
+
174
+ // Create a custom model from an existing base, without hand-writing a Modelfile string
175
+ await client.createModel({
176
+ model: 'my-assistant',
177
+ from: 'llama3.2',
178
+ system: 'You are a terse, no-nonsense assistant.',
179
+ parameters: { temperature: 0.2 },
180
+ });
181
+
182
+ await client.copyModel({ source: 'my-assistant', destination: 'my-assistant-backup' });
183
+ await client.pushModel({ model: 'my-namespace/my-assistant' });
184
+ await client.deleteModel({ model: 'my-assistant-backup' });
185
+ ```
186
+
187
+ ### Web Search & Web Fetch (Ollama Cloud)
188
+
189
+ `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:
190
+
191
+ ```typescript
192
+ const client = new OllamaClient({
193
+ baseUrl: 'http://localhost:11434', // local inference — unrelated to the calls below
194
+ apiKey: process.env.OLLAMA_API_KEY, // required for webSearch/webFetch specifically
195
+ });
196
+
197
+ const search = await client.webSearch({ query: 'latest Ollama release notes', max_results: 5 });
198
+ for (const result of search.results) {
199
+ console.log(result.title, result.url, result.content);
200
+ }
201
+
202
+ const page = await client.webFetch({ url: 'https://ollama.com/blog' });
203
+ console.log(page.title, page.content.slice(0, 200));
204
+ ```
205
+
206
+ 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.
207
+
115
208
  ### Autonomous Agent & Tool Calling
116
209
 
117
210
  ```typescript
@@ -131,7 +224,7 @@ const registry = new ToolRegistry([weatherTool]);
131
224
  const agent = new Agent(client, { tools: registry, maxIterations: 5 });
132
225
 
133
226
  const response = await agent.run({
134
- model: 'qwen3.5:2b',
227
+ model: 'qwen3:8b',
135
228
  messages: [{ role: 'user', content: 'What is the weather in Tokyo?' }],
136
229
  });
137
230
 
@@ -145,6 +238,15 @@ position by hand: `response.turns[0].toolCalls[0].id` matches
145
238
  appends to history carries the same value as `tool_call_id`. See
146
239
  [ADR 0007](./docs/adr/0007-synthetic-tool-call-ids.md).
147
240
 
241
+ Running several `Agent`s against different models/API keys for different roles (e.g. a
242
+ planning model, a coding model, a research model) is a single `OllamaClient` with
243
+ per-endpoint `models` allow-lists (see
244
+ ["Multiple API keys, each entitled to different models"](#multiple-api-keys-each-entitled-to-different-models)) —
245
+ the client resolves the right key from the model name, and `Agent` itself stays
246
+ unaware of credentials entirely. See
247
+ [Guide: Benchmarking Agent Models Across Multiple Ollama Cloud Keys](./docs/guides/multi-model-agent-benchmarking.md)
248
+ for a worked example and a runnable scenario.
249
+
148
250
  ### Tool Execution Safety & Sandboxing
149
251
 
150
252
  Tool arguments and, indirectly, which tools get called at all are driven by model
@@ -191,7 +293,7 @@ import { toResponse } from '@nemesis-oss/ollama-sdk';
191
293
  export async function POST(req: Request) {
192
294
  const { messages } = await req.json();
193
295
  const stream = await client.chatStream({
194
- model: 'qwen3.5:2b',
296
+ model: 'qwen3:8b',
195
297
  messages,
196
298
  });
197
299
 
@@ -208,6 +310,13 @@ const openAIRes = await client.openai.chatCompletions({
208
310
  messages: [{ role: 'user', content: 'Hello via OpenAI bridge' }],
209
311
  });
210
312
 
313
+ // OpenAI Responses API endpoint (/v1/responses) — added in Ollama v0.13.3
314
+ const responsesRes = await client.openai.responses({
315
+ model: 'llama3.2',
316
+ input: 'Hello via the OpenAI Responses bridge',
317
+ });
318
+ console.log(responsesRes.output[0]?.content[0]?.text);
319
+
211
320
  // Anthropic compatibility endpoint (/v1/messages)
212
321
  const anthropicRes = await client.anthropic.messages({
213
322
  model: 'llama3.2',
@@ -215,6 +324,25 @@ const anthropicRes = await client.anthropic.messages({
215
324
  });
216
325
  ```
217
326
 
327
+ `/v1/responses` is implemented non-statefully by Ollama: send the full conversation in
328
+ `input` on every call — `previous_response_id` and `conversation` are accepted for
329
+ OpenAI request-shape compatibility but ignored (see `OpenAIResponsesRequest` JSDoc).
330
+
331
+ For thinking models (`deepseek-r1`, `qwen3`, etc.), both compatibility bridges' chat
332
+ completions request accept a reasoning effort knob:
333
+
334
+ ```typescript
335
+ await client.openai.chatCompletions({
336
+ model: 'deepseek-r1:8b',
337
+ messages: [{ role: 'user', content: 'Solve: 17 * 23' }],
338
+ reasoning_effort: 'high', // or `reasoning: { effort: 'high' }`
339
+ });
340
+ ```
341
+
342
+ `tool_choice` and `parallel_tool_calls` are also typed on the request so a standard
343
+ OpenAI request object type-checks unmodified, but Ollama's compat layer does not honor
344
+ either — see the `@remarks` on each field in `OpenAIChatCompletionRequest`.
345
+
218
346
  ### Multi-Endpoint High Availability Failover
219
347
 
220
348
  ```typescript
@@ -245,6 +373,209 @@ etc.) and `capabilities()` target one specific endpoint's local state and delibe
245
373
  server doesn't retry the same operation, it silently acts on a different model catalog.
246
374
  See [ADR 0008](./docs/adr/0008-endpoint-failover-scope.md).
247
375
 
376
+ #### Multiple API keys, each entitled to different models
377
+
378
+ A common Ollama Cloud shape: several API keys, each unlocking a different set of models
379
+ under your plan (e.g. one free-tier key per model family). Give each endpoint a `models`
380
+ allow-list and the client resolves the right credential from the `model` you request —
381
+ cross-endpoint failover only ever considers endpoints actually authorized for that model,
382
+ so it never burns a request retrying an unrelated key:
383
+
384
+ ```typescript
385
+ const client = new OllamaClient({
386
+ baseUrl: 'https://ollama.com',
387
+ endpoints: [
388
+ { name: 'gpt-oss-key', apiKey: process.env.OLLAMA_KEY_1!, baseUrl: 'https://ollama.com', models: ['gpt-oss:120b'] },
389
+ { name: 'minimax-key', apiKey: process.env.OLLAMA_KEY_2!, baseUrl: 'https://ollama.com', models: ['minimax-m3'] },
390
+ { name: 'nemotron-key', apiKey: process.env.OLLAMA_KEY_3!, baseUrl: 'https://ollama.com', models: ['nemotron-3-super'] },
391
+ ],
392
+ });
393
+
394
+ // Routed to KEY_1 automatically:
395
+ await client.chat({ model: 'gpt-oss:120b', messages });
396
+ // Routed to KEY_2 automatically:
397
+ await client.chat({ model: 'minimax-m3', messages });
398
+ ```
399
+
400
+ An endpoint with no `models` field stays eligible for every model (the pre-existing
401
+ behavior), so this is fully opt-in and mixes freely with unscoped endpoints. Requesting a
402
+ model no configured endpoint is scoped to throws `OllamaModelRoutingError` immediately —
403
+ no network call, no probing every key to see which one happens to work. Two or more
404
+ endpoints can share the same model in their `models` list to get ordinary failover
405
+ between multiple keys/replicas for that one model. See
406
+ [Guide: Benchmarking Agent Models Across Multiple Ollama Cloud Keys](./docs/guides/multi-model-agent-benchmarking.md)
407
+ for the full multi-key/multi-role pattern this was built for.
408
+
409
+ **`credentials` + `modelBindings`** is an equivalent, map-based way to write the same
410
+ config, if you prefer keying by an id you choose over an array of endpoint objects — both
411
+ compile down to the same `endpoints`/`models` routing underneath, so pick whichever reads
412
+ better in your codebase:
413
+
414
+ ```typescript
415
+ const client = new OllamaClient({
416
+ baseUrl: 'https://ollama.com',
417
+ credentials: {
418
+ supervisor: { apiKey: process.env.OLLAMA_KEY_1! },
419
+ coder: { apiKey: process.env.OLLAMA_KEY_2! },
420
+ researcher: { apiKey: process.env.OLLAMA_KEY_3! },
421
+ },
422
+ modelBindings: {
423
+ 'gpt-oss:120b': 'supervisor',
424
+ 'minimax-m3': 'coder',
425
+ 'nemotron-3-super': 'researcher',
426
+ // A model can also be bound to several credentials — failover applies between them:
427
+ // 'gpt-oss:120b': ['supervisor', 'supervisor-backup'],
428
+ },
429
+ // Optional: serves any model with no entry above, at lower priority than an explicit binding.
430
+ // defaultCredential: 'supervisor',
431
+ });
432
+ ```
433
+
434
+ `modelBindings` referencing a `credentials` id that doesn't exist throws immediately at
435
+ construction — a typo in this config fails loudly, not by silently routing nowhere.
436
+ `credentials`/`modelBindings` merge additively with an `endpoints` array if you pass both.
437
+
438
+ #### A free pool of interchangeable keys — and spreading load across it
439
+
440
+ Register several keys as `credentials` and skip `modelBindings` for them entirely: an
441
+ unbound credential is eligible for every model, so any of them can serve any request:
442
+
443
+ ```typescript
444
+ const client = new OllamaClient({
445
+ baseUrl: 'https://ollama.com',
446
+ credentials: {
447
+ key1: { apiKey: process.env.OLLAMA_KEY_1! },
448
+ key2: { apiKey: process.env.OLLAMA_KEY_2! },
449
+ key3: { apiKey: process.env.OLLAMA_KEY_3! },
450
+ },
451
+ });
452
+
453
+ await client.chat({ model: 'any-model-you-like', messages });
454
+ ```
455
+
456
+ By default, candidates at the same priority (the case here — none of these keys were
457
+ given an explicit `priority`) are tried in registration order every time: `key1` first,
458
+ falling over to `key2`/`key3` only if `key1` fails. To spread consecutive requests across
459
+ the pool instead — so `key1`, `key2`, `key3` each take a turn rather than `key1` always
460
+ going first — set `endpointHealth: { strategy: 'round-robin' }`:
461
+
462
+ ```typescript
463
+ const client = new OllamaClient({
464
+ baseUrl: 'https://ollama.com',
465
+ credentials: {
466
+ key1: { apiKey: process.env.OLLAMA_KEY_1! },
467
+ key2: { apiKey: process.env.OLLAMA_KEY_2! },
468
+ key3: { apiKey: process.env.OLLAMA_KEY_3! },
469
+ },
470
+ endpointHealth: { strategy: 'round-robin' },
471
+ });
472
+ ```
473
+
474
+ Each `chat`/`generate`/`embed`/etc. call rotates the starting candidate by one position
475
+ within its priority tier — `key1, key2, key3, key1, key2, key3, ...` — while failover
476
+ still applies if whichever key ends up first happens to fail. Round-robin never lets a
477
+ lower-priority candidate jump ahead of a higher-priority one; it only reorders candidates
478
+ that were already tied. This works identically with plain `endpoints` (no `credentials`
479
+ required) and composes with `models`/`modelBindings` scoping — a scoped credential's tier
480
+ of one is unaffected, only an actual multi-candidate tier rotates.
481
+
482
+ #### Concurrent requests across single-slot accounts (least-connections)
483
+
484
+ Round-robin spreads requests over time, but it doesn't track whether a previous request
485
+ on a given account is still running — with uneven request durations, two round-robin
486
+ picks can still land on the same still-busy account back to back. That matters
487
+ specifically for Ollama Cloud's free tier, which caps each account at **1 concurrent
488
+ request**: if your application fires several requests at once (e.g. `Promise.all` across
489
+ different models) using $N$ free-tier accounts, you want a guarantee that no two land on
490
+ the same account while it's still busy — not just "spread out on average".
491
+
492
+ `strategy: 'least-connections'` provides that guarantee. Each request is routed to
493
+ whichever candidate currently has the fewest requests still in flight:
494
+
495
+ ```typescript
496
+ const client = new OllamaClient({
497
+ baseUrl: 'https://ollama.com',
498
+ credentials: {
499
+ account1: { apiKey: process.env.OLLAMA_KEY_1! },
500
+ account2: { apiKey: process.env.OLLAMA_KEY_2! },
501
+ account3: { apiKey: process.env.OLLAMA_KEY_3! },
502
+ },
503
+ endpointHealth: { strategy: 'least-connections' },
504
+ });
505
+
506
+ // Each of these lands on a different account — none has to wait on another's
507
+ // in-flight request, and no single free-tier account gets hit with a 2nd concurrent
508
+ // request while its 1st is still running.
509
+ const [llama, qwen, mistral] = await Promise.all([
510
+ client.chat({ model: 'llama3', messages: llamaMessages }),
511
+ client.chat({ model: 'qwen2.5', messages: qwenMessages }),
512
+ client.chat({ model: 'mistral', messages: mistralMessages }),
513
+ ]);
514
+ ```
515
+
516
+ This is deterministic, not probabilistic: the endpoint chosen for one request and the
517
+ `acquire()` that marks it in-flight happen synchronously with no `await` in between, and
518
+ JS's single-threaded execution means no two concurrent calls can ever observe the same
519
+ "0 active" snapshot for the same candidate — so `N` concurrent calls against `N`
520
+ same-priority candidates always land on `N` distinct ones, regardless of how the calls
521
+ happen to interleave. In-flight counts are also exposed via `client.endpointStatus()[].activeRequests`
522
+ for observability, and release automatically on both success and failure (including when
523
+ an account's actual 429 forces failover to the next-least-busy candidate). Priority tiers
524
+ are respected the same way as `'round-robin'` — a higher-priority candidate is still
525
+ always tried first regardless of its active count.
526
+
527
+ #### Queueing past capacity, instead of overrunning an account
528
+
529
+ `'least-connections'` alone only guarantees no collision for up to `N` *simultaneous*
530
+ calls against `N` candidates — an `(N+1)`th concurrent call would still be routed to
531
+ whichever account looks least busy at that instant, which, once all `N` already have one
532
+ request each, means sending it to an account that's already at its real limit. Add
533
+ `maxConcurrentPerEndpoint` to cap that exactly and queue instead:
534
+
535
+ ```typescript
536
+ const client = new OllamaClient({
537
+ baseUrl: 'https://ollama.com',
538
+ credentials: {
539
+ account1: { apiKey: process.env.OLLAMA_KEY_1! },
540
+ account2: { apiKey: process.env.OLLAMA_KEY_2! },
541
+ },
542
+ endpointHealth: { strategy: 'least-connections', maxConcurrentPerEndpoint: 1 },
543
+ });
544
+
545
+ // With 2 accounts capped at 1 request each: the first two calls run immediately, one
546
+ // per account; the third waits (no fetch is made for it yet) until either finishes,
547
+ // then takes that freed slot.
548
+ const [a, b, c] = await Promise.all([
549
+ client.chat({ model: 'm1', messages: m1Messages }),
550
+ client.chat({ model: 'm2', messages: m2Messages }),
551
+ client.chat({ model: 'm3', messages: m3Messages }),
552
+ ]);
553
+ ```
554
+
555
+ Waiting is bounded by the same `timeoutMs`/`AbortSignal` as the rest of the request — a
556
+ queued call that times out or is cancelled is removed from the queue and rejects without
557
+ ever having been sent, rather than hanging indefinitely. Queue wake order is best-effort
558
+ FIFO (a slot that frees can, rarely, be won by a brand-new call instead of the
559
+ longest-waiting one) — the exact cap, not fairness, is the guarantee this exists for.
560
+
561
+ #### Slot lifecycle: streaming and tool-calling agents
562
+
563
+ Two details matter for the concurrency accounting (`'least-connections'`/
564
+ `maxConcurrentPerEndpoint`/`activeRequests`) to reflect reality rather than just the
565
+ initial HTTP round trip:
566
+
567
+ - **Streaming (`chatStream`/`generateStream`) holds the slot for as long as the stream is
568
+ actually being consumed**, not just until the response headers arrive. The promise
569
+ `chatStream` returns resolves as soon as the stream object exists (mirroring the real
570
+ HTTP connection, which is still open at that point); the slot releases only once the
571
+ stream is fully drained, errors, or is aborted. A returned stream that's never iterated
572
+ (or `.on()`'d) holds its slot indefinitely — same as the underlying HTTP connection
573
+ would stay open — so always consume or `stream.abort()` a stream you no longer need.
574
+ - **`Agent`'s tool-execution phase never holds a slot.** Each turn's `chat()` call
575
+ acquires and releases its own slot independently; tool execution happens entirely
576
+ between turns, outside any `chat()` call, so a slow tool never ties up one of your
577
+ scarce concurrent-request accounts.
578
+
248
579
  ---
249
580
 
250
581
  ### Observability with OpenTelemetry
@@ -278,6 +609,64 @@ for exactly which spans and attributes are emitted, and the tradeoffs behind tha
278
609
 
279
610
  ---
280
611
 
612
+ ### Quota Monitoring
613
+
614
+ Ollama Cloud doesn't expose account-level quota through the API — chat/generate
615
+ responses carry only per-request token counts, and there's no header or endpoint that
616
+ reports how much of your plan's session or weekly limit is left. The only way to see
617
+ that today is the [ollama.com dashboard](https://ollama.com) or the 90%-usage email (see
618
+ [ollama/ollama#15663](https://github.com/ollama/ollama/issues/15663)). Free-tier usage
619
+ resets on a session window (~5 hours) and a weekly window (7 days), and is measured in
620
+ compute, not a fixed token count, so a budget you set for one model won't transfer
621
+ exactly to another.
622
+
623
+ `QuotaManager` is a client-side safety net, not a mirror of Ollama's real limits: it
624
+ tracks usage you record against budgets *you* configure over one or more rolling
625
+ windows, and fails fast — before a request is even sent — once a window's budget is
626
+ spent. Pair it with catching `OllamaRateLimitError` (the server's actual `429`) as the
627
+ authoritative signal.
628
+
629
+ ```typescript
630
+ import {
631
+ OllamaClient,
632
+ OllamaQuotaExceededError,
633
+ OllamaRateLimitError,
634
+ createOllamaCloudFreeTierQuota,
635
+ } from '@nemesis-oss/ollama-sdk';
636
+
637
+ const client = new OllamaClient({ apiKey: process.env.OLLAMA_API_KEY });
638
+
639
+ // Session (5h) and weekly (7d) windows, with budgets you choose empirically —
640
+ // Ollama doesn't publish the actual ceilings.
641
+ const quota = createOllamaCloudFreeTierQuota({
642
+ session: { maxTokens: 50_000 },
643
+ weekly: { maxTokens: 200_000 },
644
+ });
645
+
646
+ async function chatWithQuota(prompt: string) {
647
+ quota.assertCanProceed(); // throws OllamaQuotaExceededError if any window is spent
648
+
649
+ try {
650
+ const res = await client.chat({ model: 'qwen3:8b', messages: [{ role: 'user', content: prompt }] });
651
+ quota.recordUsage(res); // reads prompt_eval_count/eval_count off the raw response
652
+ return res.message.content;
653
+ } catch (error) {
654
+ if (error instanceof OllamaRateLimitError) {
655
+ console.warn('Server-side rate limit hit — pause until the session window resets.');
656
+ }
657
+ throw error;
658
+ }
659
+ }
660
+ ```
661
+
662
+ `quota.status()` returns per-window `tokensUsed`/`requestsMade`/`remainingTokens`/
663
+ `windowResetAt` for building your own usage dashboard, and `quota.reset(windowId?)` lets
664
+ you clear a window manually (e.g. after confirming a reset on the ollama.com dashboard).
665
+ For budgets that don't match the free tier's cadence, construct `new QuotaManager({
666
+ windows: [...] })` directly with your own `windowMs`/`maxTokens`/`maxRequests` per window.
667
+
668
+ ---
669
+
281
670
  ### Edge Runtime Compatibility
282
671
 
283
672
  The core client (`OllamaClient`, `Agent`, `ToolRegistry`, and everything exported from
@@ -308,6 +697,8 @@ class or narrow to a specific `code`:
308
697
  | `OllamaAuthError` | `auth_error` | `false` | The endpoint returned `401`/`403`. |
309
698
  | `OllamaNotFoundError` | `not_found` | `false` | The endpoint returned `404` (e.g. unknown model). |
310
699
  | `OllamaRateLimitError` | `rate_limited` | `true` | The endpoint returned `429`. |
700
+ | `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). |
701
+ | `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). |
311
702
  | `OllamaServerError` | `server_error` | `true` | The endpoint returned `5xx`. |
312
703
  | `OllamaAbortError` | `aborted` | `false` | The request was cancelled via `AbortSignal`. |
313
704
  | `OllamaToolValidationError` | `tool_validation_error` | `false` | A tool call's arguments, or a `chatWithSchema`/`generateWithSchema` result, failed Zod validation. |
@@ -325,7 +716,7 @@ the original error via the standard `cause` property:
325
716
  import { OllamaClientError, OllamaRateLimitError } from '@nemesis-oss/ollama-sdk';
326
717
 
327
718
  try {
328
- await client.chatText({ model: 'qwen3.5:2b', messages: [...] });
719
+ await client.chatText({ model: 'qwen3:8b', messages: [...] });
329
720
  } catch (err) {
330
721
  if (err instanceof OllamaRateLimitError) {
331
722
  console.warn(`Rate limited, retry after ${err.retryAfterMs}ms`);
@@ -347,7 +738,7 @@ observe per-endpoint circuit state directly.
347
738
 
348
739
  ## Testing
349
740
 
350
- The test suite contains 105 automated tests across 4 testing tiers:
741
+ The test suite contains 121 automated tests across 4 testing tiers:
351
742
 
352
743
  ```bash
353
744
  # Run unit, integration, and functional test suite