@nemesis-oss/ollama-sdk 1.14.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 +19 -0
- package/README.md +65 -5
- package/dist/index.cjs +106 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +146 -1
- package/dist/index.d.ts +146 -1
- package/dist/index.js +106 -1
- package/dist/index.js.map +1 -1
- package/dist/testing.cjs +13 -11
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +48 -8
- package/dist/testing.d.ts +48 -8
- package/dist/testing.js +13 -11
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
3
22
|
## [1.14.0] — Speculative-decoding model assembly & the public testing subpath: `draftGguf`, `draft_num_predict` docs, `@nemesis-oss/ollama-sdk/testing`
|
|
4
23
|
|
|
5
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.
|
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ Nexum’s model harness (2026-10-09) exercised this SDK via `gemma4:cloud` and `
|
|
|
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
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
|
-
-
|
|
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.
|
|
38
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.
|
|
39
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.
|
|
40
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.
|
|
@@ -533,9 +534,40 @@ console.log(`${t.tokensPerSecond} tok/s (load ${t.modelLoadMs}ms)`);
|
|
|
533
534
|
|
|
534
535
|
Already have the draft blobs uploaded? Pass the digest map directly —
|
|
535
536
|
`createModel({ draft_files: { 'draft.gguf': digest } })` — or merge both
|
|
536
|
-
(path uploads win on basename collision).
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
539
571
|
|
|
540
572
|
### Token Log Probabilities (`logprobs`)
|
|
541
573
|
|
|
@@ -1552,12 +1584,40 @@ server.clearRequests(); // and server.clear() between tests
|
|
|
1552
1584
|
await server.stop(); // idempotent — safe in afterEach
|
|
1553
1585
|
```
|
|
1554
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
|
+
|
|
1555
1614
|
Per-route controls: `status` (503/502/429 bodies), `chunkDelayMs`,
|
|
1556
1615
|
`dropConnectionMidStream` (connection reset mid-response), `onRequest`
|
|
1557
1616
|
(per-route body capture), and `injectErrorFrame.after` (error position).
|
|
1558
1617
|
Chunk builders: `mockChatChunk` (one frame with counter defaults),
|
|
1559
1618
|
`mockTextChunks` (chunked text + done frame), `mockToolCallChunks` (parallel
|
|
1560
|
-
tool calls + done frame
|
|
1619
|
+
tool calls + done frame — malformed-argument cases via string
|
|
1620
|
+
`arguments`). Node.js only — the subpath builds on `node:http`
|
|
1561
1621
|
and is never imported from the Edge-safe main entry.
|
|
1562
1622
|
|
|
1563
1623
|
### Edge Runtime Compatibility
|
package/dist/index.cjs
CHANGED
|
@@ -7750,6 +7750,111 @@ var Agent = class {
|
|
|
7750
7750
|
}
|
|
7751
7751
|
};
|
|
7752
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
|
+
|
|
7753
7858
|
// src/affinity.ts
|
|
7754
7859
|
var DEFAULT_CONCURRENT_MODELS = 1;
|
|
7755
7860
|
var DEFAULT_PER_MODEL_CONCURRENCY = 1;
|
|
@@ -8011,6 +8116,7 @@ exports.checkChatContext = checkChatContext;
|
|
|
8011
8116
|
exports.checkEndpointHealth = checkEndpointHealth;
|
|
8012
8117
|
exports.checkGenerateContext = checkGenerateContext;
|
|
8013
8118
|
exports.compactConversationHistory = compactConversationHistory;
|
|
8119
|
+
exports.compareDraftPerformance = compareDraftPerformance;
|
|
8014
8120
|
exports.composeMiddleware = composeMiddleware;
|
|
8015
8121
|
exports.contextWarningMessage = contextWarningMessage;
|
|
8016
8122
|
exports.createConsoleLogger = createConsoleLogger;
|