@nemesis-oss/ollama-sdk 1.11.0 → 1.12.1

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.js CHANGED
@@ -1035,6 +1035,27 @@ var ATTR_OLLAMA_AGENT_MAX_ITERATIONS = "ollama.agent.max_iterations";
1035
1035
  var ATTR_OLLAMA_AGENT_ITERATION = "ollama.agent.iteration";
1036
1036
  var GEN_AI_SYSTEM_OLLAMA = "ollama";
1037
1037
 
1038
+ // src/telemetry/metrics.ts
1039
+ function nsToMs(ns) {
1040
+ return ns ? Math.round(ns / 1e6 * 100) / 100 : 0;
1041
+ }
1042
+ function formatTelemetry(raw) {
1043
+ const evalDurationSec = raw.eval_duration ? raw.eval_duration / 1e9 : 0;
1044
+ const promptDurationSec = raw.prompt_eval_duration ? raw.prompt_eval_duration / 1e9 : 0;
1045
+ const promptEvaluated = raw.prompt_eval_count ?? 0;
1046
+ const promptCached = raw.prompt_eval_cached_count ?? 0;
1047
+ const promptTotal = promptCached + promptEvaluated;
1048
+ return {
1049
+ totalLatencyMs: nsToMs(raw.total_duration),
1050
+ modelLoadMs: nsToMs(raw.load_duration),
1051
+ promptEvalMs: nsToMs(raw.prompt_eval_duration),
1052
+ generationMs: nsToMs(raw.eval_duration),
1053
+ tokensPerSecond: evalDurationSec > 0 && raw.eval_count ? Math.round(raw.eval_count / evalDurationSec * 10) / 10 : 0,
1054
+ promptTokensPerSecond: promptDurationSec > 0 && promptEvaluated ? Math.round(promptEvaluated / promptDurationSec * 10) / 10 : 0,
1055
+ cacheHitRatio: promptTotal > 0 ? Math.round(promptCached / promptTotal * 1e3) / 1e3 : 0
1056
+ };
1057
+ }
1058
+
1038
1059
  // src/transport/http.ts
1039
1060
  function routeTemplate(path) {
1040
1061
  return path.replace(/^\/api\/blobs\/.+$/, "/api/blobs/{digest}");
@@ -1572,6 +1593,272 @@ async function listAvailableModels(http) {
1572
1593
  });
1573
1594
  return [...res.models];
1574
1595
  }
