@nemesis-oss/ollama-sdk 1.9.0 → 1.11.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
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.0] — Documentation digest + production-readiness audit: context discovery, compaction, affinity scheduling, agent cycle detection, URL-join hardening, vision-history hygiene
4
+
5
+ Three real gaps from the follow-up documentation digest on concurrency & memory-scaling mechanics (see [ADR 0026](./docs/adr/0026-context-discovery-history-compaction-model-affinity.md)) and three verified findings from the October 9 production-readiness audit on agent-loop ergonomics, transport hardening, and multi-turn memory retention (see [ADR 0027](./docs/adr/0027-agent-cycle-detection-url-join-history-hygiene.md)) — all additive, no breaking changes; 778 pre-existing tests stay green, 87 new tests added (40 digest + 47 audit). Stale claims in both reports were verified against the codebase and answered with documentation and tests instead of new code (one digest claim, two audit claims).
6
+
7
+ ### Dynamic context-window discovery (`src/context-discovery.ts` + `models.getContextLength()`)
8
+
9
+ - **`client.models.getContextLength({ model, skipRunningCheck?, signal?, timeoutMs? })`** resolves the real window instead of 2048/4096 guesswork, by precedence: the **allocated window of the running instance** (`GET /api/ps` `context_length` — exact, reflects the Modelfile default and what fit in memory) → the **Modelfile `num_ctx` default** (`POST /api/show` `parameters`) → the **native GGUF maximum** (`model_info["<arch>.context_length"]` — a ceiling, not an expectation; the official API examples show 131072 native vs 4096 allocated) → the conservative 2048 fallback.
10
+ - Returns every signal it found — `{ contextLength, source, runningContextLength?, parameterContextLength?, nativeContextLength? }` — so "how much headroom could I raise" and "what do I have right now" are both answered. Tag-less model names match their tagged `/api/ps` entries the way Ollama itself resolves them; a 404 from `/api/ps` degrades to the `/api/show` signals instead of failing.
11
+ - Pure pieces exported for callers holding raw responses: `resolveContextLength`, `extractParameterNumCtx`, `findRunningModelContextLength`, `extractNativeContextLength` (the existing capabilities scanner, now public). `ps()` also gained optional `{ signal, timeoutMs }` cancellation; `client.getContextLength` mirrors the models method.
12
+
13
+ ### Sliding-window conversation compaction (`compactConversationHistory` + `session.compact()`)
14
+
15
+ - **`compactConversationHistory(messages, { maxEstimatedTokens, reserveForReply?, minTailMessages? })`** — pure helper keeping the leading system message(s) plus the newest turns that fit, dropping the oldest in between. Estimates via the same CJK-aware heuristic as the pre-flight checks (message overhead, `tool_calls`, `IMAGE_TOKEN_ESTIMATE` per image included); `minTailMessages` (default 2) guarantees the newest exchange survives even when oversized — the SDK never reproduces the server's silent truncation.
16
+ - **`session.compact(options?)`** applies it in place, defaulting the budget to the session's `num_ctx` (or the 2048 fallback) minus a 10% reply reservation, and returns `{ droppedMessages, estimatedTokensBefore, estimatedTokensAfter, effectiveBudget }`. Cumulative cache tallies are deliberately never reset — the one-turn `evaluatedTokens` spike after compaction is the visible cost of the KV-prefix invalidation, and compaction stays **manual, never automatic** (see ADR 0026 for the tradeoff).
17
+
18
+ ### Model-affinity scheduling (`src/affinity.ts` + `ModelAffinityScheduler`)
19
+
20
+ - Groups async work into **per-model serial queues** with a **distinct-model cap** (`concurrentModels`, default 1) and **per-model parallelism** (`perModelConcurrency`, default 1 — mirror `OLLAMA_NUM_PARALLEL`). Dispatch is **affinity-sticky**: a queued model that is already active deepens before the next model loads, because deepening avoids an unload/cold-load swap — the anti-thrashing order for `OLLAMA_MAX_LOADED_MODELS`-bounded daemons.
21
+ - **Candidate lists** — `run(['a', 'b'], (model) => …)` picks whichever candidate is already loaded per a TTL-cached `GET /api/ps` (default 30s); single-model tasks never touch `/api/ps`, and a failing lookup degrades to the first candidate without caching the failure.
22
+ - Tasks are caller-owned (no retry/timeout/failover injected); a rejected task rejects only its own `run()`. `stats` exposes `{ activeModels, queuedTasks }`; `dispose()` resolves once every queue drains; invalid options and candidate lists fail fast with `OllamaClientError`.
23
+
24
+ ### Capacity-planning documentation
25
+
26
+ - New README section mapping the official FAQ's server knobs — `OLLAMA_NUM_PARALLEL` (KV memory scales `NUM_PARALLEL × CONTEXT_LENGTH`), `OLLAMA_MAX_LOADED_MODELS` (3× GPUs), `OLLAMA_MAX_QUEUE` (512 → 503s), `num_ctx` — to their client-side counterparts (`endpointHealth.maxConcurrentPerEndpoint`, `embedBatch`, `ModelAffinityScheduler`, `ToolRegistry.maxConcurrency`, `getContextLength` + pre-flight + compaction), with a note on `OLLAMA_KV_CACHE_TYPE`. One audit-digest claim was stale and is documented as already-covered instead: the SDK's request path has capped and queued per endpoint since `endpointHealth` landed — `maxConcurrentRequests`-style pooling exists as `maxConcurrentPerEndpoint`.
27
+
28
+ ### Agent cycle detection (`Agent` `maxRepeatedToolCalls`) — AGN-01
29
+
30
+ - Audit verification first: `Agent` was already a boundary-checked runner — `maxIterations` (default 10) with `OllamaAgentMaxIterationsError`, opt-in `maxToolCalls`, and registry-side encapsulation of every tool failure (unregistered names, argument-validation failures, timeouts, thrown errors) as failed `ToolExecutionResult`s fed back as `role: 'tool'` messages — the loop never crashes on a rejection. The real gap: a _stuck_ model (re-emitting one identical call, ignoring its own results) was indistinguishable from a slow one and burned the whole iteration budget with a generic error.
31
+ - **`AgentConfig.maxRepeatedToolCalls`** (opt-in, like `maxToolCalls`): a per-signature execution budget. Identity is the **canonical signature** — `canonicalToolCallSignature()` (newly exported) renders `name(args)` with recursively key-sorted arguments, so key reordering never splits one repeated call into several, while progressing calls (different arguments) never trip. Failed, unregistered, and timed-out executions count: re-calling a failing tool with unchanged arguments is exactly the loop this catches.
32
+ - Enforcement is batch-preflight, mirroring `maxToolCalls`: checked before the batch executes and before `onToolCallStart` fires (hook pairing stays consistent; duplicate calls within one batch see each other). Exceeding the budget throws **`OllamaAgentToolLoopError`** (`code: 'agent_tool_loop_detected'`, never retryable) with `toolName`, `repeatedExecutions`, `maxRepeatedToolCalls`, and the canonical `signature` truncated at 200 chars — a fail-fast diagnosis at turn N+1 instead of a generic burnout at `maxIterations`.
33
+ - 19 new tests: signature canonicalization (key-order invariance, array-order significance), off-by-default behavior preserved, fail-fast economics (3 model turns instead of 10), distinct-argument progression, within-batch duplicates, failed/unregistered executions counting, hook pairing, error hierarchy and truncation, `RangeError` validation — plus the audit's checklist item: an **end-to-end mock-server test** proving an unregistered tool name is encapsulated as a failed tool result, fed back to the model, and the run converges.
34
+
35
+ ### Reverse-proxy subpath hardening (`src/transport/url.ts`) — URL-01
36
+
37
+ - Audit verification first: the claimed `new URL(path, base)` dispatch never existed — `HttpClient` always concatenated `baseUrl + path` and `normalizeBaseUrl` always preserved mount prefixes (unit-tested since the failover work). The real gaps: the exactly-one-slash join invariant was implicit (a future path without its leading slash would silently produce `hostapi/chat`), and nothing proved gateway subpaths end-to-end on the wire.
38
+ - **`joinUrlPath(baseUrl, path)`** now owns the join for all three `HttpClient` request methods (`request`, `requestSseStream`, `requestStream`): collapses trailing/leading slashes, joins with exactly one slash, preserves query strings — byte-identical output for every well-formed input, hardened against malformed ones. The module documents the WHY at the point of use: WHATWG `new URL('/api/chat', 'https://gw/ai/ollama')` discards the `/ai/ollama` mount; the SDK never constructs request URLs that way.
39
+ - 16 new tests pin the behavior at three levels — `joinUrlPath` units (including the audit's exact `https://gateway.internal.corp/ai/ollama` scenario and a WHATWG-hazard regression test), `HttpClient` transport URLs (incl. query-string paths and `requestStream`), and end-to-end `OllamaClient` wire URLs for `/api/chat`, `/api/generate`, OpenAI-compat `/v1/chat/completions`, and scheme-less subpath hosts (`proxy.internal:8080/ollama/`). README gained a "Hosting Behind a Reverse Proxy" section.
40
+
41
+ ### Vision-history image eviction (`sanitizeHistoryForNextTurn`) — CTX-01
42
+
43
+ - Audit verification first: every mutation claim was false — `Agent` copies `input.messages`, `ConversationSession` freezes and copies on read, `client.chat` maps encoded messages into new arrays, `withEncodedMessageImages` is allocation-free on passthrough. The real gap: consumer-managed vision histories had no way to evict stale image payloads, and `/api/chat` is stateless — every prior base64 image is re-sent and re-billed (context tokens, upload bytes, heap) on every turn, forever.
44
+ - **`sanitizeHistoryForNextTurn(messages, { keepImagesOnLastMessages?, imagePlaceholder? })`** — pure helper returning a copy with `images` evicted outside a trailing keep-window (default 1: the rolling one-image window), conversation text fully preserved, optional placeholder note so the model can tell an image used to exist. Input arrays never mutated; untouched and kept messages reused by reference.
45
+ - Same philosophy as compaction (ADR 0026): **caller-initiated, never automatic**, because the tradeoffs are workload-specific — the model loses access to evicted images, and eviction invalidates the KV prompt prefix from that point. 12 new tests: default eviction + text preservation, purity and reference-stability, keep-windows (0/N/negative clamp), placeholder behavior (incl. empty content), empty-`images` and `Uint8Array` passthrough, and a wire-level proof that a sanitized history sent through `client.chat` carries no stale base64 while the trailing image survives.
46
+
47
+ ## [1.10.0] — Production-readiness audit: batched embedding pipeline, client teardown, type-declaration hardening
48
+
49
+ Three findings from the daily production-readiness audit (see [ADR 0025](./docs/adr/0025-embedding-batching-client-teardown-declaration-portability.md)) — all additive, no breaking changes; 749 pre-existing tests stay green, 29 new tests added.
50
+
51
+ ### Batch-constrained embedding pipeline (`src/embed-batch.ts` + `client.embedBatch()`) — EMB-01
52
+
53
+ - **`client.embedBatch({ model, input, batchSize?, concurrency?, … })`** for high-volume RAG/vector ingestion: splits any corpus into bounded `/api/embed` batches (defaults: 32 inputs × 3 in flight) instead of `Promise.all` floods that OOM local daemons, contend sockets, and saturate `OLLAMA_MAX_QUEUE` (503s).
54
+ - **Order-preserving** — `embeddings[i]` always corresponds to `input[i]` regardless of batch completion order; safe to zip straight into a vector store.
55
+ - **Fail-fast** — the first batch error aborts its in-flight siblings (each still riding the client's full failover/retry/auth/telemetry pipeline) and rejects the whole operation with the _original_ error, so a half-indexed corpus never looks like success. Caller `signal` cancels queued and in-flight batches alike (`code: 'aborted'`).
56
+ - **Per-string context-window pre-flight** — Ollama _silently truncates_ embedding inputs longer than `num_ctx`; `embedBatch()` injects `defaultContextLength` as `num_ctx` and warns (or throws under `onContextOverflow: 'throw'`) with the offending indexes _before_ any request is sent. No margin, unlike chat/generate: an embedding prompt is the whole input.
57
+ - `onBatchComplete(done, total)` progress callback; `truncate`/`dimensions`/`keep_alive`/`options`/`timeoutMs` pass through per batch; new exports `batchEmbed`, `findOversizedEmbedInputs`, `embedBatchOverflowMessage`, `DEFAULT_EMBED_BATCH_SIZE`, `DEFAULT_EMBED_CONCURRENCY` + types.
58
+
59
+ ### Client teardown — `OllamaClient.destroy(reason)` — THD-01
60
+
61
+ - One call aborts **every in-flight request, active stream, and queued capacity waiter** with `OllamaAbortError` (`code: 'aborted'`, message = `reason`) — the clean-exit path for `node:worker_threads`, CLI runners, and short-lived processes where dangling fetch bodies and unconsumed stream readers keep the event loop alive.
62
+ - Streams reject their `finalResult` and release their `maxConcurrentPerEndpoint` slots via the existing `holdUntil` release path; scopes deregister the moment their work settles, so `destroy()` can tear down a stream mid-consumption but never outlives finished work.
63
+ - Idempotent (repeat calls return `0`) and a drain, not a disable — the client stays usable afterward. Returns the number of aborted operations. Covers every surface that goes through `executeWithFailover`/`executeCloudRequest`: chat/generate/embed (+Batch), models, responses bridge, sessions, agent, usage/balance, web tools.
64
+
65
+ ### Type-declaration portability hardening — TYP-03
66
+
67
+ - Verified (not just assumed) the dual-packaging story: `@arethetypeswrong/cli` reports zero problems across node10/node16-CJS/node16-ESM/bundler for every entry point, and the exports map already maps `import → .d.ts` / `require → .d.cts` per subpath.
68
+ - **New `scripts/verify-consumer-types.sh`** (also in CI and `verify:release`): compiles two _real_ consumer projects against the packed tarball — a CommonJS consumer under `--module node16` (the `require` condition, exactly the TS1479 "masquerading ESM types" scenario) and an ESM consumer under `--module nodenext`.
69
+ - **Fixed a real leak it caught**: `VisionInput` included Node's `Buffer` global, so strict consumers without `@types/node` could not compile against our declarations. Now `string | Uint8Array` — Node Buffers are `Uint8Array` subclasses and remain fully accepted (13 vision tests untouched and green) — the published declarations are free of Node ambient globals.
70
+
3
71
  ## [1.9.0] — Digest upgrades: vision resolver, dual-mode Responses bridge, KV-cache sessions, context safety, blob publishing, cloud usage & balance
4
72
 
5
73
  Five upgrades closing the gaps surfaced by the official Ollama documentation review (see [ADR 0024](./docs/adr/0024-digest-upgrades-vision-responses-sessions-context-blobs.md)) — plus a second-pass recheck against the current official docs (see the last section) that added the Ollama Cloud account endpoints and a missing `/api/create` field. All additive — no breaking changes; 689 pre-existing tests stay green, 60 new tests added.
package/README.md CHANGED
@@ -21,14 +21,20 @@
21
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
22
  - 💳 **Ollama Cloud Usage & Balance**: `usage()`/`balance()` wrap Ollama's hosted account endpoints (`GET /api/usage`, `GET /api/balance`) — request counts, USD spend, cached-token totals bucketed by hour/day, and remaining included/purchased credits (including the legacy session/weekly plan shapes) — with the same fixed-cloud-host, API-key, timeout, and retry semantics as the web tools.
23
23
  - 🌉 **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 — plus an ergonomic dual-mode `client.responses.create()` bridge that prefers native `/v1/responses` and transparently re-issues via `/api/chat` on older servers (pre-v0.13.3).
24
- - 💬 **KV-Cache-Aware Conversation Sessions**: `client.session(model, systemPrompt)` keeps an append-only, prefix-stable history that maximizes Ollama's KV-cache reuse across turns, and surfaces per-turn + cumulative cache hit rates (`prompt_eval_cached_count`) so cache degradation is visible instead of silent.
24
+ - 💬 **KV-Cache-Aware Conversation Sessions**: `client.session(model, systemPrompt)` keeps an append-only, prefix-stable history that maximizes Ollama's KV-cache reuse across turns, surfaces per-turn + cumulative cache hit rates (`prompt_eval_cached_count`) so cache degradation is visible instead of silent, and offers a one-call `compact()` sliding-window escape hatch when a session outgrows its window.
25
25
  - 🛑 **Context-Window Safety**: Heuristic client-side token estimation warns (or throws, `onContextOverflow: 'throw'`) before a request is sent when the prompt approaches the effective `num_ctx` window — Ollama's default behavior is to _silently truncate_ oversized prompts — and `defaultContextLength` makes the window explicit on every request.
26
26
  - 📦 **Blob & Custom Model Publishing**: Content-addressed blob management per Ollama's documented protocol — `computeBlobDigest()` (SHA-256 → `sha256:<hex>`), `createBlobFromData()`/`createBlobFromFile()` (HEAD-check + upload, skipping existing blobs), and one-shot `createModelFromGguf()` (upload GGUF shard blobs, then `/api/create` with `files: {name: digest}`).
27
+ - 🧵 **Batched Embeddings with Backpressure**: `client.embedBatch()` splits corpora of any size into bounded `/api/embed` batches (default 32 inputs × 3 in flight) — order-preserving, fail-fast, progress-reporting — so RAG/vector ingestion can't OOM a local daemon or saturate `OLLAMA_MAX_QUEUE` the way `Promise.all` floods do, with per-string context-window pre-flight against silent truncation.
28
+ - 🧹 **Client Teardown (`destroy()`)**: One call aborts every in-flight request, active stream, and queued capacity waiter with `OllamaAbortError` — the clean-exit path for `worker_threads`, CLI runners, and short-lived scripts where dangling fetch bodies keep the event loop alive.
29
+ - 🧠 **Dynamic Context-Window Discovery**: `client.models.getContextLength({ model })` resolves the real window from the server — the allocated window of the running instance (`/api/ps`), the Modelfile `num_ctx` default, and the native GGUF maximum (`model_info`), in precedence order — so applications stop hardcoding 2048/4096 guesses.
30
+ - 🔁 **Model-Affinity Scheduling**: `ModelAffinityScheduler` runs multi-model workloads through per-model serial queues with a distinct-model cap (default 1), deepening the already-hot model before loading the next — the anti-thrashing order for `OLLAMA_NUM_PARALLEL`/`OLLAMA_MAX_LOADED_MODELS`-bounded daemons — and routes candidate lists to whichever model is already loaded.
31
+ - 🛡️ **Agent Loop Guardrails**: `Agent` is a boundary-checked runner out of the box — `maxIterations` (default 10), an opt-in `maxToolCalls` budget, and opt-in **cycle detection** (`maxRepeatedToolCalls`) that fails fast with `OllamaAgentToolLoopError` the moment the model re-emits one identical tool call instead of burning the whole iteration budget on it; tool rejections (including unregistered names) are always encapsulated as `role: 'tool'` results, never loop crashes.
32
+ - 🧼 **Vision History Hygiene**: `sanitizeHistoryForNextTurn()` evicts stale base64 image payloads from consumer-managed multi-turn vision histories — every `/api/chat` request re-sends the whole history, so old images silently bloat payload, context, and heap; the helper keeps images only on the trailing window you choose, conversation text fully preserved.
27
33
  - 🌊 **Web Stream Adapters**: Drop-in adapters (`toTextStream`, `toDataStream`, `toResponse`) for Next.js Route Handlers and Vercel AI SDK.
28
34
  - 📈 **OpenTelemetry Instrumentation**: Automatic spans for HTTP requests, endpoint failover, chat/generate calls, and agent runs — zero-cost when OpenTelemetry isn't installed.
29
35
  - 📊 **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.
30
36
  - ⚡ **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.
31
- - 📦 **Dual ESM & CJS Build**: Full module support with clean TypeScript `.d.ts` declaration maps.
37
+ - 📦 **Dual ESM & CJS Build**: Full module support with paired `.d.ts`/`.d.cts` declarations mapped through conditional `exports` — verified in CI by `@arethetypeswrong/cli` **and** by compiling real CommonJS (node16) and ESM (nodenext) consumer projects against the packed tarball.
32
38
  - 🧩 **Contract-First Architecture**: A single canonical IR (`contracts/ir/ollama.ir.json`) drives TypeScript interfaces, generated API classes (`NativeApi`/`OpenAIApi`/`AnthropicApi`), MCP tool descriptors, Zod schemas, and field-level parity verification. New Ollama endpoints (like `/v1/systemone`) are caught automatically by bidirectional endpoint discovery. See [ADRs 0013-0019](./docs/adr/README.md).
33
39
 
34
40
  ---
@@ -188,6 +194,21 @@ npm install @nemesis-oss/ollama-sdk zod
188
194
 
189
195
  `zod` is a peer dependency (`^3.22.0 || ^4.0.0`) — install whichever major version your project already uses instead of getting a second copy bundled in.
190
196
 
197
+ ### Hosting Behind a Reverse Proxy (subpath mounts)
198
+
199
+ Point `baseUrl` (or `OLLAMA_HOST`) at the mount point and every request path joins underneath it — `/api/chat`, the OpenAI/Anthropic compatibility routes (`/v1/...`), streaming, and generated-surface operations all preserve the prefix:
200
+
201
+ ```typescript
202
+ // Ollama behind https://gateway.internal.corp/ai/ollama →
203
+ // requests go to https://gateway.internal.corp/ai/ollama/api/chat, etc.
204
+ const client = new OllamaClient({ baseUrl: 'https://gateway.internal.corp/ai/ollama' });
205
+
206
+ // Scheme-less gateway hosts with subpaths normalize too:
207
+ // 'proxy.internal:8080/ollama' → http://proxy.internal:8080/ollama/api/chat
208
+ ```
209
+
210
+ The transport deliberately never uses `new URL(path, base)` — WHATWG semantics discard the base's path segments for absolute paths (`new URL('/api/chat', 'https://gw/ai/ollama')` → `https://gw/api/chat`). URL assembly is owned by a single join helper with an exactly-one-slash invariant, pinned by tests at the unit, transport, and client levels (see `src/transport/url.ts`).
211
+
191
212
  ---
192
213
 
193
214
  ## Quick Start
@@ -318,6 +339,125 @@ const client = new OllamaClient({
318
339
 
319
340
  The estimators are exported for custom pipelines: `estimateTokens(text)` (CJK-aware — ~1 token per CJK character, ~4 chars/token elsewhere), `estimateChatRequestTokens(req)` / `estimateGenerateRequestTokens(req)` (message overhead + tool schemas + `IMAGE_TOKEN_ESTIMATE` per image), and the underlying `checkChatContext` / `checkGenerateContext` / `contextWarningMessage`. All are heuristics (±20–30%) — for exact counts, ask the server (`prompt_eval_count`).
320
341
 
342
+ ### Discovering the Real Context Window
343
+
344
+ Guessing context windows ("it's probably 2048… or 4096?") is how silent truncation happens. Ollama advertises the real numbers in two places, and they answer _different questions_: `GET /api/ps` reports the window the **running instance actually allocated** (exact, reflects the Modelfile `num_ctx` default and what fit in memory), while `POST /api/show` exposes the Modelfile's `num_ctx` default _and_ the model's **native GGUF maximum** in `model_info["<architecture>.context_length"]` — the official API examples show a model with a 131072-token native max whose running instance allocated 4096, a 32× gap. `getContextLength()` consults them in precedence order and returns every signal it found:
345
+
346
+ ```typescript
347
+ const window = await client.models.getContextLength({ model: 'gemma4' });
348
+ // -> { contextLength: 4096, // program against this — resolved by precedence
349
+ // source: 'running', // 'running' | 'parameters' | 'model-info' | 'fallback'
350
+ // runningContextLength: 4096, // allocated by the loaded instance (when loaded)
351
+ // nativeContextLength: 131072 } // the ceiling you could raise num_ctx toward
352
+
353
+ // The canonical wiring — make the client's own pre-flight checks exact:
354
+ const client = new OllamaClient({ defaultContextLength: window.contextLength });
355
+
356
+ // Or raise the window deliberately when the model supports it (native max):
357
+ await client.chat({
358
+ model: 'gemma4',
359
+ messages,
360
+ options: { num_ctx: window.nativeContextLength }, // 131072 — needs VRAM to match
361
+ });
362
+ ```
363
+
364
+ `skipRunningCheck: true` skips the `/api/ps` round-trip (e.g. cloud/compat-only endpoints); a 404 from `/api/ps` is treated as "not running" and never fails the lookup. The pure pieces — `resolveContextLength()`, `extractParameterNumCtx()`, `findRunningModelContextLength()`, `extractNativeContextLength()` — are exported for callers that already hold `ps`/`show` responses.
365
+
366
+ ### Compacting Long Conversations
367
+
368
+ Sessions grow monotonically; windows don't. When `cacheStats.hitRate` collapses while history grows — or the pre-flight warnings above start firing — `compact()` is the explicit fix: it keeps the pinned system prompt plus the most recent turns that fit the budget, dropping the oldest turns in between.
369
+
370
+ ```typescript
371
+ const session = client.session('llama3.1', 'You are terse.', {
372
+ options: { num_ctx: 4096 },
373
+ });
374
+
375
+ // …many turns later…
376
+ const result = session.compact();
377
+ // -> { droppedMessages: 14, estimatedTokensBefore: 4180,
378
+ // estimatedTokensAfter: 3552, effectiveBudget: 3686 } // num_ctx − 10% reply reserve
379
+
380
+ // Or compact any message array with an explicit budget (the pure helper):
381
+ import { compactConversationHistory } from '@nemesis-oss/ollama-sdk';
382
+ const kept = compactConversationHistory(session.getMessages(), {
383
+ maxEstimatedTokens: 4096,
384
+ reserveForReply: 512, // headroom for the model's next reply
385
+ minTailMessages: 2, // never drop the latest exchange (default)
386
+ });
387
+ ```
388
+
389
+ Compaction is deliberately **manual, never automatic**: rewriting the history invalidates the prompt prefix, so the next turn starts with a cold KV cache (visible as a one-turn `evaluatedTokens` spike in `cacheStats`). That recompute-for-a-fitting-window tradeoff is sometimes exactly right and sometimes wasteful — the SDK surfaces the numbers and leaves the call to you. Estimates use the same CJK-aware heuristic as the pre-flight checks (tool calls and images included); `minTailMessages` (default 2) guarantees the newest exchange survives even when oversized.
390
+
391
+ ### Evicting Stale Images from Vision Histories
392
+
393
+ `/api/chat` is stateless — every request re-sends the whole history, base64 images
394
+ included, and every image costs real context tokens on every turn. A consumer-managed
395
+ vision history therefore re-uploads each photo long after the model answered the
396
+ question that needed it. `sanitizeHistoryForNextTurn()` is the explicit fix — a pure
397
+ helper that evicts image payloads from every message outside a trailing keep-window,
398
+ conversation text fully preserved:
399
+
400
+ ```typescript
401
+ import { sanitizeHistoryForNextTurn } from '@nemesis-oss/ollama-sdk';
402
+
403
+ history.push({ role: 'user', content: 'What is in THIS photo?', images: [newImage] });
404
+
405
+ // Old images evicted, only the trailing turn keeps its payload:
406
+ await client.chat({ model: 'llava', messages: sanitizeHistoryForNextTurn(history) });
407
+
408
+ // Keep a wider window, and tell the model what used to be there:
409
+ sanitizeHistoryForNextTurn(history, {
410
+ keepImagesOnLastMessages: 2,
411
+ imagePlaceholder: '[image from an earlier turn removed to save context]',
412
+ });
413
+ ```
414
+
415
+ The same philosophy as compaction — caller-initiated, never automatic — with the same
416
+ visible tradeoffs (the model loses access to evicted images; the KV prefix diverges at
417
+ the eviction point, so a hot cache-friendly session shouldn't sanitize). Input arrays
418
+ and untouched messages are never mutated; kept messages are reused by reference. See
419
+ [ADR 0027](./docs/adr/0027-agent-cycle-detection-url-join-history-hygiene.md).
420
+
421
+ ### Model-Affinity Scheduling (multi-model anti-thrashing)
422
+
423
+ Ollama's concurrency model (per the official FAQ) makes same-model parallelism cheap — KV cache scales as `OLLAMA_NUM_PARALLEL × context_length` for the _loaded_ model — but switching models is expensive: a request for a different model queues until the first goes idle or is evicted (`OLLAMA_MAX_LOADED_MODELS`, default 3× GPU count), paying an unload/cold-load swap each time. Workloads that interleave models arbitrarily (extraction on a coder model, reasoning on a thinking model) thrash the daemon that way. `ModelAffinityScheduler` is the client-side fix:
424
+
425
+ ```typescript
426
+ import { ModelAffinityScheduler } from '@nemesis-oss/ollama-sdk';
427
+
428
+ const scheduler = new ModelAffinityScheduler(client, {
429
+ concurrentModels: 1, // one model hot at a time (default) — the anti-thrash setting
430
+ perModelConcurrency: 1, // mirror the daemon's OLLAMA_NUM_PARALLEL if you raise it
431
+ });
432
+
433
+ // Per-model serial queues; the active model deepens before the next one loads:
434
+ await scheduler.run('deepseek-r1', () => reason(client));
435
+ await scheduler.run('qwen2.5:coder', () => extract(client));
436
+
437
+ // Candidate lists: picks whichever model is already loaded (GET /api/ps, TTL-cached):
438
+ const code = await scheduler.run(['qwen2.5:coder', 'qwen2.5:14b'], (model) =>
439
+ extractWith(client, model),
440
+ );
441
+
442
+ scheduler.stats; // { activeModels: ['deepseek-r1'], queuedTasks: 3 }
443
+ await scheduler.dispose(); // waits for every queue to drain
444
+ ```
445
+
446
+ Dispatch is **affinity-sticky, not round-robin**: when a queued model is already active, its tasks start before a different model's (up to `perModelConcurrency`), because deepening the hot model avoids a swap. Tasks are your own functions — the scheduler adds no retry, timeout, or failover, and a rejected task only rejects its own `run()` promise. Candidate selection degrades gracefully: if `/api/ps` fails, the first candidate runs and the failure is never cached.
447
+
448
+ ### Capacity Planning: OLLAMA_NUM_PARALLEL, MAX_QUEUE & KV-Cache Memory
449
+
450
+ The server-side knobs (official FAQ defaults) and the client-side surfaces that map onto them:
451
+
452
+ | Server knob | Default | Effect | Client-side counterpart |
453
+ | ----------------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
454
+ | `OLLAMA_NUM_PARALLEL` | 1 | Parallel requests **per loaded model**; required memory scales `NUM_PARALLEL × CONTEXT_LENGTH` (a 4K-context model at 4 parallel allocates a 16K KV cache) | `ModelAffinityScheduler({ perModelConcurrency })`, `ToolRegistry({ maxConcurrency })` for agent tool dispatch |
455
+ | `OLLAMA_MAX_LOADED_MODELS` | 3× GPUs (3 on CPU) | Concurrently loaded models; beyond it, requests queue until a model goes idle or is evicted — the swap/thrash cycle | `ModelAffinityScheduler({ concurrentModels })` — keep the working set small; `client.models.pin()`/`unload()` for explicit lifecycle |
456
+ | `OLLAMA_MAX_QUEUE` | 512 | Queued requests beyond this are rejected immediately with **503** | `endpointHealth.maxConcurrentPerEndpoint` (queue client-side instead of saturating the daemon), `embedBatch({ batchSize, concurrency })` for ingestion, retry/failover on 503 |
457
+ | `OLLAMA_CONTEXT_LENGTH` / `num_ctx` | 2048–4096 model-dependent | The window per request — prompts beyond it are **silently truncated** | `models.getContextLength()` for the real number, `defaultContextLength` to make it explicit, pre-flight warnings + `session.compact()` to stay inside it |
458
+
459
+ The two failure modes this table prevents: `Promise.all`-style floods (hundreds of simultaneous requests → queue saturation → 503s, VRAM spikes) and model ping-pong (alternating models → repeated unload/reload stalls). The SDK's request path already caps and queues per endpoint via `endpointHealth` — including `strategy: 'least-connections'` for pools — and `embedBatch`/`ModelAffinityScheduler` extend that discipline to ingestion and multi-model workloads. `OLLAMA_KV_CACHE_TYPE` (`f16`/`q8_0`/`q4_0`) trades KV precision for memory headroom at the daemon level; benchmark its latency impact against these client-side levers before relying on it for throughput.
460
+
321
461
  ### Blob Management & Custom GGUF Model Publishing
322
462
 
323
463
  Ollama's documented import protocol: push a blob for each file, then reference it in `/api/create`'s `files` map by file name and SHA-256 digest. The SDK wraps the whole flow:
@@ -462,6 +602,31 @@ console.log(
462
602
 
463
603
  `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`.
464
604
 
605
+ #### Batched embeddings with backpressure (large corpora)
606
+
607
+ Embedding a whole corpus with `Promise.all(chunks.map(c => client.embed(...)))` is the failure mode every RAG pipeline eventually hits: hundreds of simultaneous requests contend sockets, saturate the daemon's request queue (`OLLAMA_MAX_QUEUE` → 503s), and spike GPU VRAM as the model instance serves every prompt at once. Long inputs have a second, quieter failure mode — Ollama **silently truncates** any input string longer than the model's context window unless `truncate: false` is set.
608
+
609
+ `embedBatch()` is the safe building block for ingestion:
610
+
611
+ ```typescript
612
+ const { embeddings, batchCount } = await client.embedBatch({
613
+ model: 'nomic-embed-text:latest',
614
+ input: corpus, // any size — 10 strings or 10 million
615
+ batchSize: 32, // inputs per /api/embed request (default 32)
616
+ concurrency: 3, // batches in flight simultaneously (default 3)
617
+ keep_alive: '10m', // pin the model for the whole ingestion
618
+ onBatchComplete: (done, total) => progress.log(`${done}/${total} batches`),
619
+ });
620
+
621
+ // embeddings[i] always corresponds to corpus[i], no matter which batch
622
+ // finished first — safe to zip straight into a vector store.
623
+ await vectorStore.upsert(corpus.map((text, i) => ({ id: i, text, vector: embeddings[i] })));
624
+ ```
625
+
626
+ Every batch rides the client's full pipeline — failover, retry, auth, telemetry — and the operation is **order-preserving** and **fail-fast**: the first batch error aborts its in-flight siblings and rejects the whole call with the original error, so a half-indexed corpus never looks like success. The caller's `signal` cancels queued and in-flight batches alike (`code: 'aborted'`).
627
+
628
+ Like `chat`/`generate`, `embedBatch()` injects `defaultContextLength` as `num_ctx` and pre-checks each input string's estimated tokens against the resolved window — warn (default) or throw (`onContextOverflow: 'throw'`) with the offending indexes _before_ any request is sent, since Ollama truncates embedding inputs silently. Tune `batchSize`/`concurrency` per daemon: lower both for a laptop GPU, raise `concurrency` for remote or multi-endpoint clients.
629
+
465
630
  ### Model Lifecycle Management
466
631
 
467
632
  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)):
@@ -571,6 +736,43 @@ synthesizes a stable client-side ID for tracing and execution correlation:
571
736
  use Ollama's documented `tool_name` field; the SDK-local `toolCallId` is not sent on
572
737
  the wire. See [ADR 0007](./docs/adr/0007-synthetic-tool-call-ids.md).
573
738
 
739
+ The loop is boundary-checked by construction. `maxIterations` (default 10) bounds the
740
+ run; an optional `maxToolCalls` budget caps total executions; and tool rejections never
741
+ crash the loop — the registry encapsulates unregistered names, argument-validation
742
+ failures, timeouts, and thrown errors as failed `ToolExecutionResult`s that are fed
743
+ back to the model as ordinary `role: 'tool'` messages so it can self-correct. When a
744
+ model gets _stuck_ — re-emitting the same call with unchanged arguments instead of
745
+ reacting to its results — opt-in cycle detection fails fast with a precise diagnosis
746
+ instead of letting the loop burn its whole budget on the same wasted call:
747
+
748
+ ```typescript
749
+ import { Agent, OllamaAgentToolLoopError } from '@nemesis-oss/ollama-sdk';
750
+
751
+ const agent = new Agent(client, {
752
+ tools: registry,
753
+ maxIterations: 10,
754
+ maxToolCalls: 40, // total execution budget across the run
755
+ maxRepeatedToolCalls: 3, // per identical call (same name + same arguments)
756
+ });
757
+
758
+ try {
759
+ const result = await agent.run({ model: 'qwen3:8b', messages });
760
+ } catch (error) {
761
+ if (error instanceof OllamaAgentToolLoopError) {
762
+ // The model re-emitted error.toolName with identical arguments a 4th time.
763
+ // error.signature holds the canonical call; error.repeatedExecutions the count.
764
+ // Fail fast at turn 4 instead of burning all 10 iterations on it.
765
+ }
766
+ }
767
+ ```
768
+
769
+ Counting is per canonical signature — `canonicalToolCallSignature()` (also exported)
770
+ sorts argument keys recursively, so a model re-emitting the same call in a different
771
+ key order still counts as the same call. Failed, unregistered, and timed-out
772
+ executions count too: re-calling a failing tool with unchanged arguments is exactly
773
+ the pathological loop this catches. Like `maxToolCalls`, the guardrail is opt-in and
774
+ caller-owned — see [ADR 0027](./docs/adr/0027-agent-cycle-detection-url-join-history-hygiene.md).
775
+
574
776
  Running several `Agent`s against different models/API keys for different roles (e.g. a
575
777
  planning model, a coding model, a research model) is a single `OllamaClient` with
576
778
  per-endpoint `models` allow-lists (see
@@ -962,6 +1164,37 @@ initial HTTP round trip:
962
1164
  between turns, outside any `chat()` call, so a slow tool never ties up one of your
963
1165
  scarce concurrent-request accounts.
964
1166
 
1167
+ ### Client Teardown: `destroy()`
1168
+
1169
+ Inside `node:worker_threads` tasks, CLI runners, serverless handlers, and other
1170
+ short-lived processes, the things that keep an event loop (and therefore the
1171
+ thread or process) alive are exactly the things this SDK manages: in-flight
1172
+ fetch bodies, unconsumed stream readers, and requests queued behind
1173
+ `maxConcurrentPerEndpoint`. `destroy()` is the one-call drain for all of them:
1174
+
1175
+ ```typescript
1176
+ import { parentPort, workerData } from 'node:worker_threads';
1177
+
1178
+ const client = new OllamaClient({ baseUrl: workerData.ollamaUrl });
1179
+ try {
1180
+ await processTask(client, workerData.payload);
1181
+ } finally {
1182
+ // Aborts every request/stream still in flight with OllamaAbortError
1183
+ // (code 'aborted'), releases their endpoint slots, and lets the worker
1184
+ // thread exit cleanly. Idempotent — safe to call from a signal handler.
1185
+ const aborted = client.destroy('worker task finished');
1186
+ if (aborted > 0) logger.warn(`torn down ${aborted} in-flight operations`);
1187
+ }
1188
+ ```
1189
+
1190
+ Every aborted operation — dispatched **or** still queued — rejects with
1191
+ `OllamaAbortError` carrying the reason; active streams reject their
1192
+ `finalResult` and release their concurrency slots, so nothing dangles after
1193
+ the call returns the count of operations it aborted. `destroy()` is a drain,
1194
+ not a permanent disable: the client stays usable afterward (spawn a fresh one
1195
+ if you want a hard cut), and calling it on an idle client is a no-op that
1196
+ returns `0`.
1197
+
965
1198
  ---
966
1199
 
967
1200
  ### Observability with OpenTelemetry
@@ -1356,6 +1589,13 @@ npm run bench
1356
1589
 
1357
1590
  # Run full CI verification pipeline (typecheck, lint, test, build, edge runtime check)
1358
1591
  npm run verify
1592
+
1593
+ # Non-disastrous smoke test: contract gates (validate, IR diff, type drift) + full
1594
+ # verify chain + package type-resolution check + real CJS/ESM consumer compile.
1595
+ # Read-only — no clean, no codegen rewriting src/generated. Live conformance runs
1596
+ # automatically if an Ollama server is reachable on localhost:11434 (otherwise
1597
+ # those tests skip cleanly).
1598
+ npm run smoke
1359
1599
  ```
1360
1600
 
1361
1601
  ---
@@ -1,4 +1,4 @@
1
- import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, ae as SseEvent, x as OllamaClientError } from './runtime-C3rfpkZw.js';
1
+ import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, af as SseEvent, y as OllamaClientError } from './runtime-CCm4CtU_.js';
2
2
 
3
3
  /**
4
4
  * AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
@@ -1,4 +1,4 @@
1
- import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, ae as SseEvent, x as OllamaClientError } from './runtime-D2W9TfCJ.cjs';
1
+ import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, af as SseEvent, y as OllamaClientError } from './runtime-DTtWCxDs.cjs';
2
2
 
3
3
  /**
4
4
  * AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
@@ -1,4 +1,4 @@
1
- import { l as ToolDefinition, i as ToolCall, n as Message } from './types-DVcX_zMp.js';
1
+ import { l as ToolDefinition, i as ToolCall, n as Message } from './types-BtQexFpT.js';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { l as ToolDefinition, i as ToolCall, n as Message } from './types-DVcX_zMp.cjs';
1
+ import { l as ToolDefinition, i as ToolCall, n as Message } from './types-BtQexFpT.cjs';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
@@ -1,8 +1,8 @@
1
- import { O as OpenAIChatCompletionRequest, a as OpenAIChatCompletionResponse, b as OpenAIChatCompletionChunk, c as OpenAICompletionRequest, d as OpenAICompletionResponse, e as OpenAICompletionChunk, f as OpenAIEmbeddingRequest, g as OpenAIEmbeddingResponse, h as OpenAIListModelsResponse, i as OpenAIModelItem, j as OpenAIResponsesRequest, k as OpenAIResponsesResponse, l as OpenAIResponsesStreamEvent, A as AnthropicMessagesRequest, m as AnthropicMessagesResponse, n as AnthropicMessageStreamEvent } from './anthropic-BIh45UXj.cjs';
2
- export { N as NativeApi } from './anthropic-BIh45UXj.cjs';
3
- import { O as OllamaRuntime } from './runtime-D2W9TfCJ.cjs';
4
- export { a as OperationDefinition } from './runtime-D2W9TfCJ.cjs';
5
- import './types-DVcX_zMp.cjs';
1
+ import { O as OpenAIChatCompletionRequest, a as OpenAIChatCompletionResponse, b as OpenAIChatCompletionChunk, c as OpenAICompletionRequest, d as OpenAICompletionResponse, e as OpenAICompletionChunk, f as OpenAIEmbeddingRequest, g as OpenAIEmbeddingResponse, h as OpenAIListModelsResponse, i as OpenAIModelItem, j as OpenAIResponsesRequest, k as OpenAIResponsesResponse, l as OpenAIResponsesStreamEvent, A as AnthropicMessagesRequest, m as AnthropicMessagesResponse, n as AnthropicMessageStreamEvent } from './anthropic-DinrABof.cjs';
2
+ export { N as NativeApi } from './anthropic-DinrABof.cjs';
3
+ import { O as OllamaRuntime } from './runtime-DTtWCxDs.cjs';
4
+ export { a as OperationDefinition } from './runtime-DTtWCxDs.cjs';
5
+ import './types-BtQexFpT.cjs';
6
6
  import 'zod';
7
7
 
8
8
  /**
@@ -1,8 +1,8 @@
1
- import { O as OpenAIChatCompletionRequest, a as OpenAIChatCompletionResponse, b as OpenAIChatCompletionChunk, c as OpenAICompletionRequest, d as OpenAICompletionResponse, e as OpenAICompletionChunk, f as OpenAIEmbeddingRequest, g as OpenAIEmbeddingResponse, h as OpenAIListModelsResponse, i as OpenAIModelItem, j as OpenAIResponsesRequest, k as OpenAIResponsesResponse, l as OpenAIResponsesStreamEvent, A as AnthropicMessagesRequest, m as AnthropicMessagesResponse, n as AnthropicMessageStreamEvent } from './anthropic-5oYIRS6D.js';
2
- export { N as NativeApi } from './anthropic-5oYIRS6D.js';
3
- import { O as OllamaRuntime } from './runtime-C3rfpkZw.js';
4
- export { a as OperationDefinition } from './runtime-C3rfpkZw.js';
5
- import './types-DVcX_zMp.js';
1
+ import { O as OpenAIChatCompletionRequest, a as OpenAIChatCompletionResponse, b as OpenAIChatCompletionChunk, c as OpenAICompletionRequest, d as OpenAICompletionResponse, e as OpenAICompletionChunk, f as OpenAIEmbeddingRequest, g as OpenAIEmbeddingResponse, h as OpenAIListModelsResponse, i as OpenAIModelItem, j as OpenAIResponsesRequest, k as OpenAIResponsesResponse, l as OpenAIResponsesStreamEvent, A as AnthropicMessagesRequest, m as AnthropicMessagesResponse, n as AnthropicMessageStreamEvent } from './anthropic-CfricAhK.js';
2
+ export { N as NativeApi } from './anthropic-CfricAhK.js';
3
+ import { O as OllamaRuntime } from './runtime-CCm4CtU_.js';
4
+ export { a as OperationDefinition } from './runtime-CCm4CtU_.js';
5
+ import './types-BtQexFpT.js';
6
6
  import 'zod';
7
7
 
8
8
  /**