@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 +120 -0
- package/README.md +368 -3
- package/dist/{compose-DJjVpDrk.d.cts → compose-CDWq8oxJ.d.cts} +51 -2
- package/dist/{compose-DJjVpDrk.d.ts → compose-CDWq8oxJ.d.ts} +51 -2
- package/dist/index.cjs +455 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +344 -6
- package/dist/index.d.ts +344 -6
- package/dist/index.js +450 -64
- package/dist/index.js.map +1 -1
- package/dist/skills.cjs.map +1 -1
- package/dist/skills.d.cts +2 -2
- package/dist/skills.d.ts +2 -2
- package/dist/skills.js.map +1 -1
- package/package.json +1 -1
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,
|
|
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
|
|
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
|