1596
+
1597
+ // src/context-safety.ts
1598
+ var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
1599
+ var IMAGE_TOKEN_ESTIMATE = 300;
1600
+ var CHARS_PER_TOKEN = 4;
1601
+ function isCjkCodePoint(code) {
1602
+ return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
1603
+ code >= 13312 && code <= 19903 || // CJK Extension A
1604
+ code >= 12352 && code <= 12543 || // Hiragana + Katakana
1605
+ code >= 44032 && code <= 55215 || // Hangul syllables
1606
+ code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
1607
+ code >= 65280 && code <= 65519;
1608
+ }
1609
+ function estimateTokens(text) {
1610
+ if (text.length === 0) return 0;
1611
+ let cjk = 0;
1612
+ let other = 0;
1613
+ for (const char of text) {
1614
+ if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
1615
+ else other += 1;
1616
+ }
1617
+ return Math.ceil(cjk + other / CHARS_PER_TOKEN);
1618
+ }
1619
+ var MESSAGE_OVERHEAD_TOKENS = 4;
1620
+ function estimateMessageTokens(message) {
1621
+ let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
1622
+ if (message.thinking !== void 0) total += estimateTokens(message.thinking);
1623
+ if (message.tool_calls !== void 0) {
1624
+ for (const call of message.tool_calls) {
1625
+ total += estimateTokens(call.function.name);
1626
+ total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
1627
+ }
1628
+ }
1629
+ if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
1630
+ return total;
1631
+ }
1632
+ function estimateChatRequestTokens(req) {
1633
+ let total = 0;
1634
+ for (const message of req.messages) total += estimateMessageTokens(message);
1635
+ if (req.tools !== void 0) {
1636
+ for (const tool of req.tools) {
1637
+ total += estimateTokens(tool.function.description ?? "");
1638
+ total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
1639
+ }
1640
+ }
1641
+ return total;
1642
+ }
1643
+ function estimateGenerateRequestTokens(req) {
1644
+ let total = estimateTokens(req.prompt ?? "");
1645
+ if (req.system !== void 0) total += estimateTokens(req.system);
1646
+ if (req.template !== void 0) total += estimateTokens(req.template);
1647
+ if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
1648
+ return total;
1649
+ }
1650
+ function checkChatContext(req, options = {}) {
1651
+ return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
1652
+ }
1653
+ function checkGenerateContext(req, options = {}) {
1654
+ return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
1655
+ }
1656
+ function check(estimatedPromptTokens, requestNumCtx, options) {
1657
+ const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
1658
+ if (requestNumCtx !== void 0) {
1659
+ return {
1660
+ estimatedPromptTokens,
1661
+ effectiveContextLength: requestNumCtx,
1662
+ windowSource: "request",
1663
+ exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
1664
+ };
1665
+ }
1666
+ if (options.defaultContextLength !== void 0) {
1667
+ return {
1668
+ estimatedPromptTokens,
1669
+ effectiveContextLength: options.defaultContextLength,
1670
+ windowSource: "client-default",
1671
+ exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
1672
+ };
1673
+ }
1674
+ return {
1675
+ estimatedPromptTokens,
1676
+ effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
1677
+ windowSource: "server-fallback",
1678
+ exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
1679
+ };
1680
+ }
1681
+ var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
1682
+ function contextWarningMessage(check2, kind) {
1683
+ const where = `OllamaClient.${kind}()`;
1684
+ const estimate = `~${check2.estimatedPromptTokens} tokens`;
1685
+ if (check2.windowSource === "request") {
1686
+ 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\`.`;
1687
+ }
1688
+ if (check2.windowSource === "client-default") {
1689
+ 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\`.`;
1690
+ }
1691
+ 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\`.`;
1692
+ }
1693
+
1694
+ // src/context-discovery.ts
1695
+ function extractParameterNumCtx(parameters) {
1696
+ if (!parameters) return void 0;
1697
+ const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
1698
+ if (match === null) return void 0;
1699
+ const value = Number.parseInt(match[1], 10);
1700
+ return Number.isFinite(value) && value > 0 ? value : void 0;
1701
+ }
1702
+ function matchesModelReference(entry, model) {
1703
+ const exact = entry.name === model || entry.model === model;
1704
+ const taglessPrefix = !model.includes(":") && (entry.name !== void 0 && entry.name.startsWith(`${model}:`) || entry.model !== void 0 && entry.model.startsWith(`${model}:`));
1705
+ return exact || taglessPrefix;
1706
+ }
1707
+ function isModelResident(psModels, model) {
1708
+ return psModels.some((entry) => matchesModelReference(entry, model));
1709
+ }
1710
+ function findRunningModelContextLength(psModels, model) {
1711
+ for (const entry of psModels) {
1712
+ if (matchesModelReference(entry, model)) {
1713
+ return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
1714
+ }
1715
+ }
1716
+ return void 0;
1717
+ }
1718
+ function positiveInt(value) {
1719
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
1720
+ }
1721
+ function resolveContextLength(signals) {
1722
+ const running = positiveInt(signals.running);
1723
+ const parameter = positiveInt(signals.parameter);
1724
+ const native = positiveInt(signals.native);
1725
+ if (running !== void 0) {
1726
+ return {
1727
+ contextLength: running,
1728
+ source: "running",
1729
+ ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
1730
+ ...native !== void 0 ? { nativeContextLength: native } : {},
1731
+ runningContextLength: running
1732
+ };
1733
+ }
1734
+ if (parameter !== void 0) {
1735
+ return {
1736
+ contextLength: parameter,
1737
+ source: "parameters",
1738
+ parameterContextLength: parameter,
1739
+ ...native !== void 0 ? { nativeContextLength: native } : {}
1740
+ };
1741
+ }
1742
+ if (native !== void 0) {
1743
+ return { contextLength: native, source: "model-info", nativeContextLength: native };
1744
+ }
1745
+ return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
1746
+ }
1747
+
1748
+ // src/providers/model-affinity-router.ts
1749
+ var DEFAULT_TTL_MS = 3e4;
1750
+ var DEFAULT_FAILURE_RETRY_MS = 5e3;
1751
+ var ModelAffinityRouter = class {
1752
+ fetchPs;
1753
+ ttlMs;
1754
+ failureRetryMs;
1755
+ now;
1756
+ cache = /* @__PURE__ */ new Map();
1757
+ failedAt = /* @__PURE__ */ new Map();
1758
+ inflight = /* @__PURE__ */ new Map();
1759
+ constructor(fetchPs, options = {}) {
1760
+ this.fetchPs = fetchPs;
1761
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
1762
+ this.failureRetryMs = options.failureRetryMs ?? DEFAULT_FAILURE_RETRY_MS;
1763
+ this.now = options.now ?? Date.now;
1764
+ }
1765
+ /**
1766
+ * Synchronously reorders `candidates` so that, within each priority tier
1767
+ * (run of consecutive equal `priority`), endpoints whose fresh `/api/ps`
1768
+ * snapshot shows `model` resident come first (relative order preserved within
1769
+ * each group). Never moves an endpoint across tiers. Returns the input unchanged — and kicks a background refresh —
1770
+ * when any candidate's snapshot is missing or stale: a wrong reorder is worse
1771
+ * than no reorder, and the first request after a cache miss simply isn't
1772
+ * reordered. No-op without a `model` or with fewer than two candidates.
1773
+ */
1774
+ reorder(candidates, model) {
1775
+ if (model === void 0 || candidates.length < 2) return candidates;
1776
+ const currentTime = this.now();
1777
+ let allFresh = true;
1778
+ for (const endpoint of candidates) {
1779
+ const entry = this.cache.get(endpoint.name);
1780
+ if (entry === void 0 || currentTime - entry.fetchedAt > this.ttlMs) {
1781
+ allFresh = false;
1782
+ this.kickRefresh(endpoint);
1783
+ }
1784
+ }
1785
+ if (!allFresh) return candidates;
1786
+ const isResident = (ep) => {
1787
+ const entry = this.cache.get(ep.name);
1788
+ return entry !== void 0 && isModelResident(entry.models, model);
1789
+ };
1790
+ const reordered = [];
1791
+ let changed = false;
1792
+ for (let start = 0; start < candidates.length; ) {
1793
+ const tierPriority = candidates[start].priority ?? 0;
1794
+ let end = start + 1;
1795
+ while (end < candidates.length && (candidates[end].priority ?? 0) === tierPriority) end++;
1796
+ const tier = candidates.slice(start, end);
1797
+ const resident = tier.filter(isResident);
1798
+ if (resident.length === 0 || resident.length === tier.length) {
1799
+ reordered.push(...tier);
1800
+ } else {
1801
+ const tierOrder = [...resident, ...tier.filter((ep) => !isResident(ep))];
1802
+ changed ||= tierOrder.some((ep, i) => ep !== tier[i]);
1803
+ reordered.push(...tierOrder);
1804
+ }
1805
+ start = end;
1806
+ }
1807
+ return changed ? reordered : candidates;
1808
+ }
1809
+ /**
1810
+ * Forces a refresh of the affinity cache for the given endpoints (deduped with
1811
+ * any in-flight refresh; bypasses the failure backoff, because this is an
1812
+ * explicit caller request — `client.warmModelAffinity()`). Resolves once every
1813
+ * probe has settled — successfully or not; individual failures never reject.
1814
+ */
1815
+ async warm(endpoints) {
1816
+ await Promise.all(endpoints.map((endpoint) => this.refresh(endpoint)));
1817
+ }
1818
+ /** Introspection snapshots for the given endpoints (defaults to cached ones). */
1819
+ status(endpoints) {
1820
+ const names = endpoints !== void 0 ? endpoints.map((ep) => ep.name) : [...this.cache.keys(), ...this.failedAt.keys()];
1821
+ const byName = new Map((endpoints ?? []).map((ep) => [ep.name, ep]));
1822
+ const currentTime = this.now();
1823
+ return names.filter((name, i, arr) => arr.indexOf(name) === i).map((name) => {
1824
+ const entry = this.cache.get(name);
1825
+ return {
1826
+ endpointName: name,
1827
+ baseUrl: byName.get(name)?.baseUrl ?? "",
1828
+ loadedModels: entry?.models.map((m) => m.name ?? m.model ?? "").filter((n) => n !== "") ?? [],
1829
+ fetchedAt: entry?.fetchedAt,
1830
+ fresh: entry !== void 0 && currentTime - entry.fetchedAt <= this.ttlMs
1831
+ };
1832
+ });
1833
+ }
1834
+ /**
1835
+ * Starts (or joins) a background refresh for `endpoint`, unless one is already
1836
+ * in flight or the failure backoff suppresses it. Fire-and-forget: the promise
1837
+ * is stored only for dedupe/warm-joins; callers never await it on the request
1838
+ * path.
1839
+ */
1840
+ kickRefresh(endpoint) {
1841
+ if (this.inflight.has(endpoint.name)) return;
1842
+ const lastFailure = this.failedAt.get(endpoint.name);
1843
+ if (lastFailure !== void 0 && this.now() - lastFailure < this.failureRetryMs) return;
1844
+ void this.refresh(endpoint);
1845
+ }
1846
+ /** Refreshes one endpoint's snapshot; never rejects (failures record a backoff). */
1847
+ refresh(endpoint) {
1848
+ const existing = this.inflight.get(endpoint.name);
1849
+ if (existing !== void 0) return existing;
1850
+ const attempt = this.fetchPs(endpoint).then((models) => {
1851
+ this.cache.set(endpoint.name, { models, fetchedAt: this.now() });
1852
+ this.failedAt.delete(endpoint.name);
1853
+ }).catch(() => {
1854
+ this.failedAt.set(endpoint.name, this.now());
1855
+ }).finally(() => {
1856
+ this.inflight.delete(endpoint.name);
1857
+ });
1858
+ this.inflight.set(endpoint.name, attempt);
1859
+ return attempt;
1860
+ }
1861
+ };
1575
1862
  function extractJsonSubstring(text) {
1576
1863
  const trimmed = text.trim();
1577
1864
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
@@ -2184,151 +2471,6 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2184
2471
  return { signal: controller.signal, cancel };
2185
2472
  }
