@nemesis-oss/ollama-sdk 1.10.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.js CHANGED
@@ -339,6 +339,23 @@ var OllamaAgentMaxIterationsError = class extends OllamaClientError {
339
339
  this.maxIterations = options.maxIterations;
340
340
  }
341
341
  };
342
+ var OllamaAgentToolLoopError = class extends OllamaClientError {
343
+ /** Name of the tool whose identical call would exceed the repeat budget. */
344
+ toolName;
345
+ /** How many times this exact call would have executed including the rejected one. */
346
+ repeatedExecutions;
347
+ /** The configured per-signature execution budget. */
348
+ maxRepeatedToolCalls;
349
+ /** Canonical `name(args)` signature of the repeated call (very large payloads truncated). */
350
+ signature;
351
+ constructor(message, options) {
352
+ super(message, { ...options, code: "agent_tool_loop_detected", retryable: false });
353
+ this.toolName = options.toolName;
354
+ this.repeatedExecutions = options.repeatedExecutions;
355
+ this.maxRepeatedToolCalls = options.maxRepeatedToolCalls;
356
+ this.signature = options.signature;
357
+ }
358
+ };
342
359
  var OllamaMcpError = class extends OllamaClientError {
343
360
  mcpMethod;
344
361
  toolName;
@@ -638,6 +655,15 @@ var EndpointRegistry = class {
638
655
  }
639
656
  };
640
657
 
658
+ // src/transport/url.ts
659
+ function joinUrlPath(baseUrl, path) {
660
+ const base = baseUrl.replace(/\/+$/, "");
661
+ const tail = path.replace(/^\/+/, "");
662
+ if (tail === "") return base;
663
+ if (base === "") return `/${tail}`;
664
+ return `${base}/${tail}`;
665
+ }
666
+
641
667
  // src/middleware.ts
