@nemesis-oss/ollama-sdk 1.20.0 → 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,56 @@
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
+
27
+ ## [1.20.1] — Self-hosted servers are no longer refused as Ollama Cloud
28
+
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`.
30
+
31
+ ### The classification rule
32
+
33
+ `inferRuntimeMode(baseUrl)` now classifies by hostname alone, with three outcomes:
34
+
35
+ - **`cloud`**: `ollama.com` and `*.ollama.com` only. A suffix lookalike such as `ollama.com.evil.net` is not cloud. Only this host is refused for `format`.
36
+ - **`local`**: a private-network server. That covers loopback (`127.0.0.0/8`, `::1`, `0.0.0.0`, `*.localhost`), RFC 1918 IPv4 (`10/8`, `172.16/12`, `192.168/16`), IPv6 unique-local (`fc00::/7`), `*.local`, `*.internal`, and single-label names with no dot or colon, such as Docker or Kubernetes service names.
37
+ - **`unknown`**: every other hostname, including self-hosted servers on public domains. These are not refused for `format`, and they are not assumed to be local.
38
+
39
+ The IPv4 checks match address literals only, so `10.example.com` is no longer mistaken for `10.0.0.0/8`.
40
+
41
+ ### Behavior changes
42
+
43
+ - `format` requests go to self-hosted servers on any host. Only `ollama.com` is refused.
44
+ - `parallelToolCalls` for `local` hosts uses the local family and name heuristics. For `unknown` hosts it reports `'unknown'`. Previously every non-private host got the cloud heuristic, which reported `'yes'` for any tool-capable model.
45
+ - The cloud-model version gate (`OllamaCloudModelVersionError`) probes `/api/version` only for `local` endpoints. Previously a self-hosted server with a non-private hostname skipped the gate. It now still skips it when the hostname is `unknown`.
46
+ - `runtimeMode()` can now return `'unknown'`, as it already could for an unparseable base URL.
47
+
48
+ ### Tests
49
+
50
+ - `test/unit-capabilities.test.ts`: classification table for each outcome, including the lookalike and address-literal edge cases.
51
+ - `test/parallel-tool-calls.test.ts`: a public self-hosted host reports `parallelToolCalls: 'unknown'` (previously `'yes'`).
52
+ - `test/unsupported-capability.test.ts`: `format` requests reach a self-hosted endpoint on a Docker service name and on a public domain.
53
+
3
54
  ## [1.20.0] — Post-audit v1.19.1 refresh: re-pin 36a6e99, usage model grouping, ShowResponse prompt provenance, the 0.40.5 cloud-model gate
4
55
 
5
56
  The daily audit against the v1.19.1 baseline claimed "zero upstream drift since d9b8601, no action needed"; independent verification (raw content diff of upstream `docs/openapi.yaml` against the pin — the same discipline that caught the v1.19.0 audit's false row) found the headline false and two repository-internal defects besides. Two upstream commits had landed after the 2026-10-16 pin: `2476d5e` adds model grouping to cloud usage, and `36a6e99` populates `ShowResponse.renderer/parser` and adds `template_source`. Separately: the docs had promised since 1.19.0 that cloud models (`clef:cloud`) require "Ollama >= 0.40.5, enforced automatically" while only the blanket 0.35.0 floor was ever enforced, and `docs-index.json`'s `pinnedSha` silently lagged the pin by one refresh (nothing consumed it, no test covered it). All changes additive or gap-closing; no breaking changes (see [ADR 0036](./docs/adr/0036-post-audit-v1191-refresh-usage-grouping-cloud-gate.md)). 1087 pre-existing tests stay green, 14 net-new tests added.
@@ -393,18 +444,18 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
393
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.
394
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`.
395
446
 
396
- ## [Unreleased]
447
+ ### Also shipped in 1.9.0 (filed under Unreleased)
397
448
 
398
- ### Fix: IPv6 loopback was classified as Ollama Cloud
449
+ #### Fix: IPv6 loopback was classified as Ollama Cloud
399
450
 
400
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`.
401
452
 
402
- ### Host normalization and scoped `OLLAMA_API_KEY`
453
+ #### Host normalization and scoped `OLLAMA_API_KEY`
403
454
 
404
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.
405
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.
406
457
 
407
- ### Production-readiness audit — tool-call accumulator, mock server
458
+ #### Production-readiness audit — tool-call accumulator, mock server
408
459
 
409
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:
410
461
  - `GET /api/version`, `/api/tags`, `/api/ps`, `POST /api/show` schema conformance
@@ -416,7 +467,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
416
467
  - `OllamaClient` vs `NativeApi` shape parity (mirrors the live parity test)
417
468
  - Updated `test/conformance/harness.ts` docstring to document the live-vs-offline split.
418
469
 
419
- ### Production-readiness audit — tool-call accumulator, mock server
470
+ #### Production-readiness audit — tool-call accumulator, mock server
420
471
 
421
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.
422
473
  - New exports: `ToolCallAccumulator` class, `mergeToolCallArrays()` stateless helper, `mergeToolCall()` / `mergeToolCallArgumentsString()` / `isSameToolCall()` standalone helpers.
@@ -433,7 +484,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
433
484
  - Idempotent `stop()` — safe to call from `afterEach` hooks without tracking whether `start()` ran.
434
485
  - Ephemeral port support (`port: 0`) for parallel test runs.
435
486
 
436
- ### Second digest — mid-stream errors, 502/503 specialization, pull-progress ergonomics
487
+ #### Second digest — mid-stream errors, 502/503 specialization, pull-progress ergonomics
437
488
 
438
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.
439
490
  - `parseNdjsonStream` now detects bare `{"error": "..."}` frames and throws `OllamaStreamError` immediately, with the error message preserved.
@@ -469,7 +520,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
469
520
  - Otherwise → `'unknown'` (caller should treat as "ask the model and see what it does").
470
521
  - The family is extracted from `/api/show`'s `model_info.*.architecture` (with fallbacks to `details.family` then `details.families[0]`).
471
522
 
472
- ### 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
473
524
 
474
525
  - **VRAM lifecycle primitives.** Added ergonomic helpers for explicit GPU memory management, addressing the upstream `keep_alive` semantics documented in the Ollama FAQ:
475
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.
@@ -527,7 +578,7 @@ A second full audit against the current official Ollama docs (`docs/openapi.yaml
527
578
 