2186
2473
 
2187
- // src/context-safety.ts
2188
- var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
2189
- var IMAGE_TOKEN_ESTIMATE = 300;
2190
- var CHARS_PER_TOKEN = 4;
2191
- function isCjkCodePoint(code) {
2192
- return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
2193
- code >= 13312 && code <= 19903 || // CJK Extension A
2194
- code >= 12352 && code <= 12543 || // Hiragana + Katakana
2195
- code >= 44032 && code <= 55215 || // Hangul syllables
2196
- code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
2197
- code >= 65280 && code <= 65519;
2198
- }
2199
- function estimateTokens(text) {
2200
- if (text.length === 0) return 0;
2201
- let cjk = 0;
2202
- let other = 0;
2203
- for (const char of text) {
2204
- if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
2205
- else other += 1;
2206
- }
2207
- return Math.ceil(cjk + other / CHARS_PER_TOKEN);
2208
- }
2209
- var MESSAGE_OVERHEAD_TOKENS = 4;
2210
- function estimateMessageTokens(message) {
2211
- let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
2212
- if (message.thinking !== void 0) total += estimateTokens(message.thinking);
2213
- if (message.tool_calls !== void 0) {
2214
- for (const call of message.tool_calls) {
2215
- total += estimateTokens(call.function.name);
2216
- total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
2217
- }
2218
- }
2219
- if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
2220
- return total;
2221
- }
2222
- function estimateChatRequestTokens(req) {
2223
- let total = 0;
2224
- for (const message of req.messages) total += estimateMessageTokens(message);
2225
- if (req.tools !== void 0) {
2226
- for (const tool of req.tools) {
2227
- total += estimateTokens(tool.function.description ?? "");
2228
- total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
2229
- }
2230
- }
2231
- return total;
2232
- }
2233
- function estimateGenerateRequestTokens(req) {
2234
- let total = estimateTokens(req.prompt ?? "");
2235
- if (req.system !== void 0) total += estimateTokens(req.system);
2236
- if (req.template !== void 0) total += estimateTokens(req.template);
2237
- if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
2238
- return total;
2239
- }
2240
- function checkChatContext(req, options = {}) {
2241
- return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
2242
- }
2243
- function checkGenerateContext(req, options = {}) {
2244
- return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
2245
- }
2246
- function check(estimatedPromptTokens, requestNumCtx, options) {
2247
- const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
2248
- if (requestNumCtx !== void 0) {
2249
- return {
2250
- estimatedPromptTokens,
2251
- effectiveContextLength: requestNumCtx,
2252
- windowSource: "request",
2253
- exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
2254
- };
2255
- }
2256
- if (options.defaultContextLength !== void 0) {
2257
- return {
2258
- estimatedPromptTokens,
2259
- effectiveContextLength: options.defaultContextLength,
2260
- windowSource: "client-default",
2261
- exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
2262
- };
2263
- }
2264
- return {
2265
- estimatedPromptTokens,
2266
- effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
2267
- windowSource: "server-fallback",
2268
- exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
2269
- };
2270
- }
2271
- var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
2272
- function contextWarningMessage(check2, kind) {
2273
- const where = `OllamaClient.${kind}()`;
2274
- const estimate = `~${check2.estimatedPromptTokens} tokens`;
2275
- if (check2.windowSource === "request") {
2276
- 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\`.`;
2277
- }
2278
- if (check2.windowSource === "client-default") {
2279
- 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\`.`;
2280
- }
2281
- 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\`.`;
2282
- }
2283
-
2284
- // src/context-discovery.ts
2285
- function extractParameterNumCtx(parameters) {
2286
- if (!parameters) return void 0;
2287
- const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
2288
- if (match === null) return void 0;
2289
- const value = Number.parseInt(match[1], 10);
2290
- return Number.isFinite(value) && value > 0 ? value : void 0;
2291
- }
2292
- function findRunningModelContextLength(psModels, model) {
2293
- for (const entry of psModels) {
2294
- const exact = entry.name === model || entry.model === model;
2295
- const taglessPrefix = !model.includes(":") && (entry.name.startsWith(`${model}:`) || entry.model.startsWith(`${model}:`));
2296
- if (exact || taglessPrefix) {
2297
- return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
2298
- }
2299
- }
2300
- return void 0;
2301
- }
2302
- function positiveInt(value) {
2303
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2304
- }
2305
- function resolveContextLength(signals) {
2306
- const running = positiveInt(signals.running);
2307
- const parameter = positiveInt(signals.parameter);
2308
- const native = positiveInt(signals.native);
2309
- if (running !== void 0) {
2310
- return {
2311
- contextLength: running,
2312
- source: "running",
2313
- ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
2314
- ...native !== void 0 ? { nativeContextLength: native } : {},
2315
- runningContextLength: running
2316
- };
2317
- }
2318
- if (parameter !== void 0) {
2319
- return {
2320
- contextLength: parameter,
2321
- source: "parameters",
2322
- parameterContextLength: parameter,
2323
- ...native !== void 0 ? { nativeContextLength: native } : {}
2324
- };
2325
- }
2326
- if (native !== void 0) {
2327
- return { contextLength: native, source: "model-info", nativeContextLength: native };
2328
- }
2329
- return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
2330
- }
2331
-
2332
2474
  // src/keep-alive.ts
