@nemesis-oss/ollama-sdk 1.11.0 → 1.12.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/dist/index.cjs CHANGED
@@ -1037,6 +1037,27 @@ var ATTR_OLLAMA_AGENT_MAX_ITERATIONS = "ollama.agent.max_iterations";
1037
1037
  var ATTR_OLLAMA_AGENT_ITERATION = "ollama.agent.iteration";
1038
1038
  var GEN_AI_SYSTEM_OLLAMA = "ollama";
1039
1039
 
1040
+ // src/telemetry/metrics.ts
1041
+ function nsToMs(ns) {
1042
+ return ns ? Math.round(ns / 1e6 * 100) / 100 : 0;
1043
+ }
1044
+ function formatTelemetry(raw) {
1045
+ const evalDurationSec = raw.eval_duration ? raw.eval_duration / 1e9 : 0;
1046
+ const promptDurationSec = raw.prompt_eval_duration ? raw.prompt_eval_duration / 1e9 : 0;
1047
+ const promptEvaluated = raw.prompt_eval_count ?? 0;
1048
+ const promptCached = raw.prompt_eval_cached_count ?? 0;
1049
+ const promptTotal = promptCached + promptEvaluated;
1050
+ return {
1051
+ totalLatencyMs: nsToMs(raw.total_duration),
1052
+ modelLoadMs: nsToMs(raw.load_duration),
1053
+ promptEvalMs: nsToMs(raw.prompt_eval_duration),
1054
+ generationMs: nsToMs(raw.eval_duration),
1055
+ tokensPerSecond: evalDurationSec > 0 && raw.eval_count ? Math.round(raw.eval_count / evalDurationSec * 10) / 10 : 0,
1056
+ promptTokensPerSecond: promptDurationSec > 0 && promptEvaluated ? Math.round(promptEvaluated / promptDurationSec * 10) / 10 : 0,
1057
+ cacheHitRatio: promptTotal > 0 ? Math.round(promptCached / promptTotal * 1e3) / 1e3 : 0
1058
+ };
1059
+ }
1060
+
1040
1061
  // src/transport/http.ts
1041
1062
  function routeTemplate(path) {
1042
1063
  return path.replace(/^\/api\/blobs\/.+$/, "/api/blobs/{digest}");
@@ -1574,6 +1595,256 @@ async function listAvailableModels(http) {
1574
1595
  });
1575
1596
  return [...res.models];
1576
1597
  }
