@nemesis-oss/ollama-sdk 1.9.0 → 1.10.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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.0] — Production-readiness audit: batched embedding pipeline, client teardown, type-declaration hardening
4
+
5
+ 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.
6
+
7
+ ### Batch-constrained embedding pipeline (`src/embed-batch.ts` + `client.embedBatch()`) — EMB-01
8
+
9
+ - **`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).
10
+ - **Order-preserving** — `embeddings[i]` always corresponds to `input[i]` regardless of batch completion order; safe to zip straight into a vector store.
11
+ - **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'`).
12
+ - **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.
13
+ - `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.
14
+
15
+ ### Client teardown — `OllamaClient.destroy(reason)` — THD-01
16
+
17
+ - 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.
18
+ - 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.
19
+ - 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.
20
+
21
+ ### Type-declaration portability hardening — TYP-03
22
+
23
+ - 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.
24
+ - **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`.
25
+ - **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.
26
+
3
27
  ## [1.9.0] — Digest upgrades: vision resolver, dual-mode Responses bridge, KV-cache sessions, context safety, blob publishing, cloud usage & balance
4
28
 
5
29
  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
@@ -24,11 +24,13 @@
24
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.
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.
27
29
  - 🌊 **Web Stream Adapters**: Drop-in adapters (`toTextStream`, `toDataStream`, `toResponse`) for Next.js Route Handlers and Vercel AI SDK.
28
30
  - 📈 **OpenTelemetry Instrumentation**: Automatic spans for HTTP requests, endpoint failover, chat/generate calls, and agent runs — zero-cost when OpenTelemetry isn't installed.
29
31
  - 📊 **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
32
  - ⚡ **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.
33
+ - 📦 **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
34
  - 🧩 **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
35
 
34
36
  ---
@@ -462,6 +464,31 @@ console.log(
462
464
 
463
465
  `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
466
 
467
+ #### Batched embeddings with backpressure (large corpora)
468
+
469
+ 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.
470
+
471
+ `embedBatch()` is the safe building block for ingestion:
472
+
473
+ ```typescript
474
+ const { embeddings, batchCount } = await client.embedBatch({
475
+ model: 'nomic-embed-text:latest',
476
+ input: corpus, // any size — 10 strings or 10 million
477
+ batchSize: 32, // inputs per /api/embed request (default 32)
478
+ concurrency: 3, // batches in flight simultaneously (default 3)
479
+ keep_alive: '10m', // pin the model for the whole ingestion
480
+ onBatchComplete: (done, total) => progress.log(`${done}/${total} batches`),
481
+ });
482
+
483
+ // embeddings[i] always corresponds to corpus[i], no matter which batch
484
+ // finished first — safe to zip straight into a vector store.
485
+ await vectorStore.upsert(corpus.map((text, i) => ({ id: i, text, vector: embeddings[i] })));
486
+ ```
487
+
488
+ 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'`).
489
+
490
+ 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.
491
+
465
492
  ### Model Lifecycle Management
466
493
 
467
494
  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)):
@@ -962,6 +989,37 @@ initial HTTP round trip:
962
989
  between turns, outside any `chat()` call, so a slow tool never ties up one of your
963
990
  scarce concurrent-request accounts.
964
991
 
992
+ ### Client Teardown: `destroy()`
993
+
994
+ Inside `node:worker_threads` tasks, CLI runners, serverless handlers, and other
995
+ short-lived processes, the things that keep an event loop (and therefore the
996
+ thread or process) alive are exactly the things this SDK manages: in-flight
997
+ fetch bodies, unconsumed stream readers, and requests queued behind
998
+ `maxConcurrentPerEndpoint`. `destroy()` is the one-call drain for all of them:
999
+
1000
+ ```typescript
1001
+ import { parentPort, workerData } from 'node:worker_threads';
1002
+
1003
+ const client = new OllamaClient({ baseUrl: workerData.ollamaUrl });
1004
+ try {
1005
+ await processTask(client, workerData.payload);
1006
+ } finally {
1007
+ // Aborts every request/stream still in flight with OllamaAbortError
1008
+ // (code 'aborted'), releases their endpoint slots, and lets the worker
1009
+ // thread exit cleanly. Idempotent — safe to call from a signal handler.
1010
+ const aborted = client.destroy('worker task finished');
1011
+ if (aborted > 0) logger.warn(`torn down ${aborted} in-flight operations`);
1012
+ }
1013
+ ```
1014
+
1015
+ Every aborted operation — dispatched **or** still queued — rejects with
1016
+ `OllamaAbortError` carrying the reason; active streams reject their
1017
+ `finalResult` and release their concurrency slots, so nothing dangles after
1018
+ the call returns the count of operations it aborted. `destroy()` is a drain,
1019
+ not a permanent disable: the client stays usable afterward (spawn a fresh one
1020
+ if you want a hard cut), and calling it on an idle client is a no-op that
1021
+ returns `0`.
1022
+
965
1023
  ---
