@nemesis-oss/ollama-sdk 1.20.1 → 1.21.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.21.0] — `systemOne` takes a cancellation signal; the failover layer stops dropping caller signals (ADR 0037)
4
+
5
+ The Decision Plane in nexum could not bound a System One call: its `AbortSignal` never reached the wire. Tracing that found a defect in the SDK, not in nexum. `FailoverHttpClient.request` replaced the caller's signal with the failover layer's own in every attempt, so a signal passed to any runtime operation that accepts one was silently ignored. [ADR 0037](./docs/adr/0037-systemone-signal-failover-cancellation.md) records the root cause and the declined alternatives.
6
+
7
+ ### Fixed — per-call signals are honoured
8
+
9
+ - **`native.tags`, `native.ps`, `native.version`, `native.blobs`, and `runtime.invoke({ signal })`** now cancel in flight. They accepted the option and ignored it before this release. A caller that relied on the signal being ignored will now see an abort, and a caller that passes an already-aborted signal fails immediately without sending the request.
10
+
11
+ ### Added
12
+
13
+ - **`client.systemOne(request, { signal })`.** The signal covers the whole call: the cloud-model version probe that runs first (ADR 0036), and the request on the wire. An abort rejects with `OllamaAbortError`. The probe is raced against the signal rather than cancelled, so one impatient caller cannot poison the cached probe for later calls.
14
+
15
+ ### Changed
16
+
17
+ - `systemOne` calls `runtime.invoke` with the same operation and body that `NativeApi.systemOne` uses. Calls that pass no signal send the same request as before.
18
+
19
+ ### Docs
20
+
21
+ - `docs-site/api/client.md` and `docs-site/guide/system-one.md` document the signal. ADR 0037 is added to both ADR indexes.
22
+
23
+ ### Notes — CHANGELOG tidy
24
+
25
+ - Two `[Unreleased]` headings held entries that had already shipped. The 1.9.0-era entries (the IPv6 loopback fix, host normalization, and the digest work) now sit under 1.9.0, since their commits are in the `v1.9.0` tag. The MCP bridge and stdio entries now sit under "Releases 1.5.0 to 1.8.0", since their first commits are between `v1.5.0` and `v1.8.0`. There are still no release sections for 1.5.0, 1.5.1, or 1.6.0 in this file.
26
+
3
27
  ## [1.20.1] — Self-hosted servers are no longer refused as Ollama Cloud
4
28
 
5
29
  Structured output (`format`) was refused pre-flight for every base URL outside `localhost`, `*.local`, `192.168.*` and `10.*`. The classifier treated any other host as cloud, so a self-hosted server reached by a Docker or Kubernetes service name (`http://ollama:11434`), a `172.16/12` address, or a public domain of its own threw `OllamaUnsupportedCapabilityError` on every `format` request. There was no opt-out. Caught while wiring `@nemesis-oss/ollama-sdk` into agentic-tui, whose docker-compose stack points the supervisor at `http://ollama:11434`.
@@ -420,18 +444,18 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
420
444
  - Five runnable examples: `examples/vision-inputs.ts`, `examples/responses-bridge.ts`, `examples/conversation-session.ts`, `examples/context-safety.ts`, `examples/blob-publishing.ts` (`npm run example <file>`) — plus `examples/usage-balance.ts` from the docs-recheck pass.
421
445
  - 50 new unit tests (mocked fetch — CI-safe, no daemon): `test/vision-resolver.test.ts`, `test/responses-bridge.test.ts`, `test/conversation-session.test.ts`, `test/context-safety.test.ts`, `test/blob-helpers.test.ts` — plus 10 more in `test/usage-balance.test.ts`.
422
446
 
423
- ## [Unreleased]
447
+ ### Also shipped in 1.9.0 (filed under Unreleased)
424
448
 
425
- ### Fix: IPv6 loopback was classified as Ollama Cloud
449
+ #### Fix: IPv6 loopback was classified as Ollama Cloud
426
450
 