1598
+
1599
+ // src/context-safety.ts
1600
+ var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
1601
+ var IMAGE_TOKEN_ESTIMATE = 300;
1602
+ var CHARS_PER_TOKEN = 4;
1603
+ function isCjkCodePoint(code) {
1604
+ return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
1605
+ code >= 13312 && code <= 19903 || // CJK Extension A
1606
+ code >= 12352 && code <= 12543 || // Hiragana + Katakana
1607
+ code >= 44032 && code <= 55215 || // Hangul syllables
1608
+ code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
1609
+ code >= 65280 && code <= 65519;
1610
+ }
1611
+ function estimateTokens(text) {
1612
+ if (text.length === 0) return 0;
1613
+ let cjk = 0;
1614
+ let other = 0;
1615
+ for (const char of text) {
1616
+ if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
1617
+ else other += 1;
1618
+ }
1619
+ return Math.ceil(cjk + other / CHARS_PER_TOKEN);
1620
+ }
1621
+ var MESSAGE_OVERHEAD_TOKENS = 4;
1622
+ function estimateMessageTokens(message) {
1623
+ let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
1624
+ if (message.thinking !== void 0) total += estimateTokens(message.thinking);
1625
+ if (message.tool_calls !== void 0) {
1626
+ for (const call of message.tool_calls) {
1627
+ total += estimateTokens(call.function.name);
1628
+ total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
1629
+ }
1630
+ }
1631
+ if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
1632
+ return total;
1633
+ }
1634
+ function estimateChatRequestTokens(req) {
1635
+ let total = 0;
1636
+ for (const message of req.messages) total += estimateMessageTokens(message);
1637
+ if (req.tools !== void 0) {
1638
+ for (const tool of req.tools) {
1639
+ total += estimateTokens(tool.function.description ?? "");
1640
+ total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
1641
+ }
1642
+ }
1643
+ return total;
1644
+ }
1645
+ function estimateGenerateRequestTokens(req) {
1646
+ let total = estimateTokens(req.prompt ?? "");
1647
+ if (req.system !== void 0) total += estimateTokens(req.system);
1648
+ if (req.template !== void 0) total += estimateTokens(req.template);
1649
+ if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
1650
+ return total;
1651
+ }
1652
+ function checkChatContext(req, options = {}) {
1653
+ return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
1654
+ }
1655
+ function checkGenerateContext(req, options = {}) {
1656
+ return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
1657
+ }
1658
+ function check(estimatedPromptTokens, requestNumCtx, options) {
1659
+ const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
1660
+ if (requestNumCtx !== void 0) {
1661
+ return {
1662
+ estimatedPromptTokens,
1663
+ effectiveContextLength: requestNumCtx,
1664
+ windowSource: "request",
1665
+ exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
1666
+ };
1667
+ }
1668
+ if (options.defaultContextLength !== void 0) {
1669
+ return {
1670
+ estimatedPromptTokens,
1671
+ effectiveContextLength: options.defaultContextLength,
1672
+ windowSource: "client-default",
1673
+ exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
1674
+ };
1675
+ }
1676
+ return {
1677
+ estimatedPromptTokens,
1678
+ effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
1679
+ windowSource: "server-fallback",
1680
+ exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
1681
+ };
1682
+ }
1683
+ var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
1684
+ function contextWarningMessage(check2, kind) {
1685
+ const where = `OllamaClient.${kind}()`;
1686
+ const estimate = `~${check2.estimatedPromptTokens} tokens`;
1687
+ if (check2.windowSource === "request") {
1688
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the request's context window (num_ctx=${check2.effectiveContextLength}). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`options.num_ctx\` or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
1689
+ }
1690
+ if (check2.windowSource === "client-default") {
1691
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the client's \`defaultContextLength\` (${check2.effectiveContextLength}, injected as \`num_ctx\`). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`defaultContextLength\`, set \`options.num_ctx\` per request, or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
1692
+ }
1693
+ return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
1694
+ }
1695
+
1696
+ // src/context-discovery.ts
1697
+ function extractParameterNumCtx(parameters) {
1698
+ if (!parameters) return void 0;
1699
+ const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
1700
+ if (match === null) return void 0;
1701
+ const value = Number.parseInt(match[1], 10);
1702
+ return Number.isFinite(value) && value > 0 ? value : void 0;
1703
+ }
1704
+ function matchesModelReference(entry, model) {
1705
+ const exact = entry.name === model || entry.model === model;
1706
+ const taglessPrefix = !model.includes(":") && (entry.name !== void 0 && entry.name.startsWith(`${model}:`) || entry.model !== void 0 && entry.model.startsWith(`${model}:`));
1707
+ return exact || taglessPrefix;
1708
+ }
1709
+ function isModelResident(psModels, model) {
1710
+ return psModels.some((entry) => matchesModelReference(entry, model));
1711
+ }
1712
+ function findRunningModelContextLength(psModels, model) {
1713
+ for (const entry of psModels) {
1714
+ if (matchesModelReference(entry, model)) {
1715
+ return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
1716
+ }
1717
+ }
1718
+ return void 0;
1719
+ }
1720
+ function positiveInt(value) {
1721
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1722
+ }
1723
+ function resolveContextLength(signals) {
1724
+ const running = positiveInt(signals.running);
1725
+ const parameter = positiveInt(signals.parameter);
1726
+ const native = positiveInt(signals.native);
1727
+ if (running !== void 0) {
1728
+ return {
1729
+ contextLength: running,
1730
+ source: "running",
1731
+ ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
1732
+ ...native !== void 0 ? { nativeContextLength: native } : {},
1733
+ runningContextLength: running
1734
+ };
1735
+ }
1736
+ if (parameter !== void 0) {
1737
+ return {
1738
+ contextLength: parameter,
1739
+ source: "parameters",
1740
+ parameterContextLength: parameter,
1741
+ ...native !== void 0 ? { nativeContextLength: native } : {}
1742
+ };
1743
+ }
1744
+ if (native !== void 0) {
1745
+ return { contextLength: native, source: "model-info", nativeContextLength: native };
1746
+ }
1747
+ return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
1748
+ }
1749
+
1750
+ // src/providers/model-affinity-router.ts
1751
+ var DEFAULT_TTL_MS = 3e4;
1752
+ var DEFAULT_FAILURE_RETRY_MS = 5e3;
1753
+ var ModelAffinityRouter = class {
1754
+ fetchPs;
1755
+ ttlMs;
1756
+ failureRetryMs;
1757
+ now;
1758
+ cache = /* @__PURE__ */ new Map();
1759
+ failedAt = /* @__PURE__ */ new Map();
1760
+ inflight = /* @__PURE__ */ new Map();
1761
+ constructor(fetchPs, options = {}) {
1762
+ this.fetchPs = fetchPs;
1763
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1764
+ this.failureRetryMs = options.failureRetryMs ?? DEFAULT_FAILURE_RETRY_MS;
1765
+ this.now = options.now ?? Date.now;
1766
+ }
1767
+ /**
1768
+ * Synchronously reorders `candidates` so endpoints whose fresh `/api/ps`
1769
+ * snapshot shows `model` resident come first (relative order preserved within
1770
+ * each group). Returns the input unchanged — and kicks a background refresh —
1771
+ * when any candidate's snapshot is missing or stale: a wrong reorder is worse
1772
+ * than no reorder, and the first request after a cache miss simply isn't
1773
+ * reordered. No-op without a `model` or with fewer than two candidates.
1774
+ */
1775
+ reorder(candidates, model) {
1776
+ if (model === void 0 || candidates.length < 2) return candidates;
1777
+ const currentTime = this.now();
1778
+ let allFresh = true;
1779
+ for (const endpoint of candidates) {
1780
+ const entry = this.cache.get(endpoint.name);
1781
+ if (entry === void 0 || currentTime - entry.fetchedAt > this.ttlMs) {
1782
+ allFresh = false;
1783
+ this.kickRefresh(endpoint);
1784
+ }
1785
+ }
1786
+ if (!allFresh) return candidates;
1787
+ const resident = candidates.filter((ep) => {
1788
+ const entry = this.cache.get(ep.name);
1789
+ return entry !== void 0 && isModelResident(entry.models, model);
1790
+ });
1791
+ if (resident.length === 0 || resident.length === candidates.length) return candidates;
1792
+ const residentSet = new Set(resident.map((ep) => ep.name));
1793
+ return [...resident, ...candidates.filter((ep) => !residentSet.has(ep.name))];
1794
+ }
1795
+ /**
1796
+ * Forces a refresh of the affinity cache for the given endpoints (deduped with
1797
+ * any in-flight refresh; bypasses the failure backoff, because this is an
1798
+ * explicit caller request — `client.warmModelAffinity()`). Resolves once every
1799
+ * probe has settled — successfully or not; individual failures never reject.
1800
+ */
1801
+ async warm(endpoints) {
1802
+ await Promise.all(endpoints.map((endpoint) => this.refresh(endpoint)));
1803
+ }
1804
+ /** Introspection snapshots for the given endpoints (defaults to cached ones). */
1805
+ status(endpoints) {
1806
+ const names = endpoints !== void 0 ? endpoints.map((ep) => ep.name) : [...this.cache.keys(), ...this.failedAt.keys()];
1807
+ const byName = new Map((endpoints ?? []).map((ep) => [ep.name, ep]));
1808
+ const currentTime = this.now();
1809
+ return names.filter((name, i, arr) => arr.indexOf(name) === i).map((name) => {
1810
+ const entry = this.cache.get(name);
1811
+ return {
1812
+ endpointName: name,
1813
+ baseUrl: byName.get(name)?.baseUrl ?? "",
1814
+ loadedModels: entry?.models.map((m) => m.name ?? m.model ?? "").filter((n) => n !== "") ?? [],
1815
+ fetchedAt: entry?.fetchedAt,
1816
+ fresh: entry !== void 0 && currentTime - entry.fetchedAt <= this.ttlMs
1817
+ };
1818
+ });
1819
+ }
1820
+ /**
1821
+ * Starts (or joins) a background refresh for `endpoint`, unless one is already
1822
+ * in flight or the failure backoff suppresses it. Fire-and-forget: the promise
1823
+ * is stored only for dedupe/warm-joins; callers never await it on the request
1824
+ * path.
1825
+ */
1826
+ kickRefresh(endpoint) {
1827
+ if (this.inflight.has(endpoint.name)) return;
1828
+ const lastFailure = this.failedAt.get(endpoint.name);
1829
+ if (lastFailure !== void 0 && this.now() - lastFailure < this.failureRetryMs) return;
1830
+ void this.refresh(endpoint);
1831
+ }
1832
+ /** Refreshes one endpoint's snapshot; never rejects (failures record a backoff). */
1833
+ refresh(endpoint) {
1834
+ const existing = this.inflight.get(endpoint.name);
1835
+ if (existing !== void 0) return existing;
1836
+ const attempt = this.fetchPs(endpoint).then((models) => {
1837
+ this.cache.set(endpoint.name, { models, fetchedAt: this.now() });
1838
+ this.failedAt.delete(endpoint.name);
1839
+ }).catch(() => {
1840
+ this.failedAt.set(endpoint.name, this.now());
1841
+ }).finally(() => {
1842
+ this.inflight.delete(endpoint.name);
1843
+ });
1844
+ this.inflight.set(endpoint.name, attempt);
1845
+ return attempt;
1846
+ }
1847
+ };
1577
1848
  function extractJsonSubstring(text) {
1578
1849
  const trimmed = text.trim();
1579
1850
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
@@ -2186,151 +2457,6 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2186
2457
  return { signal: controller.signal, cancel };
2187
2458
  }