966
1024
 
967
1025
  ### Observability with OpenTelemetry
@@ -1356,6 +1414,13 @@ npm run bench
1356
1414
 
1357
1415
  # Run full CI verification pipeline (typecheck, lint, test, build, edge runtime check)
1358
1416
  npm run verify
1417
+
1418
+ # Non-disastrous smoke test: contract gates (validate, IR diff, type drift) + full
1419
+ # verify chain + package type-resolution check + real CJS/ESM consumer compile.
1420
+ # Read-only — no clean, no codegen rewriting src/generated. Live conformance runs
1421
+ # automatically if an Ollama server is reachable on localhost:11434 (otherwise
1422
+ # those tests skip cleanly).
1423
+ npm run smoke
1359
1424
  ```
1360
1425
 
1361
1426
  ---
@@ -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, ae as SseEvent, x as OllamaClientError } from './runtime-BV9SK_cf.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, ae as SseEvent, x as OllamaClientError } from './runtime-BsdBbuOv.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-DuE8M6Tz.cjs';
2
+ export { N as NativeApi } from './anthropic-DuE8M6Tz.cjs';
3
+ import { O as OllamaRuntime } from './runtime-BsdBbuOv.cjs';
4
+ export { a as OperationDefinition } from './runtime-BsdBbuOv.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-CPaoxeLX.js';
2
+ export { N as NativeApi } from './anthropic-CPaoxeLX.js';
3
+ import { O as OllamaRuntime } from './runtime-BV9SK_cf.js';
4
+ export { a as OperationDefinition } from './runtime-BV9SK_cf.js';
5
+ import './types-BtQexFpT.js';
6
6
  import 'zod';
7
7
 
8
8
  /**
@@ -1,6 +1,6 @@
1
- export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-D2W9TfCJ.cjs';
1
+ export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-BsdBbuOv.cjs';
2
2
  import { z } from 'zod';
3
- import './types-DVcX_zMp.cjs';
3
+ import './types-BtQexFpT.cjs';
4
4
 
5
5
  /**
6
6
  * AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
@@ -1,6 +1,6 @@
1
- export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-C3rfpkZw.js';
1
+ export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-BV9SK_cf.js';
2
2
  import { z } from 'zod';
3
- import './types-DVcX_zMp.js';
3
+ import './types-BtQexFpT.js';
4
4
 
5
5
  /**
6
6
  * AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
package/dist/index.cjs CHANGED
@@ -4474,6 +4474,130 @@ function contextWarningMessage(check2, kind) {
4474
4474
  return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
4475
4475
  }
4476
4476
 
4477
+ // src/embed-batch.ts
4478
+ var DEFAULT_EMBED_BATCH_SIZE = 32;
4479
+ var DEFAULT_EMBED_CONCURRENCY = 3;
4480
+ function findOversizedEmbedInputs(input, contextLength) {
4481
+ const oversized = [];
4482
+ for (let index = 0; index < input.length; index++) {
4483
+ const text = input[index];
4484
+ if (text === void 0) continue;
4485
+ const estimate = estimateTokens(text);
4486
+ if (estimate > contextLength) {
4487
+ oversized.push({ index, estimate });
4488
+ }
4489
+ }
4490
+ return oversized;
4491
+ }
4492
+ function embedBatchOverflowMessage(oversized, totalInputs, contextLength, truncateFalse) {
4493
+ const shown = oversized.slice(0, 5).map((entry) => `#${entry.index} (~${entry.estimate} tokens)`);
4494
+ const elided = oversized.length > 5 ? `, +${oversized.length - 5} more` : "";
4495
+ const outcome = truncateFalse ? "the server will reject them (`truncate: false`)" : "Ollama will silently truncate them, producing degraded vectors";
4496
+ return `embedBatch: ${oversized.length} of ${totalInputs} input strings exceed the ${contextLength}-token context window (${shown.join(", ")}${elided}). ${outcome}. Raise \`options.num_ctx\`, pre-split long inputs, or set \`truncate: true\` explicitly to acknowledge truncation.`;
4497
+ }
4498
+ function assertPositiveInteger(value, field) {
4499
+ if (!Number.isInteger(value) || value < 1) {
4500
+ throw new OllamaClientError(
4501
+ `embedBatch: \`${field}\` must be a positive integer (got ${value}).`,
4502
+ { code: "invalid_request" }
4503
+ );
4504
+ }
4505
+ }
4506
+ async function batchEmbed(client, options) {
4507
+ const { model, input } = options;
4508
+ const batchSize = options.batchSize ?? DEFAULT_EMBED_BATCH_SIZE;
4509
+ const concurrency = options.concurrency ?? DEFAULT_EMBED_CONCURRENCY;
4510
+ assertPositiveInteger(batchSize, "batchSize");
4511
+ assertPositiveInteger(concurrency, "concurrency");
4512
+ if (input.length === 0) {
4513
+ return { model, embeddings: [], batchCount: 0 };
4514
+ }
4515
+ const batches = [];
4516
+ for (let start = 0; start < input.length; start += batchSize) {
4517
+ batches.push(input.slice(start, start + batchSize));
4518
+ }
4519
+ const embeddings = new Array(input.length);
4520
+ const controller = new AbortController();
4521
+ const propagateExternalAbort = () => {
4522
+ controller.abort(options.signal?.reason);
4523
+ };
4524
+ if (options.signal) {
4525
+ if (options.signal.aborted) {
4526
+ propagateExternalAbort();
4527
+ } else {
4528
+ options.signal.addEventListener("abort", propagateExternalAbort, { once: true });
4529
+ }
4530
+ }
4531
+ let nextBatch = 0;
4532
+ let completedBatches = 0;
4533
+ let failure;
4534
+ const worker = async () => {
4535
+ for (; ; ) {
4536
+ if (controller.signal.aborted) {
4537
+ throw failure?.error ?? new OllamaAbortError("Embed batch aborted");
4538
+ }
4539
+ const batchIndex = nextBatch;
4540
+ nextBatch++;
4541
+ if (batchIndex >= batches.length) return;
4542
+ const batch = batches[batchIndex];
4543
+ if (!batch) return;
4544
+ try {
4545
+ const request = {
4546
+ model,
4547
+ input: batch,
4548
+ ...options.truncate !== void 0 ? { truncate: options.truncate } : {},
4549
+ ...options.dimensions !== void 0 ? { dimensions: options.dimensions } : {},
4550
+ ...options.keep_alive !== void 0 ? { keep_alive: options.keep_alive } : {},
4551
+ ...options.options !== void 0 ? { options: options.options } : {},
4552
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
4553
+ signal: controller.signal
4554
+ };
4555
+ const response = await client.embed(request);
4556
+ if (response.embeddings.length !== batch.length) {
4557
+ throw new OllamaClientError(
4558
+ `embedBatch: server returned ${response.embeddings.length} embeddings for a batch of ${batch.length} inputs (batch ${batchIndex}).`,
4559
+ { code: "invalid_response" }
4560
+ );
4561
+ }
4562
+ const offset = batchIndex * batchSize;
4563
+ for (let j = 0; j < batch.length; j++) {
4564
+ embeddings[offset + j] = response.embeddings[j];
4565
+ }
4566
+ completedBatches++;
4567
+ options.onBatchComplete?.(completedBatches, batches.length);
4568
+ } catch (error) {
4569
+ if (!failure) {
4570
+ failure = { error };
4571
+ controller.abort(error);
4572
+ throw error;
4573
+ }
4574
+ throw failure.error;
4575
+ }
4576
+ }
4577
+ };
4578
+ try {
4579
+ const workerCount = Math.min(concurrency, batches.length);
4580
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
4581
+ } finally {
4582
+ if (options.signal) {
4583
+ options.signal.removeEventListener("abort", propagateExternalAbort);
4584
+ }
4585
+ }
4586
+ for (let i = 0; i < embeddings.length; i++) {
4587
+ if (embeddings[i] === void 0) {
4588
+ throw new OllamaClientError(
4589
+ `embedBatch: internal invariant violated \u2014 no embedding produced for input #${i}.`,
4590
+ { code: "invalid_response" }
4591
+ );
4592
+ }
4593
+ }
4594
+ return {
4595
+ model,
4596
+ embeddings,
4597
+ batchCount: batches.length
4598
+ };
4599
+ }
4600
+
4477
4601
  // src/generated/api/operations.ts