427
451
  - `inferRuntimeMode()` compared `URL.hostname` to `'::1'`, but WHATWG URL reports an IPv6 literal with its brackets (`'[::1]'`), so `http://[::1]:11434` was classified as `cloud`. Structured-output (`format`) requests to a local Ollama on IPv6 loopback were therefore refused pre-flight with `OllamaUnsupportedCapabilityError`, and `localMode` was off for capability detection. The brackets are now stripped before comparing. Public IPv6 addresses are still `cloud`.
428
452
 
429
- ### Host normalization and scoped `OLLAMA_API_KEY`
453
+ #### Host normalization and scoped `OLLAMA_API_KEY`
430
454
 
431
455
  - **Base URL normalization.** `baseUrl`, `OLLAMA_HOST`, per-credential `baseUrl` and `endpoints[].baseUrl` now accept the bare forms the Ollama CLI accepts: `127.0.0.1:11434`, `0.0.0.0`, `:11434`, `myhost`. A missing scheme defaults to `http://`, a missing port on a scheme-less host defaults to `11434`, scheme-less port `443` selects `https://`, and `:port` means `127.0.0.1`. Full URLs and reverse-proxy path prefixes are unchanged. Previously a scheme-less value produced an invalid request URL.
432
456
  - **Behavior change: `OLLAMA_API_KEY` is no longer forwarded to arbitrary hosts.** The env-sourced key is applied to the default endpoint only when its host is Ollama Cloud (`ollama.com`, `*.ollama.com`) or loopback. An explicit `apiKey` is always sent, to any host. If you relied on `OLLAMA_API_KEY` for a LAN or proxied server, pass `apiKey` explicitly. Web search/fetch (always `https://ollama.com`) still use the env key.
433
457
 
434
- ### Production-readiness audit — tool-call accumulator, mock server
458
+ #### Production-readiness audit — tool-call accumulator, mock server
435
459
 
436
460
  - **Offline wire-format conformance tests** (`test/conformance/native-api-offline.test.ts`). The sibling `native-api.test.ts` hits a REAL Ollama server and skips when none is available. The new offline tests use `OllamaMockServer` to emit contract-shaped NDJSON frames without any model inference, so they run in every CI environment — including those without an Ollama daemon. They validate ONLY the wire format (schema conformance, error-class mapping, optional-field handling), NOT model behavior. The split exists because wire-format drift is a contract concern (catchable offline), while model-behavior drift is a server/model concern (only catchable against a live server). 22 new tests covering:
437
461
  - `GET /api/version`, `/api/tags`, `/api/ps`, `POST /api/show` schema conformance
@@ -443,7 +467,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
443
467
  - `OllamaClient` vs `NativeApi` shape parity (mirrors the live parity test)
444
468
  - Updated `test/conformance/harness.ts` docstring to document the live-vs-offline split.
445
469
 
446
- ### Production-readiness audit — tool-call accumulator, mock server
470
+ #### Production-readiness audit — tool-call accumulator, mock server
447
471
 
448
472
  - **Defensive tool-call streaming accumulator (`src/tools/tool-call-accumulator.ts`).** The original `aggregateChat` in `src/streaming/normalize.ts` blindly appended every chunk's `tool_calls` array, which would produce duplicate entries if Ollama ever started streaming them incrementally. The new `ToolCallAccumulator` matches entries by `id` (the SDK-synthesized stable identifier) or by array position, and merges them with spread-semantics on `function.arguments`. For the documented native Ollama behavior (tool_calls arrive complete in one chunk), the accumulator is a no-op — one chunk carries the full array, the next chunk has nothing, and the accumulator appends exactly once. The accumulator is also exported for callers who want the same merge logic on raw NDJSON chunks.
449
473
  - New exports: `ToolCallAccumulator` class, `mergeToolCallArrays()` stateless helper, `mergeToolCall()` / `mergeToolCallArgumentsString()` / `isSameToolCall()` standalone helpers.