642
668
  function composeMiddleware(middlewares, finalHandler) {
643
669
  return function runPipeline(initialRequest) {
@@ -1009,6 +1035,27 @@ var ATTR_OLLAMA_AGENT_MAX_ITERATIONS = "ollama.agent.max_iterations";
1009
1035
  var ATTR_OLLAMA_AGENT_ITERATION = "ollama.agent.iteration";
1010
1036
  var GEN_AI_SYSTEM_OLLAMA = "ollama";
1011
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
+
1012
1059
  // src/transport/http.ts
1013
1060
  function routeTemplate(path) {
1014
1061
  return path.replace(/^\/api\/blobs\/.+$/, "/api/blobs/{digest}");
@@ -1190,7 +1237,7 @@ var HttpClient = class {
1190
1237
  }
1191
1238
  }
1192
1239
  async request(options) {
1193
- const url = `${this.baseUrl}${options.path}`;
1240
+ const url = joinUrlPath(this.baseUrl, options.path);
1194
1241
  const method = options.method ?? (options.body !== void 0 || options.rawBody !== void 0 ? "POST" : "GET");
1195
1242
  const headers = this.buildHeaders(options.headers);
1196
1243
  if (options.rawBody !== void 0 && !options.headers?.["Content-Type"]) {
@@ -1250,7 +1297,7 @@ var HttpClient = class {
1250
1297
  * can decode their provider-specific event payloads without duplicating transport logic.
1251
1298
  */
1252
1299
  async requestSseStream(options) {
1253
- const url = `${this.baseUrl}${options.path}`;
1300
+ const url = joinUrlPath(this.baseUrl, options.path);
1254
1301
  const method = options.method ?? "POST";
1255
1302
  const headers = {
1256
1303
  ...this.buildHeaders(options.headers),
@@ -1317,7 +1364,7 @@ var HttpClient = class {
1317
1364
  );
1318
1365
  }
1319
1366
  async requestStream(options) {
1320
- const url = `${this.baseUrl}${options.path}`;
1367
+ const url = joinUrlPath(this.baseUrl, options.path);
1321
1368
  const method = options.method ?? "POST";
1322
1369
  const headers = this.buildHeaders(options.headers);
1323
1370
  return withSpan(
@@ -1546,6 +1593,256 @@ async function listAvailableModels(http) {
1546
1593
  });
1547
1594
  return [...res.models];
1548
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 endpoints whose fresh `/api/ps`
1767
+ * snapshot shows `model` resident come first (relative order preserved within
1768
+ * each group). Returns the input unchanged — and kicks a background refresh —
1769
+ * when any candidate's snapshot is missing or stale: a wrong reorder is worse
1770
+ * than no reorder, and the first request after a cache miss simply isn't
1771
+ * reordered. No-op without a `model` or with fewer than two candidates.
1772
+ */
1773
+ reorder(candidates, model) {
1774
+ if (model === void 0 || candidates.length < 2) return candidates;
1775
+ const currentTime = this.now();
1776
+ let allFresh = true;
1777
+ for (const endpoint of candidates) {
1778
+ const entry = this.cache.get(endpoint.name);
1779
+ if (entry === void 0 || currentTime - entry.fetchedAt > this.ttlMs) {
1780
+ allFresh = false;
1781
+ this.kickRefresh(endpoint);
1782
+ }
1783
+ }
1784
+ if (!allFresh) return candidates;
1785
+ const resident = candidates.filter((ep) => {
1786
+ const entry = this.cache.get(ep.name);
1787
+ return entry !== void 0 && isModelResident(entry.models, model);
1788
+ });
1789
+ if (resident.length === 0 || resident.length === candidates.length) return candidates;
1790
+ const residentSet = new Set(resident.map((ep) => ep.name));
1791
+ return [...resident, ...candidates.filter((ep) => !residentSet.has(ep.name))];
1792
+ }
1793
+ /**
1794
+ * Forces a refresh of the affinity cache for the given endpoints (deduped with
1795
+ * any in-flight refresh; bypasses the failure backoff, because this is an
1796
+ * explicit caller request — `client.warmModelAffinity()`). Resolves once every
1797
+ * probe has settled — successfully or not; individual failures never reject.
1798
+ */
1799
+ async warm(endpoints) {
1800
+ await Promise.all(endpoints.map((endpoint) => this.refresh(endpoint)));
1801
+ }
1802
+ /** Introspection snapshots for the given endpoints (defaults to cached ones). */
1803
+ status(endpoints) {
1804
+ const names = endpoints !== void 0 ? endpoints.map((ep) => ep.name) : [...this.cache.keys(), ...this.failedAt.keys()];
1805
+ const byName = new Map((endpoints ?? []).map((ep) => [ep.name, ep]));
1806
+ const currentTime = this.now();
1807
+ return names.filter((name, i, arr) => arr.indexOf(name) === i).map((name) => {
1808
+ const entry = this.cache.get(name);
1809
+ return {
1810
+ endpointName: name,
1811
+ baseUrl: byName.get(name)?.baseUrl ?? "",
1812
+ loadedModels: entry?.models.map((m) => m.name ?? m.model ?? "").filter((n) => n !== "") ?? [],
1813
+ fetchedAt: entry?.fetchedAt,
1814
+ fresh: entry !== void 0 && currentTime - entry.fetchedAt <= this.ttlMs
1815
+ };
1816
+ });
1817
+ }
1818
+ /**
1819
+ * Starts (or joins) a background refresh for `endpoint`, unless one is already
1820
+ * in flight or the failure backoff suppresses it. Fire-and-forget: the promise
1821
+ * is stored only for dedupe/warm-joins; callers never await it on the request
1822
+ * path.
1823
+ */
1824
+ kickRefresh(endpoint) {
1825
+ if (this.inflight.has(endpoint.name)) return;
1826
+ const lastFailure = this.failedAt.get(endpoint.name);
1827
+ if (lastFailure !== void 0 && this.now() - lastFailure < this.failureRetryMs) return;
1828
+ void this.refresh(endpoint);
1829
+ }
1830
+ /** Refreshes one endpoint's snapshot; never rejects (failures record a backoff). */
1831
+ refresh(endpoint) {
1832
+ const existing = this.inflight.get(endpoint.name);
1833
+ if (existing !== void 0) return existing;
1834
+ const attempt = this.fetchPs(endpoint).then((models) => {
1835
+ this.cache.set(endpoint.name, { models, fetchedAt: this.now() });
1836
+ this.failedAt.delete(endpoint.name);
1837
+ }).catch(() => {
1838
+ this.failedAt.set(endpoint.name, this.now());
1839
+ }).finally(() => {
1840
+ this.inflight.delete(endpoint.name);
1841
+ });
1842
+ this.inflight.set(endpoint.name, attempt);
1843
+ return attempt;
1844
+ }
1845
+ };
1549
1846
  function extractJsonSubstring(text) {
1550
1847
  const trimmed = text.trim();
1551
1848
  if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
@@ -2271,9 +2568,66 @@ var ModelsClient = class {
2271
2568
  { ...request, singleEndpoint: true }
2272
2569
  );
2273
2570
  }
2274
- ps() {
2275
- return this.runner((http) => http.request({ path: "/api/ps", method: "GET" }), {
2276
- singleEndpoint: true
2571
+ ps(request) {
2572
+ return this.runner(
2573
+ (http, signal) => http.request({ path: "/api/ps", method: "GET", signal }),
2574
+ {
2575
+ singleEndpoint: true,
2576
+ ...request?.signal !== void 0 ? { signal: request.signal } : {},
2577
+ ...request?.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
2578
+ }
2579
+ );
2580
+ }
2581
+ /**
2582
+ * Discovers the model's real context window from the server — no more
2583
+ * 2048/4096 guesswork. Consults, in precedence order:
2584
+ *
2585
+ * 1. `GET /api/ps` — the window the **running instance actually
2586
+ * allocated** (exact, reflects the Modelfile `num_ctx` default and
2587
+ * what fit in memory); skipped when the model isn't loaded, when
2588
+ * `skipRunningCheck` is set, or harmlessly on servers without the
2589
+ * field.
2590
+ * 2. `POST /api/show` `parameters` — a Modelfile-authored
2591
+ * `num_ctx <n>` default, i.e. the window a cold load will allocate.
2592
+ * 3. `POST /api/show` `model_info["<arch>.context_length"]` — the
2593
+ * model's **native GGUF maximum** (a ceiling to raise `num_ctx`
2594
+ * toward, not the allocated window).
2595
+ * 4. Fallback — Ollama's conservative unset-`num_ctx` default (2048).
2596
+ *
2597
+ * The result carries every signal it found, so `nativeContextLength:
2598
+ * 131072` vs `contextLength: 4096` (a 32× gap seen in the official API
2599
+ * examples) is visible rather than guessed. Typical wiring:
2600
+ *
2601
+ * ```ts
2602
+ * const { contextLength, source, nativeContextLength } =
2603
+ * await client.models.getContextLength({ model: 'gemma4' });
2604
+ * // -> { contextLength: 4096, source: 'running', nativeContextLength: 131072 }
2605
+ * ```
2606
+ *
2607
+ * Makes two server calls (`/api/ps` + `/api/show`) unless
2608
+ * `skipRunningCheck` is set. A 404 from `/api/ps` (endpoint without the
2609
+ * listing) is treated as "not running" and never fails the lookup;
2610
+ * other transport errors propagate normally.
2611
+ */
2612
+ async getContextLength(request) {
2613
+ let running;
2614
+ if (request.skipRunningCheck !== true) {
2615
+ try {
2616
+ const ps = await this.ps({ signal: request.signal, timeoutMs: request.timeoutMs });
2617
+ running = findRunningModelContextLength(ps.models, request.model);
2618
+ } catch (err) {
2619
+ if (!(err instanceof OllamaNotFoundError)) throw err;
2620
+ }
2621
+ }
2622
+ const showRes = await this.show({
2623
+ model: request.model,
2624
+ signal: request.signal,
2625
+ timeoutMs: request.timeoutMs
2626
+ });
2627
+ return resolveContextLength({
2628
+ running,
2629
+ parameter: extractParameterNumCtx(showRes.parameters),
2630
+ native: extractContextLength(showRes.model_info)
2277
2631
  });
2278
2632
  }
2279
2633
  version() {
@@ -4271,6 +4625,52 @@ var ResponsesModule = class {
4271
4625
  };
4272
4626
 
4273
4627
  // src/conversation.ts
4628
+ function compactConversationHistory(messages, options) {
4629
+ const reserve = Math.max(0, options.reserveForReply ?? 0);
4630
+ const minTail = Math.max(0, options.minTailMessages ?? 2);
4631
+ const budget = Math.max(0, options.maxEstimatedTokens - reserve);
4632
+ let split = 0;
4633
+ while (split < messages.length && messages[split].role === "system") split += 1;
4634
+ const pinned = messages.slice(0, split);
4635
+ const tail = messages.slice(split);
4636
+ let used = 0;
4637
+ for (const message of pinned) used += estimateMessageTokens(message);
4638
+ const retained = [];
4639
+ for (let i = tail.length - 1; i >= 0; i -= 1) {
4640
+ const message = tail[i];
4641
+ const cost = estimateMessageTokens(message);
4642
+ const fits = used + cost <= budget;
4643
+ if (!fits && retained.length >= minTail) break;
4644
+ retained.unshift(message);
4645
+ used += cost;
4646
+ }
4647
+ return [...pinned, ...retained];
4648
+ }
4649
+ function sanitizeHistoryForNextTurn(messages, options = {}) {
4650
+ const keep = Math.max(0, options.keepImagesOnLastMessages ?? 1);
4651
+ const placeholder = options.imagePlaceholder;
4652
+ const cutoff = messages.length - keep;
4653
+ const sanitized = [];
4654
+ for (let index = 0; index < messages.length; index += 1) {
4655
+ const message = messages[index];
4656
+ if (index >= cutoff || !message.images || message.images.length === 0) {
4657
+ sanitized.push(message);
4658
+ continue;
4659
+ }
4660
+ const { images: _evicted, ...rest } = message;
4661
+ if (placeholder === void 0) {
4662
+ sanitized.push(rest);
4663
+ } else {
4664
+ sanitized.push({
4665
+ ...rest,
4666
+ content: rest.content === "" ? placeholder : `${rest.content}
4667
+
4668
+ ${placeholder}`
4669
+ });
4670
+ }
4671
+ }
4672
+ return sanitized;
4673
+ }
4274
4674
  function extractTurnStats(res) {
4275
4675
  const cachedTokens = res.prompt_eval_cached_count ?? 0;
4276
4676
  const evaluatedTokens = res.prompt_eval_count ?? 0;
@@ -4357,6 +4757,48 @@ var ConversationSession = class {
4357
4757
  getMessages() {
4358
4758
  return this.history.map((message) => message);
4359
4759
  }
4760
+ /**
4761
+ * Compacts the session's history in place: pinned system prompt plus the
4762
+ * most recent turns that fit the budget, oldest tailable turns dropped.
4763
+ *
4764
+ * This is the explicit escape hatch for sessions that outgrew their
4765
+ * window — `cacheStats.hitRate` collapsing toward zero while history grows
4766
+ * is the usual symptom, and the pre-flight warnings on `chat()` fire once
4767
+ * the prompt estimate crosses the threshold. After compaction the next
4768
+ * turn's prompt prefix differs from everything before it, so the KV cache
4769
+ * starts cold — visible as a one-turn `evaluatedTokens` spike in
4770
+ * `cacheStats`. Cumulative cache tallies are deliberately **not** reset:
4771
+ * they are observations, and the spike is part of the story.
4772
+ *
4773
+ * Returns the before/after estimates and the drop count. No-op (zero
4774
+ * drops, equal before/after estimates) when the history already fits.
4775
+ */
4776
+ compact(options = {}) {
4777
+ const window = options.maxEstimatedTokens ?? this.config.options?.num_ctx ?? OLLAMA_FALLBACK_CONTEXT_LENGTH;
4778
+ const reserve = options.reserveForReply ?? Math.ceil(window * 0.1);
4779
+ const estimatedTokensBefore = this.history.reduce(
4780
+ (sum, message) => sum + estimateMessageTokens(message),
4781
+ 0
4782
+ );
4783
+ const compacted = compactConversationHistory(this.history, {
4784
+ maxEstimatedTokens: window,
4785
+ reserveForReply: reserve,
4786
+ ...options.minTailMessages !== void 0 ? { minTailMessages: options.minTailMessages } : {}
4787
+ });
4788
+ const estimatedTokensAfter = compacted.reduce(
4789
+ (sum, message) => sum + estimateMessageTokens(message),
4790
+ 0
4791
+ );
4792
+ const droppedMessages = this.history.length - compacted.length;
4793
+ this.history.length = 0;
4794
+ this.history.push(...compacted);
4795
+ return {
4796
+ droppedMessages,
4797
+ estimatedTokensBefore,
4798
+ estimatedTokensAfter,
4799
+ effectiveBudget: window - reserve
4800
+ };
4801
+ }
4360
4802
  /**
4361
4803
  * Clears the conversation back to its initial state (system prompt, zero
4362
4804
  * turns, zeroed cache statistics). The next turn starts with a cold cache
@@ -4375,103 +4817,6 @@ var ConversationSession = class {
4375
4817
  }
4376
4818
  };
4377
4819
 
4378
- // src/context-safety.ts
4379
- var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
4380
- var IMAGE_TOKEN_ESTIMATE = 300;
4381
- var CHARS_PER_TOKEN = 4;
4382
- function isCjkCodePoint(code) {
4383
- return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
4384
- code >= 13312 && code <= 19903 || // CJK Extension A
4385
- code >= 12352 && code <= 12543 || // Hiragana + Katakana
4386
- code >= 44032 && code <= 55215 || // Hangul syllables
4387
- code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
4388
- code >= 65280 && code <= 65519;
4389
- }
4390
- function estimateTokens(text) {
4391
- if (text.length === 0) return 0;
4392
- let cjk = 0;
4393
- let other = 0;
4394
- for (const char of text) {
4395
- if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
4396
- else other += 1;
4397
- }
4398
- return Math.ceil(cjk + other / CHARS_PER_TOKEN);
4399
- }
4400
- var MESSAGE_OVERHEAD_TOKENS = 4;
4401
- function estimateMessageTokens(message) {
4402
- let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
4403
- if (message.thinking !== void 0) total += estimateTokens(message.thinking);
4404
- if (message.tool_calls !== void 0) {
4405
- for (const call of message.tool_calls) {
4406
- total += estimateTokens(call.function.name);
4407
- total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
4408
- }
4409
- }
4410
- if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
4411
- return total;
4412
- }
4413
- function estimateChatRequestTokens(req) {
4414
- let total = 0;
4415
- for (const message of req.messages) total += estimateMessageTokens(message);
4416
- if (req.tools !== void 0) {
4417
- for (const tool of req.tools) {
4418
- total += estimateTokens(tool.function.description ?? "");
4419
- total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
4420
- }
4421
- }
4422
- return total;
4423
- }
4424
- function estimateGenerateRequestTokens(req) {
4425
- let total = estimateTokens(req.prompt ?? "");
4426
- if (req.system !== void 0) total += estimateTokens(req.system);
4427
- if (req.template !== void 0) total += estimateTokens(req.template);
4428
- if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
4429
- return total;
4430
- }
4431
- function checkChatContext(req, options = {}) {
4432
- return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
4433
- }
4434
- function checkGenerateContext(req, options = {}) {
4435
- return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
4436
- }
4437
- function check(estimatedPromptTokens, requestNumCtx, options) {
4438
- const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
4439
- if (requestNumCtx !== void 0) {
4440
- return {
4441
- estimatedPromptTokens,
4442
- effectiveContextLength: requestNumCtx,
4443
- windowSource: "request",
4444
- exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
4445
- };
4446
- }
4447
- if (options.defaultContextLength !== void 0) {
4448
- return {
4449
- estimatedPromptTokens,
4450
- effectiveContextLength: options.defaultContextLength,
4451
- windowSource: "client-default",
4452
- exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
4453
- };
4454
- }
4455
- return {
4456
- estimatedPromptTokens,
4457
- effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
4458
- windowSource: "server-fallback",
4459
- exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
4460
- };
4461
- }
4462
- var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
4463
- function contextWarningMessage(check2, kind) {
4464
- const where = `OllamaClient.${kind}()`;
4465
- const estimate = `~${check2.estimatedPromptTokens} tokens`;
4466
- if (check2.windowSource === "request") {
4467
- 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\`.`;
4468
- }
4469
- if (check2.windowSource === "client-default") {
4470
- 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\`.`;
4471
- }
4472
- 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\`.`;
4473
- }
4474
-
4475
4820
  // src/embed-batch.ts
4476
4821
  var DEFAULT_EMBED_BATCH_SIZE = 32;
4477
4822
  var DEFAULT_EMBED_CONCURRENCY = 3;
@@ -5096,7 +5441,7 @@ function createLogicalRequestId() {
5096
5441
  logicalRequestSequence += 1;
5097
5442
  return `ollama-request-${logicalRequestSequence}`;
5098
5443
  }
5099
- var OllamaClient = class {
5444
+ var OllamaClient = class _OllamaClient {
5100
5445
  registry;
5101
5446
  models;
5102
5447
  retryConfig;
@@ -5128,6 +5473,16 @@ var OllamaClient = class {
5128
5473
  * (streams, via `executeWithFailover`'s `holdUntil` release path).
5129
5474
  */
5130
5475
  activeRequests = /* @__PURE__ */ new Set();
5476
+ /**
5477
+ * Dynamic multi-host model-affinity routing, active only when
5478
+ * `config.endpointHealth.modelAffinity` is set — see
5479
+ * `src/providers/model-affinity-router.ts` and ADR 0028. `undefined` means
5480
+ * the request path skips reordering entirely (zero overhead, and no `/api/ps`
5481
+ * probes are ever issued).
5482
+ */
5483
+ modelAffinity;
5484
+ /** Default timeout for one-off probe requests (`ping`, affinity `/api/ps` refreshes). */
5485
+ static PROBE_TIMEOUT_MS = 5e3;
5131
5486
  constructor(config = {}) {
5132
5487
  this.cloudApiKey = resolveApiKey(config.apiKey);
5133
5488
  const defaultBaseUrl = resolveBaseUrl(config.baseUrl);
@@ -5155,6 +5510,11 @@ var OllamaClient = class {
5155
5510
  this.defaultContextLength = config.defaultContextLength;
5156
5511
  this.contextWarningThreshold = config.contextWarningThreshold;
5157
5512
  this.onContextOverflow = config.onContextOverflow ?? "warn";
5513
+ const modelAffinityOptions = config.endpointHealth?.modelAffinity;
5514
+ this.modelAffinity = modelAffinityOptions !== void 0 ? new ModelAffinityRouter(
5515
+ (endpoint) => this.fetchEndpointRunningModels(endpoint),
5516
+ modelAffinityOptions
5517
+ ) : void 0;
5158
5518
  this.models = new ModelsClient((op, opts) => this.executeWithFailover(op, opts));
5159
5519
  }
5160
5520
  get modelsClient() {
@@ -5365,7 +5725,8 @@ var OllamaClient = class {
5365
5725
  );
5366
5726
  continue;
5367
5727
  }
5368
- for (const [attemptIndex, endpoint] of runnable.entries()) {
5728
+ const ordered = this.modelAffinity !== void 0 ? this.modelAffinity.reorder(runnable, options?.model) : runnable;
5729
+ for (const [attemptIndex, endpoint] of ordered.entries()) {
5369
5730
  this.logger.debug(`Executing on endpoint "${endpoint.name}" (${endpoint.baseUrl})`);
5370
5731
  const http = new HttpClient({
5371
5732
  baseUrl: endpoint.baseUrl,
@@ -5666,7 +6027,15 @@ var OllamaClient = class {
5666
6027
  createModel = (r) => this.models.create(r);
5667
6028
  deleteModel = (req) => this.models.delete(req);
5668
6029
  copyModel = (req) => this.models.copy(req);
5669
- ps = () => this.models.ps();
6030
+ ps = (request) => this.models.ps(request);
6031
+ /**
6032
+ * Discovers the model's real context window — the allocated window of the
6033
+ * running instance (`/api/ps`), the Modelfile `num_ctx` default, and the
6034
+ * native GGUF maximum (`/api/show` `model_info`), resolved by precedence.
6035
+ * Convenience alias for {@link ModelsClient.getContextLength} — see that
6036
+ * method for the full contract and the precedence order.
6037
+ */
6038
+ getContextLength = (request) => this.models.getContextLength(request);
5670
6039
  version = () => this.models.version();
5671
6040
  createBlob = (digest, data) => this.models.createBlob(digest, data);
5672
6041
  checkBlob = (digest) => this.models.checkBlob(digest);
@@ -5879,9 +6248,114 @@ var OllamaClient = class {
5879
6248
  )
5880
6249
  );
5881
6250
  }
6251
+ /**
6252
+ * Ultra-lightweight liveness probe against the client's current best
6253
+ * endpoint: `HEAD /` — the root ping Ollama's documentation defines for
6254
+ * load-balancer health checks (HTTP 200; the daemon answers `GET /` with the
6255
+ * plain-text body "Ollama is running", which a HEAD request doesn't transfer).
6256
+ *
6257
+ * Deliberately a *single-endpoint, single-shot* probe — no failover to other
6258
+ * candidates, no retry — so the answer reflects one concrete host, not "some
6259
+ * endpoint eventually answered". To audit every configured endpoint, use
6260
+ * {@link healthCheck} (which probes all of them, and also reports the server
6261
+ * version at the cost of the heavier `GET /api/version` round trip).
6262
+ *
6263
+ * Resolves — never throws — with `{ healthy: false, error }` for connection
6264
+ * failures and timeouts; only caller-initiated cancellation (`signal`/
6265
+ * `destroy()`) rejects, mirroring how any other aborted request behaves.
6266
+ */
6267
+ async ping(options) {
6268
+ const endpoint = this.registry.candidates()[0];
6269
+ if (endpoint === void 0) {
6270
+ return { healthy: false, latencyMs: 0, baseUrl: "", error: "No configured endpoints" };
6271
+ }
6272
+ const http = new HttpClient({
6273
+ baseUrl: endpoint.baseUrl,
6274
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6275
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6276
+ fetch: this.fetchImpl,
6277
+ middleware: this.middleware,
6278
+ onLifecycleEvent: this.onLifecycleEvent
6279
+ });
6280
+ const timeout = createTimeoutSignal(
6281
+ options?.timeoutMs ?? _OllamaClient.PROBE_TIMEOUT_MS,
6282
+ options?.signal
6283
+ );
6284
+ const startTime = Date.now();
6285
+ try {
6286
+ await http.request({ path: "/", method: "HEAD", signal: timeout.signal });
6287
+ return { healthy: true, latencyMs: Date.now() - startTime, baseUrl: endpoint.baseUrl };
6288
+ } catch (err) {
6289
+ if (options?.signal?.aborted) {
6290
+ throw err instanceof Error ? err : new Error(String(err));
6291
+ }
6292
+ return {
6293
+ healthy: false,
6294
+ latencyMs: Date.now() - startTime,
6295
+ baseUrl: endpoint.baseUrl,
6296
+ error: err instanceof Error ? err.message : String(err)
6297
+ };
6298
+ } finally {
6299
+ timeout.cancel();
6300
+ }
6301
+ }
6302
+ /**
6303
+ * Affinity-cache snapshots for every configured endpoint — which models each
6304
+ * host held in VRAM at the last successful `/api/ps` probe, when that was, and
6305
+ * whether it's still fresh. Empty when `endpointHealth.modelAffinity` isn't
6306
+ * configured (no probes are ever issued, so there is nothing to report).
6307
+ */
6308
+ modelAffinityStatus() {
6309
+ return this.modelAffinity?.status(this.registry.list()) ?? [];
6310
+ }
6311
+ /**
6312
+ * Explicitly refreshes the model-affinity cache for every configured endpoint
6313
+ * (deduped with any in-flight background refresh). No-op — resolves
6314
+ * immediately — when `endpointHealth.modelAffinity` isn't configured. Use it
6315
+ * to warm the cache at startup so the very first request already routes by
6316
+ * residency instead of waiting for the self-warming first-round refresh.
6317
+ */
6318
+ async warmModelAffinity() {
6319
+ await this.modelAffinity?.warm(this.registry.list());
6320
+ }
5882
6321
  endpointStatus() {
5883
6322
  return this.registry.status();
5884
6323
  }
6324
+ /**
6325
+ * Fetches one endpoint's `GET /api/ps` running-model list for the affinity
6326
+ * router. Bypasses `executeWithFailover` deliberately: probes must not
6327
+ * reenter the routing machinery they inform, must not touch the registry's
6328
+ * circuit-breaker/failure bookkeeping (a dead host's probe failing must not
6329
+ * cool it down harder than its real traffic already does), and must not fail
6330
+ * over to a different host (the answer is per-host by definition). Failures
6331
+ * propagate to the router, which records a short backoff and retries later.
6332
+ */
6333
+ async fetchEndpointRunningModels(endpoint) {
6334
+ var _stack = [];
6335
+ try {
6336
+ const http = new HttpClient({
6337
+ baseUrl: endpoint.baseUrl,
6338
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
6339
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
6340
+ fetch: this.fetchImpl,
6341
+ middleware: this.middleware,
6342
+ onLifecycleEvent: this.onLifecycleEvent
6343
+ });
6344
+ const controller = new AbortController();
6345
+ const timer = setTimeout(() => controller.abort(), _OllamaClient.PROBE_TIMEOUT_MS);
6346
+ const _timer = __using(_stack, disposableTimer(timer));
6347
+ const res = await http.request({
6348
+ path: "/api/ps",
6349
+ method: "GET",
6350
+ signal: controller.signal
6351
+ });
6352
+ return res.models ?? [];
6353
+ } catch (_) {
6354
+ var _error = _, _hasError = true;
6355
+ } finally {
6356
+ __callDispose(_stack, _error, _hasError);
6357
+ }
6358
+ }
5885
6359
  /**
5886
6360
  * Immediately aborts every in-flight request and active stream managed by
5887
6361
  * this client — the clean teardown path for short-lived processes, CLI
@@ -6914,11 +7388,29 @@ ${systemPrompt}`
6914
7388
  }
6915
7389
 
6916
7390
  // src/agent/agent.ts
7391
+ var SIGNATURE_REPORT_LIMIT = 200;
7392
+ function stableStringify(value) {
7393
+ if (value === null || typeof value !== "object") {
7394
+ return JSON.stringify(value) ?? "null";
7395
+ }
7396
+ if (Array.isArray(value)) {
7397
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
7398
+ }
7399
+ const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
7400
+ return `{${entries.join(",")}}`;
7401
+ }
7402
+ function canonicalToolCallSignature(toolCall) {
7403
+ return `${toolCall.function.name}(${stableStringify(toolCall.function.arguments)})`;
7404
+ }
7405
+ function reportableSignature(signature) {
7406
+ return signature.length <= SIGNATURE_REPORT_LIMIT ? signature : `${signature.slice(0, SIGNATURE_REPORT_LIMIT)}\u2026`;
7407
+ }
6917
7408
  var Agent = class {
6918
7409
  client;
6919
7410
  tools;
6920
7411
  maxIterations;
6921
7412
  maxToolCalls;
7413
+ maxRepeatedToolCalls;
6922
7414
  hooks;
6923
7415
  validateToolCapability;
6924
7416
  toolContextSize;
@@ -6927,10 +7419,14 @@ var Agent = class {
6927
7419
  this.tools = config.tools;
6928
7420
  this.maxIterations = config.maxIterations ?? 10;
6929
7421
  this.maxToolCalls = config.maxToolCalls;
7422
+ this.maxRepeatedToolCalls = config.maxRepeatedToolCalls;
6930
7423
  this.hooks = config.hooks;
6931
7424
  if (this.maxToolCalls !== void 0 && (!Number.isInteger(this.maxToolCalls) || this.maxToolCalls < 0)) {
6932
7425
  throw new RangeError("Agent maxToolCalls must be a non-negative integer");
6933
7426
  }
7427
+ if (this.maxRepeatedToolCalls !== void 0 && (!Number.isInteger(this.maxRepeatedToolCalls) || this.maxRepeatedToolCalls < 1)) {
7428
+ throw new RangeError("Agent maxRepeatedToolCalls must be a positive integer");
7429
+ }
6934
7430
  this.validateToolCapability = config.validateToolCapability ?? true;
6935
7431
  this.toolContextSize = config.toolContextSize ?? 32768;
6936
7432
  if (!Number.isInteger(this.toolContextSize) || this.toolContextSize <= 0) {
@@ -6977,6 +7473,7 @@ var Agent = class {
6977
7473
  )
6978
7474
  } : input.options;
6979
7475
  let toolCallsExecuted = 0;
7476
+ const signatureCounts = /* @__PURE__ */ new Map();
6980
7477
  for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
6981
7478
  const outcome = await withSpan(
6982
7479
  "ollama.agent.turn",
@@ -7008,6 +7505,24 @@ var Agent = class {
7008
7505
  { maxToolCalls: this.maxToolCalls, toolCallsExecuted }
7009
7506
  );
7010
7507
  }
7508
+ if (this.maxRepeatedToolCalls !== void 0) {
7509
+ for (const tc of toolCalls) {
7510
+ const signature = canonicalToolCallSignature(tc);
7511
+ const executions = (signatureCounts.get(signature) ?? 0) + 1;
7512
+ if (executions > this.maxRepeatedToolCalls) {
7513
+ throw new OllamaAgentToolLoopError(
7514
+ `Agent would repeat tool call "${tc.function.name}" ${executions} times (max ${this.maxRepeatedToolCalls}) without changing arguments \u2014 stuck in a tool loop`,
7515
+ {
7516
+ toolName: tc.function.name,
7517
+ repeatedExecutions: executions,
7518
+ maxRepeatedToolCalls: this.maxRepeatedToolCalls,
7519
+ signature: reportableSignature(signature)
7520
+ }
7521
+ );
7522
+ }
7523
+ signatureCounts.set(signature, executions);
7524
+ }
7525
+ }
7011
7526
  for (const tc of toolCalls) {
7012
7527
  this.hooks?.onToolCallStart?.(tc);
7013
7528
  }
@@ -7040,6 +7555,160 @@ var Agent = class {
7040
7555
  }
7041
7556
  };
7042
7557
 
7558
+ // src/affinity.ts
7559
+ var DEFAULT_CONCURRENT_MODELS = 1;
7560
+ var DEFAULT_PER_MODEL_CONCURRENCY = 1;
7561
+ var DEFAULT_PS_CACHE_TTL_MS = 3e4;
7562
+ var ModelAffinityScheduler = class {
7563
+ constructor(client, options = {}) {
7564
+ this.client = client;
7565
+ this.concurrentModels = options.concurrentModels ?? DEFAULT_CONCURRENT_MODELS;
7566
+ this.perModelConcurrency = options.perModelConcurrency ?? DEFAULT_PER_MODEL_CONCURRENCY;
7567
+ this.psCacheTtlMs = options.psCacheTtlMs ?? DEFAULT_PS_CACHE_TTL_MS;
7568
+ if (!Number.isInteger(this.concurrentModels) || this.concurrentModels < 1) {
7569
+ throw new OllamaClientError("concurrentModels must be an integer >= 1.", {
7570
+ code: "invalid_request"
7571
+ });
7572
+ }
7573
+ if (!Number.isInteger(this.perModelConcurrency) || this.perModelConcurrency < 1) {
7574
+ throw new OllamaClientError("perModelConcurrency must be an integer >= 1.", {
7575
+ code: "invalid_request"
7576
+ });
7577
+ }
7578
+ if (!Number.isFinite(this.psCacheTtlMs) || this.psCacheTtlMs < 0) {
7579
+ throw new OllamaClientError("psCacheTtlMs must be a non-negative number.", {
7580
+ code: "invalid_request"
7581
+ });
7582
+ }
7583
+ }
7584
+ queues = /* @__PURE__ */ new Map();
7585
+ concurrentModels;
7586
+ perModelConcurrency;
7587
+ psCacheTtlMs;
7588
+ psCache;
7589
+ drainWaiters = [];
7590
+ /**
7591
+ * Runs `task` on the scheduler, serialized per model. When `model` is a
7592
+ * list of candidates, the already-loaded one is preferred (fresh
7593
+ * `/api/ps` data subject to the TTL cache); with none loaded — or when the
7594
+ * lookup fails — the first candidate runs. The chosen model is handed to
7595
+ * the task.
7596
+ */
7597
+ async run(model, task) {
7598
+ const candidates = typeof model === "string" ? [model] : [...model];
7599
+ if (candidates.length === 0 || candidates.some((name) => typeof name !== "string" || name === "")) {
7600
+ throw new OllamaClientError("run() requires at least one non-empty model name.", {
7601
+ code: "invalid_request"
7602
+ });
7603
+ }
7604
+ const chosen = candidates.length === 1 ? candidates[0] : await this.chooseCandidate(candidates);
7605
+ return new Promise((resolve, reject) => {
7606
+ const queue = this.queues.get(chosen) ?? { items: [], active: 0 };
7607
+ this.queues.set(chosen, queue);
7608
+ queue.items.push({
7609
+ task,
7610
+ resolve,
7611
+ reject
7612
+ });
7613
+ this.pump();
7614
+ });
7615
+ }
7616
+ /**
7617
+ * Names of the models currently loaded, per `GET /api/ps`, cached for
7618
+ * `psCacheTtlMs`. Degrades to an empty list when the server can't answer
7619
+ * (never throws; candidate selection treats it as "nothing loaded").
7620
+ */
7621
+ async loadedModels() {
7622
+ const names = await this.refreshPsNames();
7623
+ return [...names];
7624
+ }
7625
+ /** Current scheduling state — active models and queued task count. */
7626
+ get stats() {
7627
+ const activeModels = [];
7628
+ let queuedTasks = 0;
7629
+ for (const [name, queue] of this.queues) {
7630
+ if (queue.active > 0) activeModels.push(name);
7631
+ queuedTasks += queue.items.length;
7632
+ }
7633
+ return { activeModels, queuedTasks };
7634
+ }
7635
+ /**
7636
+ * Resolves once every queue is empty and no task is running. Does not
7637
+ * abort anything — tasks are caller-owned; abort them via their own
7638
+ * signals before disposing if needed.
7639
+ */
7640
+ async dispose() {
7641
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) return;
7642
+ await new Promise((resolve) => {
7643
+ this.drainWaiters.push(resolve);
7644
+ });
7645
+ }
7646
+ async chooseCandidate(candidates) {
7647
+ const loaded = await this.refreshPsNames();
7648
+ for (const candidate of candidates) {
7649
+ if (loaded.has(candidate)) return candidate;
7650
+ }
7651
+ return candidates[0];
7652
+ }
7653
+ async refreshPsNames() {
7654
+ const now = Date.now();
7655
+ if (this.psCache !== void 0 && now - this.psCache.at <= this.psCacheTtlMs) {
7656
+ return this.psCache.names;
7657
+ }
7658
+ let names;
7659
+ try {
7660
+ const ps = await this.client.models.ps();
7661
+ names = new Set(ps.models.map((entry) => entry.name));
7662
+ } catch {
7663
+ return /* @__PURE__ */ new Set();
7664
+ }
7665
+ this.psCache = { at: now, names };
7666
+ return names;
7667
+ }
7668
+ pump() {
7669
+ for (const [name, queue] of this.queues) {
7670
+ while (queue.items.length > 0 && queue.active < this.perModelConcurrency) {
7671
+ if (queue.active === 0 && this.activeModelCount() >= this.concurrentModels) {
7672
+ break;
7673
+ }
7674
+ const entry = queue.items.shift();
7675
+ queue.active += 1;
7676
+ this.execute(name, queue, entry);
7677
+ }
7678
+ }
7679
+ }
7680
+ activeModelCount() {
7681
+ let count = 0;
7682
+ for (const queue of this.queues.values()) {
7683
+ if (queue.active > 0) count += 1;
7684
+ }
7685
+ return count;
7686
+ }
7687
+ execute(name, queue, entry) {
7688
+ void (async () => {
7689
+ try {
7690
+ entry.resolve(await entry.task(name));
7691
+ } catch (err) {
7692
+ entry.reject(err);
7693
+ } finally {
7694
+ queue.active -= 1;
7695
+ if (queue.items.length === 0 && queue.active === 0) {
7696
+ this.queues.delete(name);
7697
+ }
7698
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) {
7699
+ const waiters = [...this.drainWaiters];
7700
+ this.drainWaiters.length = 0;
7701
+ for (const resolve of waiters) resolve();
7702
+ }
7703
+ this.pump();
7704
+ }
7705
+ })();
7706
+ }
7707
+ };
7708
+ function createModelAffinityScheduler(client, options) {
7709
+ return new ModelAffinityScheduler(client, options);
7710
+ }
7711
+
7043
7712
  // src/system-one.ts
7044
7713
  var MAX_SYSTEM_ONE_IMAGES_BYTES = 32 * 1024 * 1024;
7045
7714
  var MAX_SYSTEM_ONE_REQUEST_BYTES = 64 * 1024;
@@ -7047,6 +7716,6 @@ function estimateSystemOneRequestBytes(request) {
7047
7716
  return new TextEncoder().encode(JSON.stringify(request)).byteLength;
7048
7717
  }
7049
7718
 
7050
- 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, McpBridge, ModelsClient, NANOS_PER_MS, NANOS_PER_SECOND, NOOP_LOGGER, OLLAMA_CLOUD_BASE_URL, OLLAMA_FALLBACK_CONTEXT_LENGTH, OllamaAbortError, OllamaAgentMaxIterationsError, OllamaAgentMaxToolCallsError, 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, checkChatContext, checkEndpointHealth, checkGenerateContext, composeMiddleware, contextWarningMessage, createConsoleLogger, createDecision, createOllamaCloudFreeTierQuota, createTimeoutSignal, defineTool, detectModelCapabilities, embedBatchOverflowMessage, encodeImage, estimateChatRequestTokens, estimateGenerateRequestTokens, estimateSystemOneRequestBytes, estimateTokens, extractUsage, fetchWithBackoff, findOversizedEmbedInputs, imageStringNeedsResolution, inferRuntimeMode, isKeepAliveSugar, isKnownDoneReason, isSameToolCall, listAllMcpTools, listAvailableModels, loadMcpTools, mapError, mergeToolCall, mergeToolCallArgumentsString, mergeToolCallArrays, normalizeChatStream, normalizeGenerateStream, normalizeKeepAlive, normalizeProgressStream, parseFrontmatter, parseNdjsonStream, parseSseStream, parseStructuredOutput, registerMcpTools, resolveCredentialEndpoints, resolveImageInput, setSpanError, toDataStream, toResponse, toTextStream, withRetry, withSpan, zodToJsonSchema };
7719
+ 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 };
7051
7720
  //# sourceMappingURL=index.js.map
7052
7721
  //# sourceMappingURL=index.js.map