@nemesis-oss/ollama-sdk 1.1.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/CHANGELOG.md CHANGED
@@ -5,6 +5,126 @@ All notable changes to `@nemesis-oss/ollama-sdk` will be documented in this file
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2026-08-25
9
+
10
+ ### Added
11
+
12
+ - **Client-side quota monitoring (`QuotaManager`).** Ollama Cloud doesn't expose
13
+ account-level quota through the API — no response header or endpoint reports how much
14
+ of your plan's session/weekly limit is left (see
15
+ [ollama/ollama#15663](https://github.com/ollama/ollama/issues/15663)). `QuotaManager`
16
+ tracks usage you record (via `recordUsage`, which reads `extractUsage`-normalized token
17
+ counts off a raw response) against budgets you configure over one or more rolling
18
+ windows, and refuses to proceed (`canProceed`/`assertCanProceed`) once a window's budget
19
+ is spent — a local safety net to complement, not replace, catching the server's own
20
+ `OllamaRateLimitError`. `createOllamaCloudFreeTierQuota` is a convenience factory
21
+ wiring up the free tier's documented 5-hour session and 7-day weekly window cadence
22
+ (you still supply the token/request budgets, since Ollama doesn't publish the actual
23
+ ceilings). New `OllamaQuotaExceededError` (`code: 'quota_exceeded'`) is thrown by
24
+ `assertCanProceed`. See the [Quota Monitoring](./README.md#quota-monitoring) README
25
+ section.
26
+ - **Credential-scoped multi-key routing.** `OllamaEndpoint` gains an optional `models`
27
+ allow-list, for the common case of several Ollama Cloud API keys each entitled to a
28
+ different model. `EndpointRegistry.candidates(model)` now filters to endpoints whose
29
+ `models` includes the requested model (endpoints without `models` stay eligible for
30
+ every model — fully backward compatible), so `chat`/`generate`/`embed`/`embeddings` and
31
+ model-lifecycle calls (`showModel`, `pullModel`, etc.) automatically resolve the
32
+ authorized credential for the model requested, and cross-endpoint failover between
33
+ differently-scoped endpoints never sends a request to a key that isn't entitled to that
34
+ model. Requesting a model no configured endpoint is scoped to throws the new
35
+ `OllamaModelRoutingError` (`code: 'model_routing_error'`) immediately, before any
36
+ network call — the SDK never probes unauthorized keys to see which one happens to
37
+ work. See the README's
38
+ ["Multiple API keys, each entitled to different models"](./README.md#multiple-api-keys-each-entitled-to-different-models)
39
+ and the
40
+ [multi-model agent benchmarking guide](./docs/guides/multi-model-agent-benchmarking.md).
41
+ - **`credentials` + `modelBindings` config.** An ergonomic, map-based alternative to
42
+ writing `endpoints` with per-entry `models` allow-lists by hand: `OllamaClientConfig`
43
+ gains `credentials` (named `{ apiKey, baseUrl?, headers? }` entries, keyed by an id you
44
+ choose), `modelBindings` (maps a model to the credential id, or ids, authorized to serve
45
+ it), and `defaultCredential` (a fallback credential for any model with no explicit
46
+ binding). Both config shapes compile down to the same `EndpointRegistry`/
47
+ `executeWithFailover` routing above — this is config sugar, not a second routing
48
+ mechanism — so behavior (including `OllamaModelRoutingError` on an unrouted model) is
49
+ identical either way. `modelBindings` referencing an unknown credential id throws at
50
+ construction. New `resolveCredentialEndpoints` export for anyone who wants the
51
+ translation without going through `OllamaClient`.
52
+ - **Round-robin candidate selection.** `EndpointRegistryOptions` (passed via
53
+ `OllamaClientConfig.endpointHealth`) gains `strategy: 'priority' | 'round-robin'`
54
+ (default `'priority'`, unchanged prior behavior). With `'round-robin'`,
55
+ `EndpointRegistry.candidates()` rotates each same-priority group of healthy candidates
56
+ by one position per call, so consecutive `chat`/`generate`/`embed`/etc. calls spread
57
+ across a pool of interchangeable endpoints or unbound `credentials` (e.g. several
58
+ free-tier Ollama Cloud keys with no `models`/`modelBindings` restriction) instead of
59
+ always preferring the first one. Candidates at different priorities are unaffected —
60
+ a higher-priority candidate is still always tried first — and failover to the rest of
61
+ the (rotated) list still applies if the first pick fails. See the README's
62
+ ["A free pool of interchangeable keys — and spreading load across it"](./README.md#a-free-pool-of-interchangeable-keys--and-spreading-load-across-it).
63
+ - **Least-connections candidate selection.** `EndpointRegistryOptions.strategy` gains a
64
+ third value, `'least-connections'`, alongside `'priority'` (default) and
65
+ `'round-robin'`. Each same-priority tier is ordered by ascending in-flight request
66
+ count (new `EndpointRegistry.acquire`/`release`, wired into `OllamaClient.executeWithFailover`
67
+ around each attempt) rather than a fixed rotation, so concurrent `Promise.all`-style
68
+ fan-out deterministically lands on distinct candidates — the motivating case being
69
+ several Ollama Cloud free-tier accounts, each capped at 1 concurrent request: `N`
70
+ concurrent requests against `N` such accounts land one-per-account rather than risking
71
+ two on the same still-busy one, because the endpoint choice and its `acquire()` happen
72
+ synchronously with no `await` in between (JS's single-threaded execution rules out the
73
+ race entirely). `EndpointHealth` gains `activeRequests`, also exposed via
74
+ `client.endpointStatus()`, for observability regardless of `strategy`. See the README's
75
+ ["Concurrent requests across single-slot accounts (least-connections)"](./README.md#concurrent-requests-across-single-slot-accounts-least-connections).
76
+ - **`maxConcurrentPerEndpoint` — queueing past capacity.** `EndpointRegistryOptions`
77
+ gains `maxConcurrentPerEndpoint`, capping in-flight requests per endpoint exactly. Once
78
+ every otherwise-eligible candidate is at that cap, a request waits (FIFO, new
79
+ `EndpointRegistry.filterWithCapacity`/`waitForCapacity`) for one to free rather than
80
+ being routed to an already-saturated candidate — the gap `'least-connections'` alone
81
+ doesn't close for `N + 1` concurrent calls against `N` capacity-1 candidates. Waiting is
82
+ bounded by the request's own `timeoutMs`/`AbortSignal`; a queued call that times out or
83
+ is cancelled is dequeued and rejects without ever being sent.
84
+
85
+ ### Fixed
86
+
87
+ - **Streaming requests (`chatStream`/`generateStream`) now hold their endpoint's
88
+ concurrency slot for the lifetime of the stream, not just until it starts.**
89
+ Previously, `acquire`/`release` around a streaming call released the slot as soon as
90
+ `http.requestStream` resolved (response headers received) — before any tokens were
91
+ actually read — so `'least-connections'`/`maxConcurrentPerEndpoint` accounting stopped
92
+ reflecting reality the moment a stream was returned, undercounting real concurrency for
93
+ as long as the caller kept consuming it. `executeWithFailover` gains an internal
94
+ `holdUntil` hook, wired to the returned `OllamaStream`'s `finalResult` for `chatStream`/
95
+ `generateStream` (and the streaming `pull`/`push`/`create` progress endpoints), so the
96
+ slot now releases only once the stream is fully drained, errors, or is aborted.
97
+
98
+ ## [1.2.0] - 2026-08-24
99
+
100
+ Closes gaps found during a documentation/API-parity review against the official Ollama
101
+ API — two of which were genuine issues, the rest already covered (multimodal `images`,
102
+ native `think`, `/api/embed`, and full model-lifecycle management all predate this
103
+ release; see the new README sections documenting them).
104
+
105
+ ### Fixed
106
+
107
+ - **`webSearch`/`webFetch` were non-functional against real Ollama Cloud.** They
108
+ previously posted to `<configured-baseUrl>/api/websearch` and `/webfetch` — the wrong
109
+ host (Ollama's web tools only ever exist at `https://ollama.com`, never proxied through
110
+ a local server) _and_ the wrong path (missing the underscore: `/api/web_search`,
111
+ `/api/web_fetch`) _and_ the wrong request/response field names. Both methods now always
112
+ target `https://ollama.com` regardless of `baseUrl`/`endpoints`, using the resolved
113
+ `apiKey`/`OLLAMA_API_KEY`, and use the real field names (`max_results` request,
114
+ `content` response). `WebSearchRequestOptions.count` and `WebSearchResult.snippet` are
115
+ kept as `@deprecated` back-compat aliases (mapped to/mirrored from the correct fields)
116
+ rather than removed outright. `WebFetchResponse` gains the `title`/`links` fields the
117
+ real API actually returns.
118
+
119
+ ### Added
120
+
121
+ - **`logprobs`/`top_logprobs`:** Added to `ChatRequestOptions`/`GenerateRequestOptions`
122
+ (request) and `ChatResponse`/`GenerateResponse` (response, as a new `Logprob[]` typed
123
+ by the new `Logprob`/`LogprobToken` types), matching the official `/api/chat` and
124
+ `/api/generate` schemas.
125
+ - `OLLAMA_CLOUD_BASE_URL` exported for consumers who want to reference the fixed web-tools
126
+ host directly.
127
+
8
128
  ## [1.1.0] - 2026-08-23
9
129
 
10
130
  Brings the SDK's typed surface up to date with Ollama v0.13.3+.
package/README.md CHANGED
@@ -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: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
+ - 🌐 **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.
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.
24
28
 
@@ -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
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
@@ -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
@@ -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
@@ -271,6 +373,209 @@ etc.) and `capabilities()` target one specific endpoint's local state and delibe
271
373
  server doesn't retry the same operation, it silently acts on a different model catalog.
272
374
  See [ADR 0008](./docs/adr/0008-endpoint-failover-scope.md).
273
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
+
274
579
  ---
275
580
 
276
581
  ### Observability with OpenTelemetry
@@ -304,6 +609,64 @@ for exactly which spans and attributes are emitted, and the tradeoffs behind tha
304
609
 
305
610
  ---
306
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
+
307
670
  ### Edge Runtime Compatibility
308
671
 
309
672
  The core client (`OllamaClient`, `Agent`, `ToolRegistry`, and everything exported from
@@ -334,6 +697,8 @@ class or narrow to a specific `code`:
334
697
  | `OllamaAuthError` | `auth_error` | `false` | The endpoint returned `401`/`403`. |
335
698
  | `OllamaNotFoundError` | `not_found` | `false` | The endpoint returned `404` (e.g. unknown model). |
336
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). |
337
702
  | `OllamaServerError` | `server_error` | `true` | The endpoint returned `5xx`. |
338
703
  | `OllamaAbortError` | `aborted` | `false` | The request was cancelled via `AbortSignal`. |
339
704
  | `OllamaToolValidationError` | `tool_validation_error` | `false` | A tool call's arguments, or a `chatWithSchema`/`generateWithSchema` result, failed Zod validation. |
@@ -373,7 +738,7 @@ observe per-endpoint circuit state directly.
373
738
 
374
739
  ## Testing
375
740
 
376
- The test suite contains 114 automated tests across 4 testing tiers:
741
+ The test suite contains 121 automated tests across 4 testing tiers:
377
742
 
378
743
  ```bash
379
744
  # Run unit, integration, and functional test suite