@@ -460,7 +484,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
460
484
  - Idempotent `stop()` — safe to call from `afterEach` hooks without tracking whether `start()` ran.
461
485
  - Ephemeral port support (`port: 0`) for parallel test runs.
462
486
 
463
- ### Second digest — mid-stream errors, 502/503 specialization, pull-progress ergonomics
487
+ #### Second digest — mid-stream errors, 502/503 specialization, pull-progress ergonomics
464
488
 
465
489
  - **In-band stream-error trapping (NDJSON).** Ollama's streaming endpoints start with HTTP 200 OK and chunked transfer encoding; if generation fails mid-flight (GPU OOM, driver crash, context window overflow, model unload race), the server emits a final `{"error": "..."}` JSON frame and closes the stream. The HTTP status code never changes from 200, so HTTP-status-based error detection misses these errors entirely — they would silently bleed into the assistant's content stream as garbage tokens or undefined-field accesses.
466
490
  - `parseNdjsonStream` now detects bare `{"error": "..."}` frames and throws `OllamaStreamError` immediately, with the error message preserved.
@@ -496,7 +520,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
496
520
  - Otherwise → `'unknown'` (caller should treat as "ask the model and see what it does").
497
521
  - The family is extracted from `/api/show`'s `model_info.*.architecture` (with fallbacks to `details.family` then `details.families[0]`).
498
522
 
499
- ### First digest — VRAM lifecycle, fetchWithBackoff, System One image caps, done_reason decoder
523
+ #### First digest — VRAM lifecycle, fetchWithBackoff, System One image caps, done_reason decoder
500
524
 
501
525
  - **VRAM lifecycle primitives.** Added ergonomic helpers for explicit GPU memory management, addressing the upstream `keep_alive` semantics documented in the Ollama FAQ:
502
526
  - `KeepAlive` type union (`string | number | 'unload' | 'indefinite'`) and `normalizeKeepAlive()` helper map the SDK-level sugar literals `'unload'` and `'indefinite'` to the wire-level `0` and `-1` sentinels.
@@ -554,7 +578,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
554
578
 
555
579
  - MCP tool arguments are now validated against advertised JSON Schema constraints before dispatch, while `resultMode: 'structured'` preserves raw MCP `CallToolResult` objects for programmatic consumers.
556
580
 
557
- ## [Unreleased]
581
+ ## Releases 1.5.0 to 1.8.0 (filed under Unreleased)
558
582
 
559
583
  - **MCP bridge hardening.** MCP tool discovery now supports bounded pagination, repeated-cursor protection, request cancellation, richer MCP metadata types, and structured/non-text result preservation.
560
584
  - **Optional Node MCP stdio adapter.** `@nemesis-oss/ollama-sdk/mcp/stdio` connects to local MCP servers using the official `@modelcontextprotocol/client` v2 transport without importing Node-only code into the root package.
package/dist/index.cjs CHANGED
@@ -2626,6 +2626,24 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2626
2626
  };
2627
2627
  return { signal: controller.signal, cancel };
2628
2628
  }
2629
+ function untilAborted(promise, signal, message) {
2630
+ if (signal === void 0) return promise;
2631
+ return new Promise((resolve, reject) => {
2632
+ const onAbort = () => reject(new OllamaAbortError(message));
2633
+ promise.then(
2634
+ (value) => {
2635
+ signal.removeEventListener("abort", onAbort);
2636
+ resolve(value);
2637
+ },
2638
+ (error) => {
2639
+ signal.removeEventListener("abort", onAbort);
2640
+ reject(error);
2641
+ }
2642
+ );
2643
+ if (signal.aborted) onAbort();
2644
+ else signal.addEventListener("abort", onAbort, { once: true });
2645
+ });
2646
+ }
2629
2647
 
2630
2648
  // src/keep-alive.ts