2188
2459
 
2189
- // src/context-safety.ts
2190
- var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
2191
- var IMAGE_TOKEN_ESTIMATE = 300;
2192
- var CHARS_PER_TOKEN = 4;
2193
- function isCjkCodePoint(code) {
2194
- return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
2195
- code >= 13312 && code <= 19903 || // CJK Extension A
2196
- code >= 12352 && code <= 12543 || // Hiragana + Katakana
2197
- code >= 44032 && code <= 55215 || // Hangul syllables
2198
- code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
2199
- code >= 65280 && code <= 65519;
2200
- }
2201
- function estimateTokens(text) {
2202
- if (text.length === 0) return 0;
2203
- let cjk = 0;
2204
- let other = 0;
2205
- for (const char of text) {
2206
- if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
2207
- else other += 1;
2208
- }
2209
- return Math.ceil(cjk + other / CHARS_PER_TOKEN);
2210
- }
2211
- var MESSAGE_OVERHEAD_TOKENS = 4;
2212
- function estimateMessageTokens(message) {
2213
- let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
2214
- if (message.thinking !== void 0) total += estimateTokens(message.thinking);
2215
- if (message.tool_calls !== void 0) {
2216
- for (const call of message.tool_calls) {
2217
- total += estimateTokens(call.function.name);
2218
- total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
2219
- }
2220
- }
2221
- if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
2222
- return total;
2223
- }
2224
- function estimateChatRequestTokens(req) {
2225
- let total = 0;
2226
- for (const message of req.messages) total += estimateMessageTokens(message);
2227
- if (req.tools !== void 0) {
2228
- for (const tool of req.tools) {
2229
- total += estimateTokens(tool.function.description ?? "");
2230
- total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
2231
- }
2232
- }
2233
- return total;
2234
- }
2235
- function estimateGenerateRequestTokens(req) {
2236
- let total = estimateTokens(req.prompt ?? "");
2237
- if (req.system !== void 0) total += estimateTokens(req.system);
2238
- if (req.template !== void 0) total += estimateTokens(req.template);
2239
- if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
2240
- return total;
2241
- }
2242
- function checkChatContext(req, options = {}) {
2243
- return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
2244
- }
2245
- function checkGenerateContext(req, options = {}) {
2246
- return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
2247
- }
2248
- function check(estimatedPromptTokens, requestNumCtx, options) {
2249
- const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
2250
- if (requestNumCtx !== void 0) {
2251
- return {
2252
- estimatedPromptTokens,
2253
- effectiveContextLength: requestNumCtx,
2254
- windowSource: "request",
2255
- exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
2256
- };
2257
- }
2258
- if (options.defaultContextLength !== void 0) {
2259
- return {
2260
- estimatedPromptTokens,
2261
- effectiveContextLength: options.defaultContextLength,
2262
- windowSource: "client-default",
2263
- exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
2264
- };
2265
- }
2266
- return {
2267
- estimatedPromptTokens,
2268
- effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
2269
- windowSource: "server-fallback",
2270
- exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
2271
- };
2272
- }
2273
- var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
2274
- function contextWarningMessage(check2, kind) {
2275
- const where = `OllamaClient.${kind}()`;
2276
- const estimate = `~${check2.estimatedPromptTokens} tokens`;
2277
- if (check2.windowSource === "request") {
2278
- return `${where}: estimated prompt size ${estimate} is close to or beyond the request's context window (num_ctx=${check2.effectiveContextLength}). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`options.num_ctx\` or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2279
- }
2280
- if (check2.windowSource === "client-default") {
2281
- return `${where}: estimated prompt size ${estimate} is close to or beyond the client's \`defaultContextLength\` (${check2.effectiveContextLength}, injected as \`num_ctx\`). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`defaultContextLength\`, set \`options.num_ctx\` per request, or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2282
- }
2283
- return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2284
- }
2285
-
2286
- // src/context-discovery.ts
2287
- function extractParameterNumCtx(parameters) {
2288
- if (!parameters) return void 0;
2289
- const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
2290
- if (match === null) return void 0;
2291
- const value = Number.parseInt(match[1], 10);
2292
- return Number.isFinite(value) && value > 0 ? value : void 0;
2293
- }
2294
- function findRunningModelContextLength(psModels, model) {
2295
- for (const entry of psModels) {
2296
- const exact = entry.name === model || entry.model === model;
2297
- const taglessPrefix = !model.includes(":") && (entry.name.startsWith(`${model}:`) || entry.model.startsWith(`${model}:`));
2298
- if (exact || taglessPrefix) {
2299
- return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
2300
- }
2301
- }
2302
- return void 0;
2303
- }
2304
- function positiveInt(value) {
2305
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2306
- }
2307
- function resolveContextLength(signals) {
2308
- const running = positiveInt(signals.running);
2309
- const parameter = positiveInt(signals.parameter);
2310
- const native = positiveInt(signals.native);
2311
- if (running !== void 0) {
2312
- return {
2313
- contextLength: running,
2314
- source: "running",
2315
- ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
2316
- ...native !== void 0 ? { nativeContextLength: native } : {},
2317
- runningContextLength: running
2318
- };
2319
- }
2320
- if (parameter !== void 0) {
2321
- return {
2322
- contextLength: parameter,
2323
- source: "parameters",
2324
- parameterContextLength: parameter,
2325
- ...native !== void 0 ? { nativeContextLength: native } : {}
2326
- };
2327
- }
2328
- if (native !== void 0) {
2329
- return { contextLength: native, source: "model-info", nativeContextLength: native };
2330
- }
2331
- return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
2332
- }
2333
-
2334
2460
  // src/keep-alive.ts
