@nemesis-oss/ollama-sdk 1.13.0 → 1.15.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 +43 -0
- package/README.md +170 -4
- package/dist/{anthropic-B9AFyzXI.d.ts → anthropic-DrDUfbfQ.d.ts} +1 -1
- package/dist/{anthropic-CYy_ECam.d.cts → anthropic-UeaVjLeP.d.cts} +1 -1
- package/dist/{compose-6UOPEknN.d.ts → compose-CeFbHJeO.d.ts} +1 -1
- package/dist/{compose-DhvoUrWa.d.cts → compose-DFkmtUOP.d.cts} +1 -1
- package/dist/generated-api.d.cts +5 -5
- package/dist/generated-api.d.ts +5 -5
- package/dist/generated-runtime.d.cts +2 -2
- package/dist/generated-runtime.d.ts +2 -2
- package/dist/index.cjs +140 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +185 -11
- package/dist/index.d.ts +185 -11
- package/dist/index.js +140 -3
- package/dist/index.js.map +1 -1
- package/dist/mcp-generated.d.cts +2 -2
- package/dist/mcp-generated.d.ts +2 -2
- package/dist/{runtime-CIQYCRL3.d.ts → runtime-BIjr5ME4.d.ts} +1 -1
- package/dist/{runtime-DmI1cOEq.d.cts → runtime-C4RRHqOs.d.cts} +1 -1
- package/dist/skills.d.cts +3 -3
- package/dist/skills.d.ts +3 -3
- package/dist/testing.cjs +283 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +376 -0
- package/dist/testing.d.ts +376 -0
- package/dist/testing.js +277 -0
- package/dist/testing.js.map +1 -0
- package/dist/{types-B57ZZfp3.d.cts → types-DbPnfVh-.d.cts} +20 -3
- package/dist/{types-B57ZZfp3.d.ts → types-DbPnfVh-.d.ts} +20 -3
- package/package.json +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.15.0] — Draft benchmark utility & request-aware mock routes: `compareDraftPerformance`, mock `responder`
|
|
4
|
+
|
|
5
|
+
The October 13 digest ("Speculative Performance & Telemetry Observations") reviewed how upstream reports speculative efficiency — no dedicated draft-token acceptance counter, throughput-only observability, a noisy signal (rejected drafts _add_ compute on creative prompts; accepted drafts slash `eval_duration` on structured ones) — and asked for a benchmark helper plus mock scenario hooks. Audited against the codebase first (see [ADR 0030](./docs/adr/0030-draft-benchmark-request-aware-mock-routes.md)): the "manual nanosecond ratios" claim was partially stale — `formatTelemetry` derives tokens/second since v1.12.0 and `lab/15` implements the full protocol, but neither reaches npm consumers (`files` publishes only `dist/`), so the packaged-harness gap was real. The mock scenario claims were stale: parallel tool calls in one frame, malformed-argument tool calls (string `arguments`), in-band mid-stream error injection, and 503 bodies all shipped in v1.14.0. The genuine engine gap the audit surfaced: routes couldn't vary responses **per request**. All additive, no breaking changes. 960 pre-existing tests stay green, 17 new tests added.
|
|
6
|
+
|
|
7
|
+
### Speculative draft benchmark (`compareDraftPerformance`)
|
|
8
|
+
|
|
9
|
+
- **`compareDraftPerformance(client, options)`** — the deterministic A/B for "does `draft_num_predict` pay off for _this_ prompt": identical prompt on both sides, `temperature: 0` forced + a fixed `seed` (default 42), one warm-up generation per setting (pays the model load), `samples` measured runs per side (default 5) with the **median** compared — single samples mislead. Throughput flows through `formatTelemetry` (the official Usage-doc math; never hand-rolled nanosecond division).
|
|
10
|
+
- Result: `baselineTokensPerSec` / `speculativeTokensPerSec` (1dp), `speedupMultiplier` (2dp, computed from the two reported medians so the numbers are self-consistent), `effectiveGain` (`> gainThreshold`, default 1.05), plus full per-side `DraftRunStats` (every sample, medians).
|
|
11
|
+
- **Structural dependency** — accepts any `DraftBenchmarkClient` (`{ generate }` with the non-streaming signature); `OllamaClient` satisfies it without being named, and tests can pass a plain object. A `client.benchmark` namespace was deliberately not added, and the digest's `SpeculativeBenchmarkRunner` class was folded into the function — rationale in ADR 0030.
|
|
12
|
+
- Guarded: `RangeError` on invalid numeric options; non-positive/unusable samples are skipped, and a side with **no** valid samples throws `OllamaClientError` (`draft_benchmark_no_valid_samples`) instead of fabricating a zero/Infinity speedup; the abort signal is checked between runs and forwarded to every call.
|
|
13
|
+
- The no-draft-network reading is documented on the utility and in the README: a ≈1.0 speedup means the model has no draft network (and no embedded MTP tensors) — the server silently ignores the option.
|
|
14
|
+
|
|
15
|
+
### Request-aware mock routes (`responder`)
|
|
16
|
+
|
|
17
|
+
- `MockEndpointOptions` now accepts `responder(record) => MockRouteResponse | undefined` — per-request responses computed from the recorded method/path/body. Returning `undefined` falls back to the route's static fields, so fail-twice-then-succeed retry drills, body-dependent payloads (e.g. draft-setting-aware frames), and call-count scenarios are closure-local one-liners on the same engine — no subclassing, no one-shot global scenario queue. The digest's `ScenarioMockOllamaServer` sketch was declined for exactly that reason (it forks the request path ADR 0029 protects; see ADR 0030).
|
|
18
|
+
- The emission pipeline is shared: `chunkDelayMs`, `injectErrorFrame`, `dropConnectionMidStream`, and `rawBody` all keep their semantics inside responder routes; request recording and `onRequest` still fire.
|
|
19
|
+
- Type-level: the response fields moved to a new `MockRouteResponse` interface that `MockEndpointOptions` extends — pure refactor, existing routes unaffected. `MockRouteResponse` is exported from the `/testing` subpath.
|
|
20
|
+
- The SDK's own benchmark tests run daemonless through responder routes (draft-aware `/api/generate` frames) — the exact consumer CI pattern the digest's downstream stories describe, exercising the public engine rather than test-only fixtures.
|
|
21
|
+
|
|
22
|
+
## [1.14.0] — Speculative-decoding model assembly & the public testing subpath: `draftGguf`, `draft_num_predict` docs, `@nemesis-oss/ollama-sdk/testing`
|
|
23
|
+
|
|
24
|
+
The October 12 digest ("Speculative Decoding & Draft Model Creation") reviewed the official model-creation surfaces — `draft_files` + `draft_quantize` on `POST /api/create`, the `draft_num_predict` Modelfile parameter and runtime option — and asked for an exportable zero-network mock server. Audited against the codebase first (see [ADR 0029](./docs/adr/0029-public-testing-subpath-speculative-draft-conveniences.md)): the digest's headline typing claims were stale — `draft_files`/`draft_quantize` already shipped on both the generated `CreateRequest` and the hand-written `CreateRequestOptions` (official OpenAPI, 2026-10-07 pin), and `draft_num_predict` was already typed + documented on the runtime `ModelOptions`; the "DRAFT `<model>`" Modelfile instruction the digest describes is not in the current official docs (only the parameter is). The real gaps — a path-based draft upload convenience, wire tests, discoverable documentation, and the not-exported mock server — are closed here. All additive, no breaking changes. 932 pre-existing tests stay green, 28 new tests added.
|
|
25
|
+
|
|
26
|
+
### Speculative model assembly (`createModelFromGguf` → `draftGguf`)
|
|
27
|
+
|
|
28
|
+
- **`CreateModelFromGgufOptions.draftGguf`**: pass the draft network's GGUF path(s) alongside the primary shards — each is uploaded as a blob and assembled into `draft_files` under its basename, the same protocol as primary shards. Split draft GGUFs, explicit `draft_files` digest maps (merged; path uploads win on basename collision), and `draft_quantize` passthrough all work; no draft → no `draft_files` key on the wire at all.
|
|
29
|
+
- **Discoverable speculative-decoding docs**: `CreateRequestOptions.parameters` now documents `draft_num_predict` (separate draft models default to 4; `0` disables drafting; embedded MTP tensors require setting it explicitly — official Modelfile reference), and `draft_files`/`draft_quantize` carry the speculative-decoding context with the verification-semantics note that makes aggressive draft compression safe.
|
|
30
|
+
- Wire tests pin the whole contract: `createModel` round-trips `draft_files` + `draft_quantize` + `parameters.draft_num_predict`; `createModelFromGguf` uploads draft blobs separately (HEAD + POST per shard) and never leaks `draftGguf` or empty maps into the request body.
|
|
31
|
+
|
|
32
|
+
### Public testing subpath (`@nemesis-oss/ollama-sdk/testing`)
|
|
33
|
+
|
|
34
|
+
- The SDK's internal mock engine is **promoted, not forked** (ADR 0029, same discipline as ADRs 0027/0028): `test/mocks/ollama-mock-server.ts` is now a re-export shim, so the SDK's own suite exercises exactly the code consumers import.
|
|
35
|
+
- **`createMockOllamaServer(options?)`** — the recommended factory. Built-in **default routes** answer the official liveness probe (`HEAD /` → 200, `GET /` → the plain-text body "Ollama is running" byte-for-byte) and `GET /api/version` (configurable version, default `0.14.0`), so `client.ping()` works against a fresh mock with zero setup. `defaultRoutes: false` restores bare 404s; `register()` always wins over the defaults.
|
|
36
|
+
- **Request recording** — `server.requests` captures every request (method, path, parsed body, timestamp), including default-route hits and 404s; `clearRequests()` for `beforeEach` hooks. Wire-level assertions without per-route callbacks.
|
|
37
|
+
- **In-band mid-stream error injection** — `injectErrorFrame: { message, after? }` emits an `{"error": "..."}` NDJSON frame while HTTP stays 200, exactly how a real daemon reports GPU OOM/driver crashes mid-generation. The default injection point is `chunks.length - 1`: every content chunk, then the error frame, with the final `done` frame never emitted — an error frame after `done` would be unread (the stream has already completed cleanly). `after: 0` errors before any content; explicit larger values move the position. The SDK's stream readers raise `OllamaStreamError` with the partial content.
|
|
38
|
+
- **`rawBody`** route option for exact non-NDJSON bodies (the liveness body uses it — no trailing newline).
|
|
39
|
+
- **Chunk builders** producing correctly-shaped `/api/chat` frames: `mockChatChunk` (one frame, counter defaults on the done frame for telemetry paths), `mockTextChunks` (chunked text + done frame), and `mockToolCallChunks` — parallel tool calls in one frame, the documented native shape, followed by the done frame.
|
|
40
|
+
- **Packaging**: dedicated `./testing` entry in the exports map (paired `.d.ts`/`.d.cts`), a new tsup entry, and the consumer-type verification now compiles a real CJS (node16) **and** ESM (nodenext) consumer importing the subpath. Node.js-only by design (`node:http`) — never imported from the Edge-safe main entry, so `verify:edge-runtime` stays clean.
|
|
41
|
+
|
|
42
|
+
### Lab
|
|
43
|
+
|
|
44
|
+
- `lab/15-speculative-draft/measure-draft-speedup.ts`: live harness comparing `draft_num_predict` 0/4/8/16 — warm-up per setting, 5 samples each, median `tokensPerSecond` via `formatTelemetry`, and a speedup table against drafting-disabled. Makes "the server has no draft network, the option is being ignored" obvious at a glance.
|
|
45
|
+
|
|
3
46
|
## [1.13.0] — docs.ollama.com re-audit: parity verifier reads checkbox state, strict compat types, web-search agent tools
|
|
4
47
|
|
|
5
48
|
A full re-read of docs.ollama.com (API reference, capability guides, integrations, OpenAPI spec) against the SDK. The native API was already at parity except for two upstream changes; the real defect was in the compatibility-parity tooling, which had been reporting fields Ollama documents as unsupported as "supported".
|
package/README.md
CHANGED
|
@@ -33,7 +33,9 @@ Nexum’s model harness (2026-10-09) exercised this SDK via `gemma4:cloud` and `
|
|
|
33
33
|
- 🌉 **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).
|
|
34
34
|
- 💬 **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.
|
|
35
35
|
- 🛑 **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.
|
|
36
|
-
- 📦 **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}`).
|
|
36
|
+
- 📦 **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}`) — with **speculative-decoding assembly**: `draftGguf` uploads the draft network's shards into `draft_files`, `draft_quantize` compresses them independently, and `parameters.draft_num_predict` tunes the per-step draft length.
|
|
37
|
+
- 🏁 **Speculative Draft Benchmark (`compareDraftPerformance`)**: One call answers "does drafting pay off for _this_ prompt?" — identical prompt on both sides, deterministic sampling (`temperature: 0` + fixed seed), warm-up discard, several samples per side with **medians** compared, throughput via `formatTelemetry` — reported as baseline vs. speculative tok/s, `speedupMultiplier`, and `effectiveGain` past a configurable threshold. A ≈1.0 speedup is itself diagnostic: the model has no draft network, so the server silently ignores the option.
|
|
38
|
+
- 🧪 **In-Memory Mock Server (`@nemesis-oss/ollama-sdk/testing`)**: The SDK's own deterministic test double, promoted public — `createMockOllamaServer()` with scriptable NDJSON routes, built-in liveness/version defaults (so `ping()` works out of the box), request recording for wire-level assertions, **request-aware routes** (`responder` — per-request responses for fail-twice-then-succeed retry drills and body-dependent payloads), mid-stream **in-band error injection** (`{"error": "..."}` frames at HTTP 200, exactly how a real daemon reports GPU OOM), connection drops, and correctly-shaped chunk builders for streaming text and **parallel tool calls** — zero network, zero daemon, CI-deterministic.
|
|
37
39
|
- 🧵 **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.
|
|
38
40
|
- 🧹 **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.
|
|
39
41
|
- 🧠 **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.
|
|
@@ -497,6 +499,76 @@ await client.models.createModelFromGguf('my-model', [
|
|
|
497
499
|
]);
|
|
498
500
|
```
|
|
499
501
|
|
|
502
|
+
### Speculative Decoding (Draft Models)
|
|
503
|
+
|
|
504
|
+
Ollama packages speculative decoding as a small **draft network** whose token
|
|
505
|
+
guesses are batch-verified against the primary weights in a single forward
|
|
506
|
+
pass — the draft runs ahead, the primary model confirms. On the wire this is
|
|
507
|
+
`draft_files` (draft GGUF digests) + `draft_quantize` on `/api/create`, the
|
|
508
|
+
`draft_num_predict` Modelfile parameter, and the `draft_num_predict` runtime
|
|
509
|
+
option at generation time. The SDK covers the full flow:
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
// 1. Assemble: primary + draft shards, draft weights quantized independently
|
|
513
|
+
// (draft tokens are verified, never returned — aggressive compression is
|
|
514
|
+
// usually safe). Separate draft models default draft_num_predict to 4;
|
|
515
|
+
// 0 disables drafting; models with embedded MTP tensors require setting
|
|
516
|
+
// it explicitly.
|
|
517
|
+
await client.models.createModelFromGguf('my-model', '/models/model.gguf', {
|
|
518
|
+
draftGguf: '/models/draft.gguf',
|
|
519
|
+
draftQuantize: 'q8_0',
|
|
520
|
+
parameters: { draft_num_predict: 8 },
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// 2. Generate with drafting (also available ad hoc via options at runtime)
|
|
524
|
+
const response = await client.chat({
|
|
525
|
+
model: 'my-model',
|
|
526
|
+
messages: [{ role: 'user', content: 'Describe a lighthouse in a storm.' }],
|
|
527
|
+
options: { draft_num_predict: 8 },
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// 3. Measure the speedup with the standardized telemetry
|
|
531
|
+
const t = formatTelemetry(response);
|
|
532
|
+
console.log(`${t.tokensPerSecond} tok/s (load ${t.modelLoadMs}ms)`);
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Already have the draft blobs uploaded? Pass the digest map directly —
|
|
536
|
+
`createModel({ draft_files: { 'draft.gguf': digest } })` — or merge both
|
|
537
|
+
(path uploads win on basename collision).
|
|
538
|
+
|
|
539
|
+
**Does drafting actually pay off?** Upstream emits no draft-token
|
|
540
|
+
acceptance counter — speculative efficiency is only observable as effective
|
|
541
|
+
token throughput, and the signal is noisy: rejected draft predictions _add_
|
|
542
|
+
compute (high-temperature, creative prompts) while accepted drafts slash
|
|
543
|
+
`eval_duration` on predictable workloads (structured code, JSON extraction).
|
|
544
|
+
`compareDraftPerformance()` runs the deterministic A/B for a specific
|
|
545
|
+
prompt — identical prompt both sides, `temperature: 0` + a fixed seed, one
|
|
546
|
+
warm-up generation per setting (pays the model load), several samples per
|
|
547
|
+
side with the **median** compared, throughput derived by `formatTelemetry`:
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
import { OllamaClient, compareDraftPerformance } from '@nemesis-oss/ollama-sdk';
|
|
551
|
+
|
|
552
|
+
const comparison = await compareDraftPerformance(new OllamaClient(), {
|
|
553
|
+
model: 'my-model',
|
|
554
|
+
prompt: 'Extract the sender, subject, and date from this email as JSON: ...',
|
|
555
|
+
draftNumPredict: 4, // default; the baseline side always runs with 0
|
|
556
|
+
samples: 5, // median per side — single runs mislead
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
console.log(
|
|
560
|
+
`${comparison.baselineTokensPerSec} → ${comparison.speculativeTokensPerSec} tok/s ` +
|
|
561
|
+
`(${comparison.speedupMultiplier}x, gain: ${comparison.effectiveGain})`,
|
|
562
|
+
);
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
`effectiveGain` flips to `true` when the reported multiplier exceeds
|
|
566
|
+
`gainThreshold` (default `1.05` — five percent, past run-to-run noise). A
|
|
567
|
+
speedup of ≈1.0 is itself the diagnosis: the model has no draft network
|
|
568
|
+
(and no embedded MTP tensors), so the server silently ignores
|
|
569
|
+
`draft_num_predict` — the same reading `lab/15-speculative-draft/measure-draft-speedup.ts`
|
|
570
|
+
makes obvious across 0/4/8/16.
|
|
571
|
+
|
|
500
572
|
### Token Log Probabilities (`logprobs`)
|
|
501
573
|
|
|
502
574
|
Set `logprobs: true` (optionally with `top_logprobs`) on `chat()`/`generate()` to get per-token log probabilities back — useful for confidence scoring, speculative decoding, or agent routing decisions.
|
|
@@ -955,9 +1027,19 @@ rejects `x-api-key`-only auth; the SDK always sends the bearer header plus
|
|
|
955
1027
|
`anthropic-version: 2023-06-01`):
|
|
956
1028
|
|
|
957
1029
|
```typescript
|
|
958
|
-
const cloud = new OllamaClient({
|
|
959
|
-
|
|
960
|
-
|
|
1030
|
+
const cloud = new OllamaClient({
|
|
1031
|
+
baseUrl: 'https://ollama.com',
|
|
1032
|
+
apiKey: process.env.OLLAMA_API_KEY,
|
|
1033
|
+
});
|
|
1034
|
+
await cloud.openai.chatCompletions({
|
|
1035
|
+
model: 'gemma4:31b',
|
|
1036
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
1037
|
+
});
|
|
1038
|
+
await cloud.anthropic.messages({
|
|
1039
|
+
model: 'gemma4:31b',
|
|
1040
|
+
max_tokens: 1024,
|
|
1041
|
+
messages: [{ role: 'user', content: 'Hi' }],
|
|
1042
|
+
});
|
|
961
1043
|
```
|
|
962
1044
|
|
|
963
1045
|
The cloud API does not support stateful Responses, built-in web search through
|
|
@@ -1454,6 +1536,90 @@ windows: [...] })` directly with your own `windowMs`/`maxTokens`/`maxRequests` p
|
|
|
1454
1536
|
|
|
1455
1537
|
---
|
|
1456
1538
|
|
|
1539
|
+
### Testing with the In-Memory Mock Server (`/testing`)
|
|
1540
|
+
|
|
1541
|
+
The SDK ships its own deterministic test double as a dedicated subpath —
|
|
1542
|
+
`@nemesis-oss/ollama-sdk/testing` — the same engine the SDK's own test suite
|
|
1543
|
+
runs against (ADR 0029: promote, don't fork). No daemon, no network, no GPU:
|
|
1544
|
+
scriptable NDJSON routes on a real `node:http` server, built-in defaults for
|
|
1545
|
+
the official liveness probe and `/api/version`, wire-level request recording,
|
|
1546
|
+
and failure injection that covers what static cassettes can't.
|
|
1547
|
+
|
|
1548
|
+
```typescript
|
|
1549
|
+
import {
|
|
1550
|
+
createMockOllamaServer,
|
|
1551
|
+
mockTextChunks,
|
|
1552
|
+
mockToolCallChunks,
|
|
1553
|
+
} from '@nemesis-oss/ollama-sdk/testing';
|
|
1554
|
+
import { OllamaClient } from '@nemesis-oss/ollama-sdk';
|
|
1555
|
+
|
|
1556
|
+
const server = createMockOllamaServer(); // ephemeral port, defaults on
|
|
1557
|
+
await server.start();
|
|
1558
|
+
const client = new OllamaClient({ baseUrl: server.baseUrl });
|
|
1559
|
+
|
|
1560
|
+
// Defaults answer out of the box: HEAD / (ping) and GET /api/version.
|
|
1561
|
+
// Register everything else — register() always wins over the defaults.
|
|
1562
|
+
server.register('/api/chat', {
|
|
1563
|
+
chunks: mockToolCallChunks([
|
|
1564
|
+
// Parallel tool calls: one frame, complete calls — the native shape.
|
|
1565
|
+
{ name: 'get_weather', arguments: { city: 'Tokyo' } },
|
|
1566
|
+
{ name: 'get_time', arguments: { tz: 'Asia/Tokyo' } },
|
|
1567
|
+
]),
|
|
1568
|
+
chunkDelayMs: 10, // inter-chunk backpressure testing
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// Mid-stream failure, GPU-OOM style: HTTP stays 200, the done frame is
|
|
1572
|
+
// replaced by an {"error": "..."} frame — the SDK raises OllamaStreamError.
|
|
1573
|
+
server.register('/api/generate', {
|
|
1574
|
+
chunks: mockTextChunks('partial answer', { chunkSize: 8 }),
|
|
1575
|
+
injectErrorFrame: { message: 'CUDA out of memory' }, // after: N to move it
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
// ... run your code against the client ...
|
|
1579
|
+
|
|
1580
|
+
// Wire-level assertions: every request was recorded.
|
|
1581
|
+
expect(server.requests[0]?.body).toMatchObject({ model: 'llama3.2' });
|
|
1582
|
+
server.clearRequests(); // and server.clear() between tests
|
|
1583
|
+
|
|
1584
|
+
await server.stop(); // idempotent — safe in afterEach
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
**Request-aware routes** — `responder` computes the response _per request_
|
|
1588
|
+
from the recorded method/path/body, covering the scenario drills a static
|
|
1589
|
+
route can't: fail-twice-then-succeed retries, body-dependent payloads,
|
|
1590
|
+
call-count behavior:
|
|
1591
|
+
|
|
1592
|
+
```typescript
|
|
1593
|
+
// Fail twice, then serve — retry/backoff drills:
|
|
1594
|
+
let hits = 0;
|
|
1595
|
+
server.register('/api/chat', {
|
|
1596
|
+
chunks: mockTextChunks('recovered'),
|
|
1597
|
+
responder: () =>
|
|
1598
|
+
hits++ < 2 ? { status: 503, chunks: ['{"error":"server queue is full"}'] } : undefined,
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// Body-dependent — answer differently by what the client sent:
|
|
1602
|
+
server.register('/api/generate', {
|
|
1603
|
+
responder: (record) => ({
|
|
1604
|
+
chunks: [frameFor(readDraftSetting(record.body))],
|
|
1605
|
+
}),
|
|
1606
|
+
});
|
|
1607
|
+
```
|
|
1608
|
+
|
|
1609
|
+
Returning `undefined` falls back to the route's static fields; a returned
|
|
1610
|
+
response feeds the full emission pipeline, so `chunkDelayMs`,
|
|
1611
|
+
`injectErrorFrame`, `dropConnectionMidStream`, and `rawBody` all keep their
|
|
1612
|
+
semantics inside responder routes.
|
|
1613
|
+
|
|
1614
|
+
Per-route controls: `status` (503/502/429 bodies), `chunkDelayMs`,
|
|
1615
|
+
`dropConnectionMidStream` (connection reset mid-response), `onRequest`
|
|
1616
|
+
(per-route body capture), and `injectErrorFrame.after` (error position).
|
|
1617
|
+
Chunk builders: `mockChatChunk` (one frame with counter defaults),
|
|
1618
|
+
`mockTextChunks` (chunked text + done frame), `mockToolCallChunks` (parallel
|
|
1619
|
+
tool calls + done frame — malformed-argument cases via string
|
|
1620
|
+
`arguments`). Node.js only — the subpath builds on `node:http`
|
|
1621
|
+
and is never imported from the Edge-safe main entry.
|
|
1622
|
+
|
|
1457
1623
|
### Edge Runtime Compatibility
|
|
1458
1624
|
|
|
1459
1625
|
The core client (`OllamaClient`, `Agent`, `ToolRegistry`, and everything exported from
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, af as SseEvent, y as OllamaClientError } from './runtime-
|
|
1
|
+
import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, af as SseEvent, y as OllamaClientError } from './runtime-BIjr5ME4.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, af as SseEvent, y as OllamaClientError } from './runtime-
|
|
1
|
+
import { O as OllamaRuntime, H as HttpClient, A as AbortableAsyncIterable, af as SseEvent, y as OllamaClientError } from './runtime-C4RRHqOs.cjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
|
package/dist/generated-api.d.cts
CHANGED
|
@@ -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-
|
|
2
|
-
export { N as NativeApi } from './anthropic-
|
|
3
|
-
import { O as OllamaRuntime } from './runtime-
|
|
4
|
-
export { a as OperationDefinition } from './runtime-
|
|
5
|
-
import './types-
|
|
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-UeaVjLeP.cjs';
|
|
2
|
+
export { N as NativeApi } from './anthropic-UeaVjLeP.cjs';
|
|
3
|
+
import { O as OllamaRuntime } from './runtime-C4RRHqOs.cjs';
|
|
4
|
+
export { a as OperationDefinition } from './runtime-C4RRHqOs.cjs';
|
|
5
|
+
import './types-DbPnfVh-.cjs';
|
|
6
6
|
import 'zod';
|
|
7
7
|
|
|
8
8
|
/**
|
package/dist/generated-api.d.ts
CHANGED
|
@@ -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-
|
|
2
|
-
export { N as NativeApi } from './anthropic-
|
|
3
|
-
import { O as OllamaRuntime } from './runtime-
|
|
4
|
-
export { a as OperationDefinition } from './runtime-
|
|
5
|
-
import './types-
|
|
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-DrDUfbfQ.js';
|
|
2
|
+
export { N as NativeApi } from './anthropic-DrDUfbfQ.js';
|
|
3
|
+
import { O as OllamaRuntime } from './runtime-BIjr5ME4.js';
|
|
4
|
+
export { a as OperationDefinition } from './runtime-BIjr5ME4.js';
|
|
5
|
+
import './types-DbPnfVh-.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-
|
|
1
|
+
export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-C4RRHqOs.cjs';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import './types-
|
|
3
|
+
import './types-DbPnfVh-.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-
|
|
1
|
+
export { I as InvokeRequest, O as OllamaRuntime, b as OllamaRuntimeOptions, a as OperationDefinition, S as SchemaRef } from './runtime-BIjr5ME4.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import './types-
|
|
3
|
+
import './types-DbPnfVh-.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* AUTO-GENERATED by `npm run contract:generate`. DO NOT EDIT.
|
package/dist/index.cjs
CHANGED
|
@@ -2817,12 +2817,29 @@ var ModelsClient = class {
|
|
|
2817
2817
|
* await client.models.createModelFromGguf('my-model', '/path/to/model.gguf', {
|
|
2818
2818
|
* template: '{{ .Prompt }}',
|
|
2819
2819
|
* });
|
|
2820
|
+
*
|
|
2821
|
+
* // Speculative decoding: primary + draft shards + tuning:
|
|
2822
|
+
* await client.models.createModelFromGguf('my-model', '/path/to/model.gguf', {
|
|
2823
|
+
* draftGguf: '/path/to/draft.gguf',
|
|
2824
|
+
* draftQuantize: 'q8_0',
|
|
2825
|
+
* parameters: { draft_num_predict: 8 },
|
|
2826
|
+
* });
|
|
2820
2827
|
* ```
|
|
2821
2828
|
*
|
|
2829
|
+
* Speculative-decoding models assemble the same way: pass the draft
|
|
2830
|
+
* network's GGUF(s) as `draftGguf` (uploaded into `draft_files`), optionally
|
|
2831
|
+
* set `draft_quantize` to compress the draft weights independently of the
|
|
2832
|
+
* primary model, and set `parameters.draft_num_predict` to tune how many
|
|
2833
|
+
* tokens the draft network predicts per step (Ollama defaults separate
|
|
2834
|
+
* draft models to 4; `0` disables drafting; models with embedded MTP
|
|
2835
|
+
* tensors require setting it explicitly).
|
|
2836
|
+
*
|
|
2822
2837
|
* @param model Name for the new model (e.g. `'my-model:latest'`).
|
|
2823
2838
|
* @param gguf Path(s) to GGUF file(s) on the local filesystem.
|
|
2824
2839
|
* @param opts Everything else `/api/create` accepts (`template`, `system`,
|
|
2825
|
-
* `parameters`, `license`, `quantize`, …) except `files
|
|
2840
|
+
* `parameters`, `license`, `quantize`, `draftGguf`, …) except `files`,
|
|
2841
|
+
* `draft_files` (use `draftGguf` for path-based drafts; an explicit digest
|
|
2842
|
+
* map is still accepted and merged), `from`, and `stream`.
|
|
2826
2843
|
*/
|
|
2827
2844
|
async createModelFromGguf(model, gguf, opts) {
|
|
2828
2845
|
const paths = typeof gguf === "string" ? [gguf] : gguf;
|
|
@@ -2836,14 +2853,29 @@ var ModelsClient = class {
|
|
|
2836
2853
|
const uploaded = await this.createBlobFromFile(path, { signal: opts?.signal });
|
|
2837
2854
|
files[uploaded.fileName] = uploaded.digest;
|
|
2838
2855
|
}
|
|
2856
|
+
const draftOpt = opts?.draftGguf;
|
|
2857
|
+
const draftPaths = typeof draftOpt === "string" ? [draftOpt] : draftOpt ?? [];
|
|
2839
2858
|
const {
|
|
2840
2859
|
model: _model,
|
|
2841
2860
|
files: _files,
|
|
2842
2861
|
from: _from,
|
|
2843
2862
|
stream: _stream,
|
|
2863
|
+
draftGguf: _draftGguf,
|
|
2864
|
+
draft_files: _draftFiles,
|
|
2844
2865
|
...rest
|
|
2845
2866
|
} = opts ?? {};
|
|
2846
|
-
|
|
2867
|
+
const draftFiles = { ..._draftFiles ?? {} };
|
|
2868
|
+
for (const path of draftPaths) {
|
|
2869
|
+
const uploaded = await this.createBlobFromFile(path, { signal: opts?.signal });
|
|
2870
|
+
draftFiles[uploaded.fileName] = uploaded.digest;
|
|
2871
|
+
}
|
|
2872
|
+
return this.create({
|
|
2873
|
+
...rest,
|
|
2874
|
+
model,
|
|
2875
|
+
files,
|
|
2876
|
+
...Object.keys(draftFiles).length > 0 ? { draft_files: draftFiles } : {},
|
|
2877
|
+
stream: false
|
|
2878
|
+
});
|
|
2847
2879
|
}
|
|
2848
2880
|
};
|
|
2849
2881
|
|
|
@@ -7718,6 +7750,111 @@ var Agent = class {
|
|
|
7718
7750
|
}
|
|
7719
7751
|
};
|
|
7720
7752
|
|
|
7753
|
+
// src/draft-benchmark.ts
|
|
7754
|
+
var DEFAULT_DRAFT_TOKENS = 4;
|
|
7755
|
+
var DEFAULT_SAMPLES = 5;
|
|
7756
|
+
var DEFAULT_NUM_PREDICT = 256;
|
|
7757
|
+
var DEFAULT_SEED = 42;
|
|
7758
|
+
var DEFAULT_GAIN_THRESHOLD = 1.05;
|
|
7759
|
+
function median(values) {
|
|
7760
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
7761
|
+
const mid = Math.floor(sorted.length / 2);
|
|
7762
|
+
return sorted.length % 2 === 1 ? sorted[mid] ?? 0 : ((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2;
|
|
7763
|
+
}
|
|
7764
|
+
function roundTo(value, decimals) {
|
|
7765
|
+
const factor = 10 ** decimals;
|
|
7766
|
+
return Math.round(value * factor) / factor;
|
|
7767
|
+
}
|
|
7768
|
+
function requirePositiveInteger(name, value) {
|
|
7769
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
7770
|
+
throw new RangeError(
|
|
7771
|
+
`compareDraftPerformance: ${name} must be an integer >= 1 (got ${value}).`
|
|
7772
|
+
);
|
|
7773
|
+
}
|
|
7774
|
+
}
|
|
7775
|
+
async function runSide(client, options, draftNumPredict, samples, numPredict, seed) {
|
|
7776
|
+
const samplingOptions = {
|
|
7777
|
+
num_predict: numPredict,
|
|
7778
|
+
temperature: 0,
|
|
7779
|
+
seed,
|
|
7780
|
+
draft_num_predict: draftNumPredict
|
|
7781
|
+
};
|
|
7782
|
+
if (options.warmup ?? true) {
|
|
7783
|
+
await client.generate({
|
|
7784
|
+
model: options.model,
|
|
7785
|
+
prompt: options.prompt,
|
|
7786
|
+
options: { ...samplingOptions, num_predict: 1 },
|
|
7787
|
+
stream: false,
|
|
7788
|
+
signal: options.signal
|
|
7789
|
+
});
|
|
7790
|
+
}
|
|
7791
|
+
const tokensPerSecond = [];
|
|
7792
|
+
const generationMs = [];
|
|
7793
|
+
for (let i = 0; i < samples; i++) {
|
|
7794
|
+
options.signal?.throwIfAborted();
|
|
7795
|
+
const response = await client.generate({
|
|
7796
|
+
model: options.model,
|
|
7797
|
+
prompt: options.prompt,
|
|
7798
|
+
options: samplingOptions,
|
|
7799
|
+
stream: false,
|
|
7800
|
+
signal: options.signal
|
|
7801
|
+
});
|
|
7802
|
+
const telemetry = formatTelemetry(response);
|
|
7803
|
+
if (Number.isFinite(telemetry.tokensPerSecond) && telemetry.tokensPerSecond > 0) {
|
|
7804
|
+
tokensPerSecond.push(telemetry.tokensPerSecond);
|
|
7805
|
+
generationMs.push(telemetry.generationMs);
|
|
7806
|
+
}
|
|
7807
|
+
}
|
|
7808
|
+
if (tokensPerSecond.length === 0) {
|
|
7809
|
+
throw new OllamaClientError(
|
|
7810
|
+
`compareDraftPerformance: every draft_num_predict=${draftNumPredict} sample returned a non-positive tokens/second. The model name is likely wrong, or the server returned empty generations.`,
|
|
7811
|
+
{ code: "draft_benchmark_no_valid_samples" }
|
|
7812
|
+
);
|
|
7813
|
+
}
|
|
7814
|
+
return {
|
|
7815
|
+
draftNumPredict,
|
|
7816
|
+
medianTokensPerSecond: median(tokensPerSecond),
|
|
7817
|
+
sampleTokensPerSecond: tokensPerSecond,
|
|
7818
|
+
medianEvalDurationMs: median(generationMs)
|
|
7819
|
+
};
|
|
7820
|
+
}
|
|
7821
|
+
async function compareDraftPerformance(client, options) {
|
|
7822
|
+
const draftNumPredict = options.draftNumPredict ?? DEFAULT_DRAFT_TOKENS;
|
|
7823
|
+
requirePositiveInteger("draftNumPredict", draftNumPredict);
|
|
7824
|
+
const samples = options.samples ?? DEFAULT_SAMPLES;
|
|
7825
|
+
requirePositiveInteger("samples", samples);
|
|
7826
|
+
const numPredict = options.numPredict ?? DEFAULT_NUM_PREDICT;
|
|
7827
|
+
requirePositiveInteger("numPredict", numPredict);
|
|
7828
|
+
const seed = options.seed ?? DEFAULT_SEED;
|
|
7829
|
+
if (!Number.isInteger(seed)) {
|
|
7830
|
+
throw new RangeError(`compareDraftPerformance: seed must be an integer (got ${seed}).`);
|
|
7831
|
+
}
|
|
7832
|
+
const gainThreshold = options.gainThreshold ?? DEFAULT_GAIN_THRESHOLD;
|
|
7833
|
+
if (!Number.isFinite(gainThreshold) || gainThreshold <= 0) {
|
|
7834
|
+
throw new RangeError(
|
|
7835
|
+
`compareDraftPerformance: gainThreshold must be a positive finite number (got ${gainThreshold}).`
|
|
7836
|
+
);
|
|
7837
|
+
}
|
|
7838
|
+
options.signal?.throwIfAborted();
|
|
7839
|
+
const baseline = await runSide(client, options, 0, samples, numPredict, seed);
|
|
7840
|
+
const speculative = await runSide(client, options, draftNumPredict, samples, numPredict, seed);
|
|
7841
|
+
const baselineTokensPerSec = roundTo(baseline.medianTokensPerSecond, 1);
|
|
7842
|
+
const speculativeTokensPerSec = roundTo(speculative.medianTokensPerSecond, 1);
|
|
7843
|
+
const speedupMultiplier = baselineTokensPerSec > 0 ? roundTo(speculativeTokensPerSec / baselineTokensPerSec, 2) : 0;
|
|
7844
|
+
return {
|
|
7845
|
+
model: options.model,
|
|
7846
|
+
prompt: options.prompt,
|
|
7847
|
+
draftNumPredict,
|
|
7848
|
+
baselineTokensPerSec,
|
|
7849
|
+
speculativeTokensPerSec,
|
|
7850
|
+
speedupMultiplier,
|
|
7851
|
+
effectiveGain: speedupMultiplier > gainThreshold,
|
|
7852
|
+
gainThreshold,
|
|
7853
|
+
baseline,
|
|
7854
|
+
speculative
|
|
7855
|
+
};
|
|
7856
|
+
}
|
|
7857
|
+
|
|
7721
7858
|
// src/affinity.ts
|
|
7722
7859
|
var DEFAULT_CONCURRENT_MODELS = 1;
|
|
7723
7860
|
var DEFAULT_PER_MODEL_CONCURRENCY = 1;
|
|
@@ -7979,6 +8116,7 @@ exports.checkChatContext = checkChatContext;
|
|
|
7979
8116
|
exports.checkEndpointHealth = checkEndpointHealth;
|
|
7980
8117
|
exports.checkGenerateContext = checkGenerateContext;
|
|
7981
8118
|
exports.compactConversationHistory = compactConversationHistory;
|
|
8119
|
+
exports.compareDraftPerformance = compareDraftPerformance;
|
|
7982
8120
|
exports.composeMiddleware = composeMiddleware;
|
|
7983
8121
|
exports.contextWarningMessage = contextWarningMessage;
|
|
7984
8122
|
exports.createConsoleLogger = createConsoleLogger;
|