2631
2649
  var KEEP_ALIVE_UNLOAD = 0;
@@ -5653,15 +5671,13 @@ var FailoverHttpClient = class {
5653
5671
  async request(options) {
5654
5672
  const { model, ...httpOptions } = options;
5655
5673
  return this.client.executeWithFailover(
5656
- async (http, signal) => {
5657
- const mergedOptions = {
5658
- ...httpOptions,
5659
- ...httpOptions.signal !== void 0 ? { signal } : { signal }
5660
- };
5661
- return http.request(mergedOptions);
5662
- },
5674
+ // The signal handed to the operation already combines the caller's signal
5675
+ // (passed below) with the failover scope and timeout. Using it, rather than
5676
+ // the caller's, keeps the request cancellable by `destroy()` as well.
5677
+ async (http, signal) => http.request({ ...httpOptions, signal }),
5663
5678
  {
5664
5679
  singleEndpoint: false,
5680
+ ...httpOptions.signal !== void 0 ? { signal: httpOptions.signal } : {},
5665
5681
  ...model !== void 0 ? { model } : {}
5666
5682
  }
5667
5683
  );
@@ -6501,12 +6517,18 @@ var OllamaClient = class _OllamaClient {
6501
6517
  *
6502
6518
  * For the low-level generated API (without the generic wrapper), use
6503
6519
  * `client.native.systemOne(request)` directly.
6520
+ *
6521
+ * @param options.signal Cancels the whole call, including the cloud-model
6522
+ * version probe that runs first, so the gate never extends a caller's
6523
+ * deadline. An abort rejects with an {@link OllamaAbortError}.
6504
6524
  */
6505
- async systemOne(request) {
6506
- await this.assertCloudModelSupported(request.model);
6507
- return this.native.systemOne(
6508
- request
6509
- );
6525
+ async systemOne(request, options = {}) {
6526
+ await this.assertCloudModelSupported(request.model, options.signal);
6527
+ return this.runtime.invoke({
6528
+ operation: systemOneOp,
6529
+ body: request,
6530
+ ...options.signal !== void 0 ? { signal: options.signal } : {}
6531
+ });
6510
6532
  }
6511
6533
  /**
6512
6534
  * Cloud-model version gate for {@link systemOne} — throws
@@ -6518,19 +6540,25 @@ var OllamaClient = class _OllamaClient {
6518
6540
  * so a later gated call retries), so at most one extra round-trip happens
6519
6541
  * across all gated calls.
6520
6542
  */
6521
- async assertCloudModelSupported(model) {
6543
+ async assertCloudModelSupported(model, signal) {
6522
6544
  if (!isCloudModel(model)) return;
6523
6545
  const candidates = this.registry.candidates();
6524
6546
  const baseUrl = candidates[0]?.baseUrl ?? DEFAULT_BASE_URL;
6525
6547
  if (inferRuntimeMode(baseUrl) !== "local") return;
6548
+ if (this.serverVersionCache === void 0) {
6549
+ this.serverVersionCache = this.native.version().then((v) => v.version);
6550
+ }
6551
+ const probe = this.serverVersionCache;
6526
6552
  let version;
6527
6553
  try {
6528
- if (this.serverVersionCache === void 0) {
6529
- this.serverVersionCache = this.native.version().then((v) => v.version);
6530
- }
6531
- version = await this.serverVersionCache;
6532
- } catch {
6533
- this.serverVersionCache = void 0;
6554
+ version = await untilAborted(
6555
+ probe,
6556
+ signal,
6557
+ "System One call aborted while probing the Ollama server version"
6558
+ );
6559
+ } catch (error) {
6560
+ if (signal?.aborted) throw error;
6561
+ if (this.serverVersionCache === probe) this.serverVersionCache = void 0;
6534
6562
  return;
6535
6563
  }
6536
6564
  if (version && compareOllamaVersions(version, CLOUD_MODEL_MIN_OLLAMA_VERSION) < 0) {