2333
2475
  var KEEP_ALIVE_UNLOAD = 0;
2334
2476
  var KEEP_ALIVE_INDEFINITE = -1;
@@ -5315,7 +5457,7 @@ function createLogicalRequestId() {
5315
5457
  logicalRequestSequence += 1;
5316
5458
  return `ollama-request-${logicalRequestSequence}`;
5317
5459
  }
5318
- var OllamaClient = class {
5460
+ var OllamaClient = class _OllamaClient {
5319
5461
  registry;
5320
5462
  models;
5321
5463
  retryConfig;
@@ -5347,6 +5489,16 @@ var OllamaClient = class {
5347
5489
  * (streams, via `executeWithFailover`'s `holdUntil` release path).
5348
5490
  */
5349
5491
  activeRequests = /* @__PURE__ */ new Set();
5492
+ /**
5493
+ * Dynamic multi-host model-affinity routing, active only when
5494
+ * `config.endpointHealth.modelAffinity` is set — see
5495
+ * `src/providers/model-affinity-router.ts` and ADR 0028. `undefined` means
5496
+ * the request path skips reordering entirely (zero overhead, and no `/api/ps`
5497
+ * probes are ever issued).
5498
+ */
5499
+ modelAffinity;
5500
+ /** Default timeout for one-off probe requests (`ping`, affinity `/api/ps` refreshes). */
5501
+ static PROBE_TIMEOUT_MS = 5e3;
5350
5502
  constructor(config = {}) {
5351
5503
  this.cloudApiKey = resolveApiKey(config.apiKey);
5352
5504
  const defaultBaseUrl = resolveBaseUrl(config.baseUrl);
@@ -5374,6 +5526,11 @@ var OllamaClient = class {
5374
5526
  this.defaultContextLength = config.defaultContextLength;
5375
5527
  this.contextWarningThreshold = config.contextWarningThreshold;
5376
5528
  this.onContextOverflow = config.onContextOverflow ?? "warn";
5529
+ const modelAffinityOptions = config.endpointHealth?.modelAffinity;
5530
+ this.modelAffinity = modelAffinityOptions !== void 0 ? new ModelAffinityRouter(
5531
+ (endpoint) => this.fetchEndpointRunningModels(endpoint),
5532
+ modelAffinityOptions
5533
+ ) : void 0;
5377
5534
  this.models = new ModelsClient((op, opts) => this.executeWithFailover(op, opts));
5378
5535
  }
5379
5536
  get modelsClient() {
@@ -5584,7 +5741,8 @@ var OllamaClient = class {
5584
5741
  );
5585
5742
  continue;
5586
5743
  }
5587
- for (const [attemptIndex, endpoint] of runnable.entries()) {
5744
+ const ordered = this.modelAffinity !== void 0 ? this.modelAffinity.reorder(runnable, options?.model) : runnable;
5745
+ for (const [attemptIndex, endpoint] of ordered.entries()) {
5588
5746
  this.logger.debug(`Executing on endpoint "${endpoint.name}" (${endpoint.baseUrl})`);
5589
5747
  const http = new HttpClient({
5590
5748
  baseUrl: endpoint.baseUrl,
@@ -6106,9 +6264,114 @@ var OllamaClient = class {
6106
6264
  )
6107
6265
  );
6108
6266
  }
6267
+ /**
6268
+ * Ultra-lightweight liveness probe against the client's current best
6269
+ * endpoint: `HEAD /` — the root ping Ollama's documentation defines for
6270
+ * load-balancer health checks (HTTP 200; the daemon answers `GET /` with the
6271
+ * plain-text body "Ollama is running", which a HEAD request doesn't transfer).
6272
+ *
6273
+ * Deliberately a *single-endpoint, single-shot* probe — no failover to other
6274
+ * candidates, no retry — so the answer reflects one concrete host, not "some
6275
+ * endpoint eventually answered". To audit every configured endpoint, use
6276
+ * {@link healthCheck} (which probes all of them, and also reports the server
6277
+ * version at the cost of the heavier `GET /api/version` round trip).
6278
+ *
6279
+ * Resolves — never throws — with `{ healthy: false, error }` for connection
6280
+ * failures and timeouts; only caller-initiated cancellation (`signal`/
6281
+ * `destroy()`) rejects, mirroring how any other aborted request behaves.
6282
+ */
6283
+ async ping(options) {
6284
+ const endpoint = this.registry.candidates()[0];
6285
+ if (endpoint === void 0) {
6286
+ return { healthy: false, latencyMs: 0, baseUrl: "", error: "No configured endpoints" };
6287
+ }
6288
+ const http = new HttpClient({
6289
+ baseUrl: endpoint.baseUrl,
6290
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6291
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6292
+ fetch: this.fetchImpl,
6293
+ middleware: this.middleware,
6294
+ onLifecycleEvent: this.onLifecycleEvent
6295
+ });
6296
+ const timeout = createTimeoutSignal(
6297
+ options?.timeoutMs ?? _OllamaClient.PROBE_TIMEOUT_MS,
6298
+ options?.signal
6299
+ );
6300
+ const startTime = Date.now();
6301
+ try {
6302
+ await http.request({ path: "/", method: "HEAD", signal: timeout.signal });
6303
+ return { healthy: true, latencyMs: Date.now() - startTime, baseUrl: endpoint.baseUrl };
6304
+ } catch (err) {
6305
+ if (options?.signal?.aborted) {
6306
+ throw err instanceof Error ? err : new Error(String(err));
6307
+ }
6308
+ return {
6309
+ healthy: false,
6310
+ latencyMs: Date.now() - startTime,
6311
+ baseUrl: endpoint.baseUrl,
6312
+ error: err instanceof Error ? err.message : String(err)
6313
+ };
6314
+ } finally {
6315
+ timeout.cancel();
6316
+ }
6317
+ }
6318
+ /**
6319
+ * Affinity-cache snapshots for every configured endpoint — which models each
6320
+ * host held in VRAM at the last successful `/api/ps` probe, when that was, and
6321
+ * whether it's still fresh. Empty when `endpointHealth.modelAffinity` isn't
6322
+ * configured (no probes are ever issued, so there is nothing to report).
6323
+ */
6324
+ modelAffinityStatus() {
6325
+ return this.modelAffinity?.status(this.registry.list()) ?? [];
6326
+ }
6327
+ /**
6328
+ * Explicitly refreshes the model-affinity cache for every configured endpoint
6329
+ * (deduped with any in-flight background refresh). No-op — resolves
6330
+ * immediately — when `endpointHealth.modelAffinity` isn't configured. Use it
6331
+ * to warm the cache at startup so the very first request already routes by
6332
+ * residency instead of waiting for the self-warming first-round refresh.
6333
+ */
6334
+ async warmModelAffinity() {
6335
+ await this.modelAffinity?.warm(this.registry.list());
6336
+ }
6109
6337
  endpointStatus() {
6110
6338
  return this.registry.status();
6111
6339
  }
6340
+ /**
6341
+ * Fetches one endpoint's `GET /api/ps` running-model list for the affinity
6342
+ * router. Bypasses `executeWithFailover` deliberately: probes must not
6343
+ * reenter the routing machinery they inform, must not touch the registry's
6344
+ * circuit-breaker/failure bookkeeping (a dead host's probe failing must not
6345
+ * cool it down harder than its real traffic already does), and must not fail
6346
+ * over to a different host (the answer is per-host by definition). Failures
6347
+ * propagate to the router, which records a short backoff and retries later.
6348
+ */
6349
+ async fetchEndpointRunningModels(endpoint) {
6350
+ var _stack = [];
6351
+ try {
6352
+ const http = new HttpClient({
6353
+ baseUrl: endpoint.baseUrl,
6354
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6355
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6356
+ fetch: this.fetchImpl,
6357
+ middleware: this.middleware,
6358
+ onLifecycleEvent: this.onLifecycleEvent
6359
+ });
6360
+ const controller = new AbortController();
6361
+ const timer = setTimeout(() => controller.abort(), _OllamaClient.PROBE_TIMEOUT_MS);
6362
+ const _timer = __using(_stack, disposableTimer(timer));
6363
+ const res = await http.request({
6364
+ path: "/api/ps",
6365
+ method: "GET",
6366
+ signal: controller.signal
6367
+ });
6368
+ return res.models ?? [];
6369
+ } catch (_) {
6370
+ var _error = _, _hasError = true;
6371
+ } finally {
6372
+ __callDispose(_stack, _error, _hasError);
6373
+ }
6374
+ }
6112
6375
  /**
6113
6376
  * Immediately aborts every in-flight request and active stream managed by
6114
6377
  * this client — the clean teardown path for short-lived processes, CLI
@@ -7469,6 +7732,6 @@ function estimateSystemOneRequestBytes(request) {
7469
7732
  return new TextEncoder().encode(JSON.stringify(request)).byteLength;
7470
7733
  }
7471
7734
 
7472
- export { ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_OLLAMA_AGENT_ITERATION, ATTR_OLLAMA_AGENT_MAX_ITERATIONS, ATTR_OLLAMA_ENDPOINT_ATTEMPT, ATTR_OLLAMA_ENDPOINT_NAME, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, Agent, AnthropicCompatClient, AnthropicMessagesStream, ConversationSession, DEFAULT_BACKOFF, DEFAULT_BASE_URL, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_EMBED_CONCURRENCY, DEFAULT_FAILOVER_CODES, DEFAULT_FETCH_BACKOFF_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TIMEOUT_MS, EndpointRegistry, GEN_AI_SYSTEM_OLLAMA, HttpClient, IMAGE_TOKEN_ESTIMATE, KEEP_ALIVE_INDEFINITE, KEEP_ALIVE_UNLOAD, KNOWN_DONE_REASONS, MAX_SYSTEM_ONE_IMAGES_BYTES, MAX_SYSTEM_ONE_REQUEST_BYTES, MESSAGE_OVERHEAD_TOKENS, McpBridge, ModelAffinityScheduler, ModelsClient, NANOS_PER_MS, NANOS_PER_SECOND, NOOP_LOGGER, OLLAMA_CLOUD_BASE_URL, OLLAMA_FALLBACK_CONTEXT_LENGTH, OllamaAbortError, OllamaAgentMaxIterationsError, OllamaAgentMaxToolCallsError, OllamaAgentToolLoopError, OllamaAuthError, OllamaBadGatewayError, OllamaClient, OllamaClientError, OllamaGenericClientError, OllamaIncompatibleModelError, OllamaMcpError, OllamaModelRoutingError, OllamaNetworkError, OllamaNotFoundError, OllamaOverloadedError, OllamaQuotaExceededError, OllamaRateLimitError, OllamaRequestTooLargeError, OllamaRequestValidationError, OllamaResponseValidationError, OllamaServerError, OllamaServerVersionUnknownError, OllamaSkillInvalidError, OllamaSkillNotFoundError, OllamaStream, OllamaStreamError, OllamaTimeoutError, OllamaToolTimeoutError, OllamaToolValidationError, OllamaUnsupportedCapabilityError, OpenAIChatCompletionStream, OpenAICompatClient, OpenAICompletionStream, OpenAIResponsesStream, OpenAIResponsesStreamError, QuotaManager, RETRYABLE_STATUS_CODES, ResponsesModule, ToolCallAccumulator, ToolRegistry, addSpanEvent, applySkill, batchEmbed, calculateBackoff, canonicalToolCallSignature, checkChatContext, checkEndpointHealth, checkGenerateContext, compactConversationHistory, composeMiddleware, contextWarningMessage, createConsoleLogger, createDecision, createModelAffinityScheduler, createOllamaCloudFreeTierQuota, createTimeoutSignal, defineTool, detectModelCapabilities, embedBatchOverflowMessage, encodeImage, estimateChatRequestTokens, estimateGenerateRequestTokens, estimateMessageTokens, estimateSystemOneRequestBytes, estimateTokens, extractContextLength as extractNativeContextLength, extractParameterNumCtx, extractUsage, fetchWithBackoff, findOversizedEmbedInputs, findRunningModelContextLength, imageStringNeedsResolution, inferRuntimeMode, isKeepAliveSugar, isKnownDoneReason, isSameToolCall, listAllMcpTools, listAvailableModels, loadMcpTools, mapError, mergeToolCall, mergeToolCallArgumentsString, mergeToolCallArrays, normalizeChatStream, normalizeGenerateStream, normalizeKeepAlive, normalizeProgressStream, parseFrontmatter, parseNdjsonStream, parseSseStream, parseStructuredOutput, registerMcpTools, resolveContextLength, resolveCredentialEndpoints, resolveImageInput, sanitizeHistoryForNextTurn, setSpanError, toDataStream, toResponse, toTextStream, withRetry, withSpan, zodToJsonSchema };
7735
+ export { ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_USAGE_INPUT_TOKENS, ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_OLLAMA_AGENT_ITERATION, ATTR_OLLAMA_AGENT_MAX_ITERATIONS, ATTR_OLLAMA_ENDPOINT_ATTEMPT, ATTR_OLLAMA_ENDPOINT_NAME, ATTR_SERVER_ADDRESS, ATTR_SERVER_PORT, ATTR_URL_FULL, Agent, AnthropicCompatClient, AnthropicMessagesStream, ConversationSession, DEFAULT_BACKOFF, DEFAULT_BASE_URL, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_EMBED_BATCH_SIZE, DEFAULT_EMBED_CONCURRENCY, DEFAULT_FAILOVER_CODES, DEFAULT_FETCH_BACKOFF_CONFIG, DEFAULT_RETRY_CONFIG, DEFAULT_TIMEOUT_MS, EndpointRegistry, GEN_AI_SYSTEM_OLLAMA, HttpClient, IMAGE_TOKEN_ESTIMATE, KEEP_ALIVE_INDEFINITE, KEEP_ALIVE_UNLOAD, KNOWN_DONE_REASONS, MAX_SYSTEM_ONE_IMAGES_BYTES, MAX_SYSTEM_ONE_REQUEST_BYTES, MESSAGE_OVERHEAD_TOKENS, McpBridge, ModelAffinityRouter, ModelAffinityScheduler, ModelsClient, NANOS_PER_MS, NANOS_PER_SECOND, NOOP_LOGGER, OLLAMA_CLOUD_BASE_URL, OLLAMA_FALLBACK_CONTEXT_LENGTH, OllamaAbortError, OllamaAgentMaxIterationsError, OllamaAgentMaxToolCallsError, OllamaAgentToolLoopError, OllamaAuthError, OllamaBadGatewayError, OllamaClient, OllamaClientError, OllamaGenericClientError, OllamaIncompatibleModelError, OllamaMcpError, OllamaModelRoutingError, OllamaNetworkError, OllamaNotFoundError, OllamaOverloadedError, OllamaQuotaExceededError, OllamaRateLimitError, OllamaRequestTooLargeError, OllamaRequestValidationError, OllamaResponseValidationError, OllamaServerError, OllamaServerVersionUnknownError, OllamaSkillInvalidError, OllamaSkillNotFoundError, OllamaStream, OllamaStreamError, OllamaTimeoutError, OllamaToolTimeoutError, OllamaToolValidationError, OllamaUnsupportedCapabilityError, OpenAIChatCompletionStream, OpenAICompatClient, OpenAICompletionStream, OpenAIResponsesStream, OpenAIResponsesStreamError, QuotaManager, RETRYABLE_STATUS_CODES, ResponsesModule, ToolCallAccumulator, ToolRegistry, addSpanEvent, applySkill, batchEmbed, calculateBackoff, canonicalToolCallSignature, checkChatContext, checkEndpointHealth, checkGenerateContext, compactConversationHistory, composeMiddleware, contextWarningMessage, createConsoleLogger, createDecision, createModelAffinityScheduler, createOllamaCloudFreeTierQuota, createTimeoutSignal, defineTool, detectModelCapabilities, embedBatchOverflowMessage, encodeImage, estimateChatRequestTokens, estimateGenerateRequestTokens, estimateMessageTokens, estimateSystemOneRequestBytes, estimateTokens, extractContextLength as extractNativeContextLength, extractParameterNumCtx, extractUsage, fetchWithBackoff, findOversizedEmbedInputs, findRunningModelContextLength, formatTelemetry, imageStringNeedsResolution, inferRuntimeMode, isKeepAliveSugar, isKnownDoneReason, isModelResident, isSameToolCall, listAllMcpTools, listAvailableModels, loadMcpTools, mapError, mergeToolCall, mergeToolCallArgumentsString, mergeToolCallArrays, normalizeChatStream, normalizeGenerateStream, normalizeKeepAlive, normalizeProgressStream, parseFrontmatter, parseNdjsonStream, parseSseStream, parseStructuredOutput, registerMcpTools, resolveContextLength, resolveCredentialEndpoints, resolveImageInput, sanitizeHistoryForNextTurn, setSpanError, toDataStream, toResponse, toTextStream, withRetry, withSpan, zodToJsonSchema };
7473
7736
  //# sourceMappingURL=index.js.map
7474
7737
  //# sourceMappingURL=index.js.map