4478
4602
  var blobsOp = {
4479
4603
  operationId: "blobs",
@@ -4998,6 +5122,14 @@ var OllamaClient = class {
4998
5122
  contextWarningThreshold;
4999
5123
  /** Configured overflow policy — `'warn'` (default) or `'throw'`. */
5000
5124
  onContextOverflow;
5125
+ /**
5126
+ * Controllers for every in-flight request and active stream managed by this
5127
+ * client — see {@link OllamaClient.destroy} and {@link createRequestScope}.
5128
+ * Entries live for exactly as long as the underlying work does: until the
5129
+ * response settles (requests) or the stream's `finalResult` settles
5130
+ * (streams, via `executeWithFailover`'s `holdUntil` release path).
5131
+ */
5132
+ activeRequests = /* @__PURE__ */ new Set();
5001
5133
  constructor(config = {}) {
5002
5134
  this.cloudApiKey = resolveApiKey(config.apiKey);
5003
5135
  const defaultBaseUrl = resolveBaseUrl(config.baseUrl);
@@ -5162,6 +5294,39 @@ var OllamaClient = class {
5162
5294
  }
5163
5295
  return { ...options, num_ctx: this.defaultContextLength };
5164
5296
  }
5297
+ /**
5298
+ * Creates the per-request abort scope registered with {@link destroy}:
5299
+ * a controller that fires when the caller's `signal` aborts OR when
5300
+ * `destroy()` runs, whichever comes first. `dispose()` removes the
5301
+ * registration when the work it guards settles — for plain requests when
5302
+ * the response settles, for streams when `finalResult` settles (the same
5303
+ * moment `executeWithFailover`'s `holdUntil` releases the endpoint slot),
5304
+ * so a destroy() can tear down a stream mid-consumption but never outlives
5305
+ * one that already finished.
5306
+ */
5307
+ createRequestScope(userSignal) {
5308
+ const controller = new AbortController();
5309
+ const propagateUserAbort = () => {
5310
+ controller.abort(userSignal?.reason);
5311
+ };
5312
+ let detach;
5313
+ if (userSignal) {
5314
+ if (userSignal.aborted) {
5315
+ propagateUserAbort();
5316
+ } else {
5317
+ userSignal.addEventListener("abort", propagateUserAbort, { once: true });
5318
+ detach = () => userSignal.removeEventListener("abort", propagateUserAbort);
5319
+ }
5320
+ }
5321
+ this.activeRequests.add(controller);
5322
+ return {
5323
+ signal: controller.signal,
5324
+ dispose: () => {
5325
+ detach?.();
5326
+ this.activeRequests.delete(controller);
5327
+ }
5328
+ };
5329
+ }
5165
5330
  /**
5166
5331
  * Fail-fast guard for `format` (structured output) requests: throws before any network
5167
5332
  * call if the candidate endpoint is inferred as Ollama Cloud, which does not currently
@@ -5178,7 +5343,8 @@ var OllamaClient = class {
5178
5343
  }
5179
5344
  }
5180
5345
  async executeWithFailover(operation, options) {
5181
- const timeout = createTimeoutSignal(options?.timeoutMs ?? this.timeoutMs, options?.signal);
5346
+ const scope = this.createRequestScope(options?.signal);
5347
+ const timeout = createTimeoutSignal(options?.timeoutMs ?? this.timeoutMs, scope.signal);
5182
5348
  const requestId = createLogicalRequestId();
5183
5349
  let deferTimeoutCancel = false;
5184
5350
  try {
@@ -5257,6 +5423,7 @@ var OllamaClient = class {
5257
5423
  if (holdPromise) {
5258
5424
  void holdPromise.catch(() => void 0).finally(() => {
5259
5425
  timeout.cancel();
5426
+ scope.dispose();
5260
5427
  this.registry.release(endpoint.name);
5261
5428
  });
5262
5429
  } else {
@@ -5267,7 +5434,10 @@ var OllamaClient = class {
5267
5434
  throw lastError ?? new Error("No healthy Ollama endpoints available");
5268
5435
  }
5269
5436
  } finally {
5270
- if (!deferTimeoutCancel) timeout.cancel();
5437
+ if (!deferTimeoutCancel) {
5438
+ timeout.cancel();
5439
+ scope.dispose();
5440
+ }
5271
5441
  }
5272
5442
  }
5273
5443
  async chat(req) {
@@ -5427,6 +5597,58 @@ var OllamaClient = class {
5427
5597
  const res = await this.embed({ model, input });
5428
5598
  return res.embeddings;
5429
5599
  }
5600
+ /**
5601
+ * Batch-constrained, order-preserving embedding generation for large
5602
+ * corpora — the safe building block for RAG/vector-index ingestion at
5603
+ * scale. Splits `input` into `batchSize` slices and embeds them through
5604
+ * {@link embed} with at most `concurrency` batches in flight (defaults:
5605
+ * 32 × 3), so `Promise.all`-style floods that OOM local daemons and
5606
+ * saturate `OLLAMA_MAX_QUEUE` can't happen by accident. Fail-fast: the
5607
+ * first batch error aborts every sibling in-flight batch and rejects the
5608
+ * whole operation with the original error.
5609
+ *
5610
+ * Before any request is sent, `defaultContextLength` is injected as
5611
+ * `num_ctx` (same as chat/generate) and each input string's token estimate
5612
+ * is checked against the resolved window: Ollama silently truncates
5613
+ * oversized embedding inputs by default, so the client warns (or throws,
5614
+ * under `onContextOverflow: 'throw'`) with the offending indexes — see
5615
+ * `src/embed-batch.ts` for the exact trip point (no margin: an embedding
5616
+ * prompt IS the whole input).
5617
+ *
5618
+ * ```ts
5619
+ * const { embeddings, batchCount } = await client.embedBatch({
5620
+ * model: 'nomic-embed-text:latest',
5621
+ * input: corpus, // e.g. 50k chunks
5622
+ * batchSize: 32, // inputs per /api/embed request
5623
+ * concurrency: 3, // batches in flight
5624
+ * onBatchComplete: (done, total) => progress.log(`${done}/${total}`),
5625
+ * });
5626
+ * // embeddings[i] always corresponds to corpus[i]
5627
+ * ```
5628
+ */
5629
+ async embedBatch(options) {
5630
+ const resolvedOptions = this.injectDefaultContextLength(options.options);
5631
+ const contextLength = resolvedOptions?.num_ctx;
5632
+ if (contextLength !== void 0) {
5633
+ const oversized = findOversizedEmbedInputs(options.input, contextLength);
5634
+ if (oversized.length > 0) {
5635
+ const message = embedBatchOverflowMessage(
5636
+ oversized,
5637
+ options.input.length,
5638
+ contextLength,
5639
+ options.truncate === false
5640
+ );
5641
+ if (this.onContextOverflow === "throw") {
5642
+ throw new OllamaClientError(message, { code: "context_overflow" });
5643
+ }
5644
+ this.logger.warn(message);
5645
+ }
5646
+ }
5647
+ return batchEmbed(
5648
+ this,
5649
+ resolvedOptions === options.options ? options : { ...options, options: resolvedOptions }
5650
+ );
5651
+ }
5430
5652
  /**
5431
5653
  * @deprecated Ollama's `/api/embeddings` endpoint has been superseded by `/api/embed`
5432
5654
  * (exposed here as {@link OllamaClient.embed}), which additionally supports batch
@@ -5596,10 +5818,12 @@ var OllamaClient = class {
5596
5818
  async executeCloudRequest(operation, options) {
5597
5819
  var _stack = [];
5598
5820
  try {
5599
- const timeout = createTimeoutSignal(options.timeoutMs ?? this.timeoutMs, options.signal);
5821
+ const scope = this.createRequestScope(options.signal);
5822
+ const timeout = createTimeoutSignal(options.timeoutMs ?? this.timeoutMs, scope.signal);
5600
5823
  const _timeout = __using(_stack, {
5601
5824
  [Symbol.dispose]() {
5602
5825
  timeout.cancel();
5826
+ scope.dispose();
5603
5827
  }
5604
5828
  });
5605
5829
  const requestId = createLogicalRequestId();
@@ -5660,6 +5884,34 @@ var OllamaClient = class {
5660
5884
  endpointStatus() {
5661
5885
  return this.registry.status();
5662
5886
  }
5887
+ /**
5888
+ * Immediately aborts every in-flight request and active stream managed by
5889
+ * this client — the clean teardown path for short-lived processes, CLI
5890
+ * runners, and `node:worker_threads` tasks where dangling fetch bodies,
5891
+ * unconsumed stream readers, or queued capacity waiters would otherwise
5892
+ * keep the event loop (and thus the thread/process) alive.
5893
+ *
5894
+ * Every aborted operation rejects with an {@link OllamaAbortError}
5895
+ * (`code: 'aborted'`) carrying `reason` as its message — including requests
5896
+ * still queued behind `maxConcurrentPerEndpoint`, not just dispatched ones.
5897
+ * Streams reject their `finalResult` and release their endpoint slots.
5898
+ *
5899
+ * Idempotent: calling it again (or on a client with nothing in flight)
5900
+ * is a no-op that returns `0`. The client remains usable afterward —
5901
+ * `destroy()` is a drain, not a permanent disable; callers that want a
5902
+ * permanently-dead client should discard the instance.
5903
+ *
5904
+ * @returns the number of in-flight operations that were aborted.
5905
+ */
5906
+ destroy(reason = "Ollama client destroyed") {
5907
+ const controllers = [...this.activeRequests];
5908
+ const cause = new OllamaAbortError(reason);
5909
+ for (const controller of controllers) {
5910
+ controller.abort(cause);
5911
+ }
5912
+ this.activeRequests.clear();
5913
+ return controllers.length;
5914
+ }
5663
5915
  // --- Compatibility Adapters ---
5664
5916
  get openai() {
5665
5917
  const ep = this.registry.candidates()[0];
@@ -6821,6 +7073,8 @@ exports.ConversationSession = ConversationSession;
6821
7073
  exports.DEFAULT_BACKOFF = DEFAULT_BACKOFF;
6822
7074
  exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
6823
7075
  exports.DEFAULT_CONTEXT_WARNING_THRESHOLD = DEFAULT_CONTEXT_WARNING_THRESHOLD;
7076
+ exports.DEFAULT_EMBED_BATCH_SIZE = DEFAULT_EMBED_BATCH_SIZE;
7077
+ exports.DEFAULT_EMBED_CONCURRENCY = DEFAULT_EMBED_CONCURRENCY;
6824
7078
  exports.DEFAULT_FAILOVER_CODES = DEFAULT_FAILOVER_CODES;
6825
7079
  exports.DEFAULT_FETCH_BACKOFF_CONFIG = DEFAULT_FETCH_BACKOFF_CONFIG;
6826
7080
  exports.DEFAULT_RETRY_CONFIG = DEFAULT_RETRY_CONFIG;
@@ -6882,6 +7136,7 @@ exports.ToolCallAccumulator = ToolCallAccumulator;
6882
7136
  exports.ToolRegistry = ToolRegistry;
6883
7137
  exports.addSpanEvent = addSpanEvent;
6884
7138
  exports.applySkill = applySkill;
7139
+ exports.batchEmbed = batchEmbed;
6885
7140
  exports.calculateBackoff = calculateBackoff;
6886
7141
  exports.checkChatContext = checkChatContext;
6887
7142
  exports.checkEndpointHealth = checkEndpointHealth;
@@ -6894,6 +7149,7 @@ exports.createOllamaCloudFreeTierQuota = createOllamaCloudFreeTierQuota;
6894
7149
  exports.createTimeoutSignal = createTimeoutSignal;
6895
7150
  exports.defineTool = defineTool;
6896
7151
  exports.detectModelCapabilities = detectModelCapabilities;
7152
+ exports.embedBatchOverflowMessage = embedBatchOverflowMessage;
6897
7153
  exports.encodeImage = encodeImage;
6898
7154
  exports.estimateChatRequestTokens = estimateChatRequestTokens;
6899
7155
  exports.estimateGenerateRequestTokens = estimateGenerateRequestTokens;
@@ -6901,6 +7157,7 @@ exports.estimateSystemOneRequestBytes = estimateSystemOneRequestBytes;
6901
7157
  exports.estimateTokens = estimateTokens;
6902
7158
  exports.extractUsage = extractUsage;
6903
7159
  exports.fetchWithBackoff = fetchWithBackoff;
7160
+ exports.findOversizedEmbedInputs = findOversizedEmbedInputs;
6904
7161
  exports.imageStringNeedsResolution = imageStringNeedsResolution;
6905
7162
  exports.inferRuntimeMode = inferRuntimeMode;
6906
7163
  exports.isKeepAliveSugar = isKeepAliveSugar;