528
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.
529
580
 
530
- ## [Unreleased]
581
+ ## Releases 1.5.0 to 1.8.0 (filed under Unreleased)
531
582
 
532
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.
533
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
@@ -1608,16 +1608,23 @@ async function checkEndpointHealth(endpoint, fetchImpl, timeoutMs = 5e3, middlew
1608
1608
 
1609
1609
  // src/capabilities/capabilities.ts
1610
1610
  function inferRuntimeMode(baseUrl) {
1611
+ let host;
1611
1612
  try {
1612
- const url = new URL(baseUrl);
1613
- const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
1614
- if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local") || host.startsWith("192.168.") || host.startsWith("10.")) {
1615
- return "local";
1616
- }
1617
- return "cloud";
1613
+ host = new URL(baseUrl).hostname.toLowerCase().replace(/^\[|\]$/g, "");
1618
1614
  } catch {
1619
1615
  return "unknown";
1620
1616
  }
1617
+ if (host === "ollama.com" || host.endsWith(".ollama.com")) return "cloud";
1618
+ if (isPrivateNetworkHost(host)) return "local";
1619
+ return "unknown";
1620
+ }
1621
+ function isPrivateNetworkHost(host) {
1622
+ if (!host.includes(".") && !host.includes(":")) return true;
1623
+ if (host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal"))
1624
+ return true;
1625
+ if (host === "::1" || host === "0.0.0.0") return true;
1626
+ if (host.includes(":") && /^f[cd][0-9a-f]{0,2}:/.test(host)) return true;
1627
+ return /^(?:127|10)\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host) || /^192\.168\.\d{1,3}\.\d{1,3}$/.test(host) || /^172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/.test(host);
1621
1628
  }
1622
1629
  function extractContextLength(modelInfo) {
1623
1630
  if (!modelInfo) return void 0;
@@ -2619,6 +2626,24 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2619
2626
  };
2620
2627
  return { signal: controller.signal, cancel };
2621
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
+ }
2622
2647
 
2623
2648
  // src/keep-alive.ts
2624
2649
  var KEEP_ALIVE_UNLOAD = 0;
@@ -5646,15 +5671,13 @@ var FailoverHttpClient = class {
5646
5671
  async request(options) {
5647
5672
  const { model, ...httpOptions } = options;
5648
5673
  return this.client.executeWithFailover(
5649
- async (http, signal) => {
5650
- const mergedOptions = {
5651
- ...httpOptions,
5652
- ...httpOptions.signal !== void 0 ? { signal } : { signal }
5653
- };
5654
- return http.request(mergedOptions);
5655
- },
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 }),
5656
5678
  {
5657
5679
  singleEndpoint: false,
5680
+ ...httpOptions.signal !== void 0 ? { signal: httpOptions.signal } : {},
5658
5681
  ...model !== void 0 ? { model } : {}
5659
5682
  }
5660
5683
  );
@@ -6494,12 +6517,18 @@ var OllamaClient = class _OllamaClient {
6494
6517
  *
6495
6518
  * For the low-level generated API (without the generic wrapper), use
6496
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}.
6497
6524
  */
6498
- async systemOne(request) {
6499
- await this.assertCloudModelSupported(request.model);
6500
- return this.native.systemOne(
6501
- request
6502
- );
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
+ });
6503
6532
  }
6504
6533
  /**
6505
6534
  * Cloud-model version gate for {@link systemOne} — throws
@@ -6511,19 +6540,25 @@ var OllamaClient = class _OllamaClient {
6511
6540
  * so a later gated call retries), so at most one extra round-trip happens
6512
6541
  * across all gated calls.
6513
6542
  */
6514
- async assertCloudModelSupported(model) {
6543
+ async assertCloudModelSupported(model, signal) {
6515
6544
  if (!isCloudModel(model)) return;
6516
6545
  const candidates = this.registry.candidates();
6517
6546
  const baseUrl = candidates[0]?.baseUrl ?? DEFAULT_BASE_URL;
6518
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;
6519
6552
  let version;
6520
6553
  try {
6521
- if (this.serverVersionCache === void 0) {
6522
- this.serverVersionCache = this.native.version().then((v) => v.version);
6523
- }
6524
- version = await this.serverVersionCache;
6525
- } catch {
6526
- 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;
6527
6562
  return;
6528
6563
  }
6529
6564
  if (version && compareOllamaVersions(version, CLOUD_MODEL_MIN_OLLAMA_VERSION) < 0) {