2335
2461
  var KEEP_ALIVE_UNLOAD = 0;
2336
2462
  var KEEP_ALIVE_INDEFINITE = -1;
@@ -5317,7 +5443,7 @@ function createLogicalRequestId() {
5317
5443
  logicalRequestSequence += 1;
5318
5444
  return `ollama-request-${logicalRequestSequence}`;
5319
5445
  }
5320
- var OllamaClient = class {
5446
+ var OllamaClient = class _OllamaClient {
5321
5447
  registry;
5322
5448
  models;
5323
5449
  retryConfig;
@@ -5349,6 +5475,16 @@ var OllamaClient = class {
5349
5475
  * (streams, via `executeWithFailover`'s `holdUntil` release path).
5350
5476
  */
5351
5477
  activeRequests = /* @__PURE__ */ new Set();
5478
+ /**
5479
+ * Dynamic multi-host model-affinity routing, active only when
5480
+ * `config.endpointHealth.modelAffinity` is set — see
5481
+ * `src/providers/model-affinity-router.ts` and ADR 0028. `undefined` means
5482
+ * the request path skips reordering entirely (zero overhead, and no `/api/ps`
5483
+ * probes are ever issued).
5484
+ */
5485
+ modelAffinity;
5486
+ /** Default timeout for one-off probe requests (`ping`, affinity `/api/ps` refreshes). */
5487
+ static PROBE_TIMEOUT_MS = 5e3;
5352
5488
  constructor(config = {}) {
5353
5489
  this.cloudApiKey = resolveApiKey(config.apiKey);
5354
5490
  const defaultBaseUrl = resolveBaseUrl(config.baseUrl);
@@ -5376,6 +5512,11 @@ var OllamaClient = class {
5376
5512
  this.defaultContextLength = config.defaultContextLength;
5377
5513
  this.contextWarningThreshold = config.contextWarningThreshold;
5378
5514
  this.onContextOverflow = config.onContextOverflow ?? "warn";
5515
+ const modelAffinityOptions = config.endpointHealth?.modelAffinity;
5516
+ this.modelAffinity = modelAffinityOptions !== void 0 ? new ModelAffinityRouter(
5517
+ (endpoint) => this.fetchEndpointRunningModels(endpoint),
5518
+ modelAffinityOptions
5519
+ ) : void 0;
5379
5520
  this.models = new ModelsClient((op, opts) => this.executeWithFailover(op, opts));
5380
5521
  }
5381
5522
  get modelsClient() {
@@ -5586,7 +5727,8 @@ var OllamaClient = class {
5586
5727
  );
5587
5728
  continue;
5588
5729
  }
5589
- for (const [attemptIndex, endpoint] of runnable.entries()) {
5730
+ const ordered = this.modelAffinity !== void 0 ? this.modelAffinity.reorder(runnable, options?.model) : runnable;
5731
+ for (const [attemptIndex, endpoint] of ordered.entries()) {
5590
5732
  this.logger.debug(`Executing on endpoint "${endpoint.name}" (${endpoint.baseUrl})`);
5591
5733
  const http = new HttpClient({
5592
5734
  baseUrl: endpoint.baseUrl,
@@ -6108,9 +6250,114 @@ var OllamaClient = class {
6108
6250
  )
6109
6251
  );
6110
6252
  }
6253
+ /**
6254
+ * Ultra-lightweight liveness probe against the client's current best
6255
+ * endpoint: `HEAD /` — the root ping Ollama's documentation defines for
6256
+ * load-balancer health checks (HTTP 200; the daemon answers `GET /` with the
6257
+ * plain-text body "Ollama is running", which a HEAD request doesn't transfer).
6258
+ *
6259
+ * Deliberately a *single-endpoint, single-shot* probe — no failover to other
6260
+ * candidates, no retry — so the answer reflects one concrete host, not "some
6261
+ * endpoint eventually answered". To audit every configured endpoint, use
6262
+ * {@link healthCheck} (which probes all of them, and also reports the server
6263
+ * version at the cost of the heavier `GET /api/version` round trip).
6264
+ *
6265
+ * Resolves — never throws — with `{ healthy: false, error }` for connection
6266
+ * failures and timeouts; only caller-initiated cancellation (`signal`/
6267
+ * `destroy()`) rejects, mirroring how any other aborted request behaves.
6268
+ */
6269
+ async ping(options) {
6270
+ const endpoint = this.registry.candidates()[0];
6271
+ if (endpoint === void 0) {
6272
+ return { healthy: false, latencyMs: 0, baseUrl: "", error: "No configured endpoints" };
6273
+ }
6274
+ const http = new HttpClient({
6275
+ baseUrl: endpoint.baseUrl,
6276
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6277
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6278
+ fetch: this.fetchImpl,
6279
+ middleware: this.middleware,
6280
+ onLifecycleEvent: this.onLifecycleEvent
6281
+ });
6282
+ const timeout = createTimeoutSignal(
6283
+ options?.timeoutMs ?? _OllamaClient.PROBE_TIMEOUT_MS,
6284
+ options?.signal
6285
+ );
6286
+ const startTime = Date.now();
6287
+ try {
6288
+ await http.request({ path: "/", method: "HEAD", signal: timeout.signal });
6289
+ return { healthy: true, latencyMs: Date.now() - startTime, baseUrl: endpoint.baseUrl };
6290
+ } catch (err) {
6291
+ if (options?.signal?.aborted) {
6292
+ throw err instanceof Error ? err : new Error(String(err));
6293
+ }
6294
+ return {
6295
+ healthy: false,
6296
+ latencyMs: Date.now() - startTime,
6297
+ baseUrl: endpoint.baseUrl,
6298
+ error: err instanceof Error ? err.message : String(err)
6299
+ };
6300
+ } finally {
6301
+ timeout.cancel();
6302
+ }
6303
+ }
6304
+ /**
6305
+ * Affinity-cache snapshots for every configured endpoint — which models each
6306
+ * host held in VRAM at the last successful `/api/ps` probe, when that was, and
6307
+ * whether it's still fresh. Empty when `endpointHealth.modelAffinity` isn't
6308
+ * configured (no probes are ever issued, so there is nothing to report).
6309
+ */
6310
+ modelAffinityStatus() {
6311
+ return this.modelAffinity?.status(this.registry.list()) ?? [];
6312
+ }
6313
+ /**
6314
+ * Explicitly refreshes the model-affinity cache for every configured endpoint
6315
+ * (deduped with any in-flight background refresh). No-op — resolves
6316
+ * immediately — when `endpointHealth.modelAffinity` isn't configured. Use it
6317
+ * to warm the cache at startup so the very first request already routes by
6318
+ * residency instead of waiting for the self-warming first-round refresh.
6319
+ */
6320
+ async warmModelAffinity() {
6321
+ await this.modelAffinity?.warm(this.registry.list());
6322
+ }
6111
6323
  endpointStatus() {
6112
6324
  return this.registry.status();
6113
6325
  }
6326
+ /**
6327
+ * Fetches one endpoint's `GET /api/ps` running-model list for the affinity
6328
+ * router. Bypasses `executeWithFailover` deliberately: probes must not
6329
+ * reenter the routing machinery they inform, must not touch the registry's
6330
+ * circuit-breaker/failure bookkeeping (a dead host's probe failing must not
6331
+ * cool it down harder than its real traffic already does), and must not fail
6332
+ * over to a different host (the answer is per-host by definition). Failures
6333
+ * propagate to the router, which records a short backoff and retries later.
6334
+ */
6335
+ async fetchEndpointRunningModels(endpoint) {
6336
+ var _stack = [];
6337
+ try {
6338
+ const http = new HttpClient({
6339
+ baseUrl: endpoint.baseUrl,
6340
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6341
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6342
+ fetch: this.fetchImpl,
6343
+ middleware: this.middleware,
6344
+ onLifecycleEvent: this.onLifecycleEvent
6345
+ });
6346
+ const controller = new AbortController();
6347
+ const timer = setTimeout(() => controller.abort(), _OllamaClient.PROBE_TIMEOUT_MS);
6348
+ const _timer = __using(_stack, disposableTimer(timer));
6349
+ const res = await http.request({
6350
+ path: "/api/ps",
6351
+ method: "GET",
6352
+ signal: controller.signal
6353
+ });
6354
+ return res.models ?? [];
6355
+ } catch (_) {
6356
+ var _error = _, _hasError = true;
6357
+ } finally {
6358
+ __callDispose(_stack, _error, _hasError);
6359
+ }
6360
+ }
6114
6361
  /**
6115
6362
  * Immediately aborts every in-flight request and active stream managed by
6116
6363
  * this client — the clean teardown path for short-lived processes, CLI
@@ -7512,6 +7759,7 @@ exports.MAX_SYSTEM_ONE_IMAGES_BYTES = MAX_SYSTEM_ONE_IMAGES_BYTES;
7512
7759
  exports.MAX_SYSTEM_ONE_REQUEST_BYTES = MAX_SYSTEM_ONE_REQUEST_BYTES;
7513
7760
  exports.MESSAGE_OVERHEAD_TOKENS = MESSAGE_OVERHEAD_TOKENS;
7514
7761
  exports.McpBridge = McpBridge;
7762
+ exports.ModelAffinityRouter = ModelAffinityRouter;
7515
7763
  exports.ModelAffinityScheduler = ModelAffinityScheduler;
7516
7764
  exports.ModelsClient = ModelsClient;
7517
7765
  exports.NANOS_PER_MS = NANOS_PER_MS;
@@ -7590,10 +7838,12 @@ exports.extractUsage = extractUsage;
7590
7838
  exports.fetchWithBackoff = fetchWithBackoff;
7591
7839
  exports.findOversizedEmbedInputs = findOversizedEmbedInputs;
7592
7840
  exports.findRunningModelContextLength = findRunningModelContextLength;
7841
+ exports.formatTelemetry = formatTelemetry;
7593
7842
  exports.imageStringNeedsResolution = imageStringNeedsResolution;
7594
7843
  exports.inferRuntimeMode = inferRuntimeMode;
7595
7844
  exports.isKeepAliveSugar = isKeepAliveSugar;
7596
7845
  exports.isKnownDoneReason = isKnownDoneReason;
7846
+ exports.isModelResident = isModelResident;
7597
7847
  exports.isSameToolCall = isSameToolCall;
7598
7848
  exports.listAllMcpTools = listAllMcpTools;
7599
7849
  exports.listAvailableModels = listAvailableModels;