@nemesis-oss/ollama-sdk 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -341,6 +341,23 @@ var OllamaAgentMaxIterationsError = class extends OllamaClientError {
341
341
  this.maxIterations = options.maxIterations;
342
342
  }
343
343
  };
344
+ var OllamaAgentToolLoopError = class extends OllamaClientError {
345
+ /** Name of the tool whose identical call would exceed the repeat budget. */
346
+ toolName;
347
+ /** How many times this exact call would have executed including the rejected one. */
348
+ repeatedExecutions;
349
+ /** The configured per-signature execution budget. */
350
+ maxRepeatedToolCalls;
351
+ /** Canonical `name(args)` signature of the repeated call (very large payloads truncated). */
352
+ signature;
353
+ constructor(message, options) {
354
+ super(message, { ...options, code: "agent_tool_loop_detected", retryable: false });
355
+ this.toolName = options.toolName;
356
+ this.repeatedExecutions = options.repeatedExecutions;
357
+ this.maxRepeatedToolCalls = options.maxRepeatedToolCalls;
358
+ this.signature = options.signature;
359
+ }
360
+ };
344
361
  var OllamaMcpError = class extends OllamaClientError {
345
362
  mcpMethod;
346
363
  toolName;
@@ -640,6 +657,15 @@ var EndpointRegistry = class {
640
657
  }
641
658
  };
642
659
 
660
+ // src/transport/url.ts
661
+ function joinUrlPath(baseUrl, path) {
662
+ const base = baseUrl.replace(/\/+$/, "");
663
+ const tail = path.replace(/^\/+/, "");
664
+ if (tail === "") return base;
665
+ if (base === "") return `/${tail}`;
666
+ return `${base}/${tail}`;
667
+ }
668
+
643
669
  // src/middleware.ts
644
670
  function composeMiddleware(middlewares, finalHandler) {
645
671
  return function runPipeline(initialRequest) {
@@ -1192,7 +1218,7 @@ var HttpClient = class {
1192
1218
  }
1193
1219
  }
1194
1220
  async request(options) {
1195
- const url = `${this.baseUrl}${options.path}`;
1221
+ const url = joinUrlPath(this.baseUrl, options.path);
1196
1222
  const method = options.method ?? (options.body !== void 0 || options.rawBody !== void 0 ? "POST" : "GET");
1197
1223
  const headers = this.buildHeaders(options.headers);
1198
1224
  if (options.rawBody !== void 0 && !options.headers?.["Content-Type"]) {
@@ -1252,7 +1278,7 @@ var HttpClient = class {
1252
1278
  * can decode their provider-specific event payloads without duplicating transport logic.
1253
1279
  */
1254
1280
  async requestSseStream(options) {
1255
- const url = `${this.baseUrl}${options.path}`;
1281
+ const url = joinUrlPath(this.baseUrl, options.path);
1256
1282
  const method = options.method ?? "POST";
1257
1283
  const headers = {
1258
1284
  ...this.buildHeaders(options.headers),
@@ -1319,7 +1345,7 @@ var HttpClient = class {
1319
1345
  );
1320
1346
  }
1321
1347
  async requestStream(options) {
1322
- const url = `${this.baseUrl}${options.path}`;
1348
+ const url = joinUrlPath(this.baseUrl, options.path);
1323
1349
  const method = options.method ?? "POST";
1324
1350
  const headers = this.buildHeaders(options.headers);
1325
1351
  return withSpan(
@@ -2160,6 +2186,151 @@ function createTimeoutSignal(timeoutMs, userSignal) {
2160
2186
  return { signal: controller.signal, cancel };
2161
2187
  }
2162
2188
 
2189
+ // src/context-safety.ts
2190
+ var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
2191
+ var IMAGE_TOKEN_ESTIMATE = 300;
2192
+ var CHARS_PER_TOKEN = 4;
2193
+ function isCjkCodePoint(code) {
2194
+ return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
2195
+ code >= 13312 && code <= 19903 || // CJK Extension A
2196
+ code >= 12352 && code <= 12543 || // Hiragana + Katakana
2197
+ code >= 44032 && code <= 55215 || // Hangul syllables
2198
+ code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
2199
+ code >= 65280 && code <= 65519;
2200
+ }
2201
+ function estimateTokens(text) {
2202
+ if (text.length === 0) return 0;
2203
+ let cjk = 0;
2204
+ let other = 0;
2205
+ for (const char of text) {
2206
+ if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
2207
+ else other += 1;
2208
+ }
2209
+ return Math.ceil(cjk + other / CHARS_PER_TOKEN);
2210
+ }
2211
+ var MESSAGE_OVERHEAD_TOKENS = 4;
2212
+ function estimateMessageTokens(message) {
2213
+ let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
2214
+ if (message.thinking !== void 0) total += estimateTokens(message.thinking);
2215
+ if (message.tool_calls !== void 0) {
2216
+ for (const call of message.tool_calls) {
2217
+ total += estimateTokens(call.function.name);
2218
+ total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
2219
+ }
2220
+ }
2221
+ if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
2222
+ return total;
2223
+ }
2224
+ function estimateChatRequestTokens(req) {
2225
+ let total = 0;
2226
+ for (const message of req.messages) total += estimateMessageTokens(message);
2227
+ if (req.tools !== void 0) {
2228
+ for (const tool of req.tools) {
2229
+ total += estimateTokens(tool.function.description ?? "");
2230
+ total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
2231
+ }
2232
+ }
2233
+ return total;
2234
+ }
2235
+ function estimateGenerateRequestTokens(req) {
2236
+ let total = estimateTokens(req.prompt ?? "");
2237
+ if (req.system !== void 0) total += estimateTokens(req.system);
2238
+ if (req.template !== void 0) total += estimateTokens(req.template);
2239
+ if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
2240
+ return total;
2241
+ }
2242
+ function checkChatContext(req, options = {}) {
2243
+ return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
2244
+ }
2245
+ function checkGenerateContext(req, options = {}) {
2246
+ return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
2247
+ }
2248
+ function check(estimatedPromptTokens, requestNumCtx, options) {
2249
+ const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
2250
+ if (requestNumCtx !== void 0) {
2251
+ return {
2252
+ estimatedPromptTokens,
2253
+ effectiveContextLength: requestNumCtx,
2254
+ windowSource: "request",
2255
+ exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
2256
+ };
2257
+ }
2258
+ if (options.defaultContextLength !== void 0) {
2259
+ return {
2260
+ estimatedPromptTokens,
2261
+ effectiveContextLength: options.defaultContextLength,
2262
+ windowSource: "client-default",
2263
+ exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
2264
+ };
2265
+ }
2266
+ return {
2267
+ estimatedPromptTokens,
2268
+ effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
2269
+ windowSource: "server-fallback",
2270
+ exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
2271
+ };
2272
+ }
2273
+ var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
2274
+ function contextWarningMessage(check2, kind) {
2275
+ const where = `OllamaClient.${kind}()`;
2276
+ const estimate = `~${check2.estimatedPromptTokens} tokens`;
2277
+ if (check2.windowSource === "request") {
2278
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the request's context window (num_ctx=${check2.effectiveContextLength}). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`options.num_ctx\` or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2279
+ }
2280
+ if (check2.windowSource === "client-default") {
2281
+ return `${where}: estimated prompt size ${estimate} is close to or beyond the client's \`defaultContextLength\` (${check2.effectiveContextLength}, injected as \`num_ctx\`). The server will likely silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Raise \`defaultContextLength\`, set \`options.num_ctx\` per request, or shorten the conversation. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2282
+ }
2283
+ return `${where}: estimated prompt size ${estimate} outright exceeds Ollama's conservative server-default context window (${OLLAMA_FALLBACK_CONTEXT_LENGTH}\u20134096 tokens when \`num_ctx\` is unset \u2014 model-dependent). Unless the model's default is larger, the server will silently truncate the top of the conversation \u2014 the model loses earlier turns with no error. Set \`options.num_ctx\` (or the client's \`defaultContextLength\`) to an explicit window to make truncation visible and controllable. Estimates are heuristic (\xB120\u201330%); see \`estimateTokens\`.`;
2284
+ }
2285
+
2286
+ // src/context-discovery.ts
2287
+ function extractParameterNumCtx(parameters) {
2288
+ if (!parameters) return void 0;
2289
+ const match = /^[ \t]*num_ctx[ \t]+(\d+)\s*$/m.exec(parameters);
2290
+ if (match === null) return void 0;
2291
+ const value = Number.parseInt(match[1], 10);
2292
+ return Number.isFinite(value) && value > 0 ? value : void 0;
2293
+ }
2294
+ function findRunningModelContextLength(psModels, model) {
2295
+ for (const entry of psModels) {
2296
+ const exact = entry.name === model || entry.model === model;
2297
+ const taglessPrefix = !model.includes(":") && (entry.name.startsWith(`${model}:`) || entry.model.startsWith(`${model}:`));
2298
+ if (exact || taglessPrefix) {
2299
+ return typeof entry.context_length === "number" && Number.isFinite(entry.context_length) && entry.context_length > 0 ? entry.context_length : void 0;
2300
+ }
2301
+ }
2302
+ return void 0;
2303
+ }
2304
+ function positiveInt(value) {
2305
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : void 0;
2306
+ }
2307
+ function resolveContextLength(signals) {
2308
+ const running = positiveInt(signals.running);
2309
+ const parameter = positiveInt(signals.parameter);
2310
+ const native = positiveInt(signals.native);
2311
+ if (running !== void 0) {
2312
+ return {
2313
+ contextLength: running,
2314
+ source: "running",
2315
+ ...parameter !== void 0 ? { parameterContextLength: parameter } : {},
2316
+ ...native !== void 0 ? { nativeContextLength: native } : {},
2317
+ runningContextLength: running
2318
+ };
2319
+ }
2320
+ if (parameter !== void 0) {
2321
+ return {
2322
+ contextLength: parameter,
2323
+ source: "parameters",
2324
+ parameterContextLength: parameter,
2325
+ ...native !== void 0 ? { nativeContextLength: native } : {}
2326
+ };
2327
+ }
2328
+ if (native !== void 0) {
2329
+ return { contextLength: native, source: "model-info", nativeContextLength: native };
2330
+ }
2331
+ return { contextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH, source: "fallback" };
2332
+ }
2333
+
2163
2334
  // src/keep-alive.ts
2164
2335
  var KEEP_ALIVE_UNLOAD = 0;
2165
2336
  var KEEP_ALIVE_INDEFINITE = -1;
@@ -2273,9 +2444,66 @@ var ModelsClient = class {
2273
2444
  { ...request, singleEndpoint: true }
2274
2445
  );
2275
2446
  }
2276
- ps() {
2277
- return this.runner((http) => http.request({ path: "/api/ps", method: "GET" }), {
2278
- singleEndpoint: true
2447
+ ps(request) {
2448
+ return this.runner(
2449
+ (http, signal) => http.request({ path: "/api/ps", method: "GET", signal }),
2450
+ {
2451
+ singleEndpoint: true,
2452
+ ...request?.signal !== void 0 ? { signal: request.signal } : {},
2453
+ ...request?.timeoutMs !== void 0 ? { timeoutMs: request.timeoutMs } : {}
2454
+ }
2455
+ );
2456
+ }
2457
+ /**
2458
+ * Discovers the model's real context window from the server — no more
2459
+ * 2048/4096 guesswork. Consults, in precedence order:
2460
+ *
2461
+ * 1. `GET /api/ps` — the window the **running instance actually
2462
+ * allocated** (exact, reflects the Modelfile `num_ctx` default and
2463
+ * what fit in memory); skipped when the model isn't loaded, when
2464
+ * `skipRunningCheck` is set, or harmlessly on servers without the
2465
+ * field.
2466
+ * 2. `POST /api/show` `parameters` — a Modelfile-authored
2467
+ * `num_ctx <n>` default, i.e. the window a cold load will allocate.
2468
+ * 3. `POST /api/show` `model_info["<arch>.context_length"]` — the
2469
+ * model's **native GGUF maximum** (a ceiling to raise `num_ctx`
2470
+ * toward, not the allocated window).
2471
+ * 4. Fallback — Ollama's conservative unset-`num_ctx` default (2048).
2472
+ *
2473
+ * The result carries every signal it found, so `nativeContextLength:
2474
+ * 131072` vs `contextLength: 4096` (a 32× gap seen in the official API
2475
+ * examples) is visible rather than guessed. Typical wiring:
2476
+ *
2477
+ * ```ts
2478
+ * const { contextLength, source, nativeContextLength } =
2479
+ * await client.models.getContextLength({ model: 'gemma4' });
2480
+ * // -> { contextLength: 4096, source: 'running', nativeContextLength: 131072 }
2481
+ * ```
2482
+ *
2483
+ * Makes two server calls (`/api/ps` + `/api/show`) unless
2484
+ * `skipRunningCheck` is set. A 404 from `/api/ps` (endpoint without the
2485
+ * listing) is treated as "not running" and never fails the lookup;
2486
+ * other transport errors propagate normally.
2487
+ */
2488
+ async getContextLength(request) {
2489
+ let running;
2490
+ if (request.skipRunningCheck !== true) {
2491
+ try {
2492
+ const ps = await this.ps({ signal: request.signal, timeoutMs: request.timeoutMs });
2493
+ running = findRunningModelContextLength(ps.models, request.model);
2494
+ } catch (err) {
2495
+ if (!(err instanceof OllamaNotFoundError)) throw err;
2496
+ }
2497
+ }
2498
+ const showRes = await this.show({
2499
+ model: request.model,
2500
+ signal: request.signal,
2501
+ timeoutMs: request.timeoutMs
2502
+ });
2503
+ return resolveContextLength({
2504
+ running,
2505
+ parameter: extractParameterNumCtx(showRes.parameters),
2506
+ native: extractContextLength(showRes.model_info)
2279
2507
  });
2280
2508
  }
2281
2509
  version() {
@@ -4273,6 +4501,52 @@ var ResponsesModule = class {
4273
4501
  };
4274
4502
 
4275
4503
  // src/conversation.ts
4504
+ function compactConversationHistory(messages, options) {
4505
+ const reserve = Math.max(0, options.reserveForReply ?? 0);
4506
+ const minTail = Math.max(0, options.minTailMessages ?? 2);
4507
+ const budget = Math.max(0, options.maxEstimatedTokens - reserve);
4508
+ let split = 0;
4509
+ while (split < messages.length && messages[split].role === "system") split += 1;
4510
+ const pinned = messages.slice(0, split);
4511
+ const tail = messages.slice(split);
4512
+ let used = 0;
4513
+ for (const message of pinned) used += estimateMessageTokens(message);
4514
+ const retained = [];
4515
+ for (let i = tail.length - 1; i >= 0; i -= 1) {
4516
+ const message = tail[i];
4517
+ const cost = estimateMessageTokens(message);
4518
+ const fits = used + cost <= budget;
4519
+ if (!fits && retained.length >= minTail) break;
4520
+ retained.unshift(message);
4521
+ used += cost;
4522
+ }
4523
+ return [...pinned, ...retained];
4524
+ }
4525
+ function sanitizeHistoryForNextTurn(messages, options = {}) {
4526
+ const keep = Math.max(0, options.keepImagesOnLastMessages ?? 1);
4527
+ const placeholder = options.imagePlaceholder;
4528
+ const cutoff = messages.length - keep;
4529
+ const sanitized = [];
4530
+ for (let index = 0; index < messages.length; index += 1) {
4531
+ const message = messages[index];
4532
+ if (index >= cutoff || !message.images || message.images.length === 0) {
4533
+ sanitized.push(message);
4534
+ continue;
4535
+ }
4536
+ const { images: _evicted, ...rest } = message;
4537
+ if (placeholder === void 0) {
4538
+ sanitized.push(rest);
4539
+ } else {
4540
+ sanitized.push({
4541
+ ...rest,
4542
+ content: rest.content === "" ? placeholder : `${rest.content}
4543
+
4544
+ ${placeholder}`
4545
+ });
4546
+ }
4547
+ }
4548
+ return sanitized;
4549
+ }
4276
4550
  function extractTurnStats(res) {
4277
4551
  const cachedTokens = res.prompt_eval_cached_count ?? 0;
4278
4552
  const evaluatedTokens = res.prompt_eval_count ?? 0;
@@ -4359,6 +4633,48 @@ var ConversationSession = class {
4359
4633
  getMessages() {
4360
4634
  return this.history.map((message) => message);
4361
4635
  }
4636
+ /**
4637
+ * Compacts the session's history in place: pinned system prompt plus the
4638
+ * most recent turns that fit the budget, oldest tailable turns dropped.
4639
+ *
4640
+ * This is the explicit escape hatch for sessions that outgrew their
4641
+ * window — `cacheStats.hitRate` collapsing toward zero while history grows
4642
+ * is the usual symptom, and the pre-flight warnings on `chat()` fire once
4643
+ * the prompt estimate crosses the threshold. After compaction the next
4644
+ * turn's prompt prefix differs from everything before it, so the KV cache
4645
+ * starts cold — visible as a one-turn `evaluatedTokens` spike in
4646
+ * `cacheStats`. Cumulative cache tallies are deliberately **not** reset:
4647
+ * they are observations, and the spike is part of the story.
4648
+ *
4649
+ * Returns the before/after estimates and the drop count. No-op (zero
4650
+ * drops, equal before/after estimates) when the history already fits.
4651
+ */
4652
+ compact(options = {}) {
4653
+ const window = options.maxEstimatedTokens ?? this.config.options?.num_ctx ?? OLLAMA_FALLBACK_CONTEXT_LENGTH;
4654
+ const reserve = options.reserveForReply ?? Math.ceil(window * 0.1);
4655
+ const estimatedTokensBefore = this.history.reduce(
4656
+ (sum, message) => sum + estimateMessageTokens(message),
4657
+ 0
4658
+ );
4659
+ const compacted = compactConversationHistory(this.history, {
4660
+ maxEstimatedTokens: window,
4661
+ reserveForReply: reserve,
4662
+ ...options.minTailMessages !== void 0 ? { minTailMessages: options.minTailMessages } : {}
4663
+ });
4664
+ const estimatedTokensAfter = compacted.reduce(
4665
+ (sum, message) => sum + estimateMessageTokens(message),
4666
+ 0
4667
+ );
4668
+ const droppedMessages = this.history.length - compacted.length;
4669
+ this.history.length = 0;
4670
+ this.history.push(...compacted);
4671
+ return {
4672
+ droppedMessages,
4673
+ estimatedTokensBefore,
4674
+ estimatedTokensAfter,
4675
+ effectiveBudget: window - reserve
4676
+ };
4677
+ }
4362
4678
  /**
4363
4679
  * Clears the conversation back to its initial state (system prompt, zero
4364
4680
  * turns, zeroed cache statistics). The next turn starts with a cold cache
@@ -4377,102 +4693,129 @@ var ConversationSession = class {
4377
4693
  }
4378
4694
  };
4379
4695
 
4380
- // src/context-safety.ts
4381
- var DEFAULT_CONTEXT_WARNING_THRESHOLD = 0.9;
4382
- var IMAGE_TOKEN_ESTIMATE = 300;
4383
- var CHARS_PER_TOKEN = 4;
4384
- function isCjkCodePoint(code) {
4385
- return code >= 19968 && code <= 40959 || // CJK Unified Ideographs
4386
- code >= 13312 && code <= 19903 || // CJK Extension A
4387
- code >= 12352 && code <= 12543 || // Hiragana + Katakana
4388
- code >= 44032 && code <= 55215 || // Hangul syllables
4389
- code >= 63744 && code <= 64255 || // CJK Compatibility Ideographs
4390
- code >= 65280 && code <= 65519;
4391
- }
4392
- function estimateTokens(text) {
4393
- if (text.length === 0) return 0;
4394
- let cjk = 0;
4395
- let other = 0;
4396
- for (const char of text) {
4397
- if (isCjkCodePoint(char.codePointAt(0) ?? 0)) cjk += 1;
4398
- else other += 1;
4696
+ // src/embed-batch.ts
4697
+ var DEFAULT_EMBED_BATCH_SIZE = 32;
4698
+ var DEFAULT_EMBED_CONCURRENCY = 3;
4699
+ function findOversizedEmbedInputs(input, contextLength) {
4700
+ const oversized = [];
4701
+ for (let index = 0; index < input.length; index++) {
4702
+ const text = input[index];
4703
+ if (text === void 0) continue;
4704
+ const estimate = estimateTokens(text);
4705
+ if (estimate > contextLength) {
4706
+ oversized.push({ index, estimate });
4707
+ }
4708
+ }
4709
+ return oversized;
4710
+ }
4711
+ function embedBatchOverflowMessage(oversized, totalInputs, contextLength, truncateFalse) {
4712
+ const shown = oversized.slice(0, 5).map((entry) => `#${entry.index} (~${entry.estimate} tokens)`);
4713
+ const elided = oversized.length > 5 ? `, +${oversized.length - 5} more` : "";
4714
+ const outcome = truncateFalse ? "the server will reject them (`truncate: false`)" : "Ollama will silently truncate them, producing degraded vectors";
4715
+ return `embedBatch: ${oversized.length} of ${totalInputs} input strings exceed the ${contextLength}-token context window (${shown.join(", ")}${elided}). ${outcome}. Raise \`options.num_ctx\`, pre-split long inputs, or set \`truncate: true\` explicitly to acknowledge truncation.`;
4716
+ }
4717
+ function assertPositiveInteger(value, field) {
4718
+ if (!Number.isInteger(value) || value < 1) {
4719
+ throw new OllamaClientError(
4720
+ `embedBatch: \`${field}\` must be a positive integer (got ${value}).`,
4721
+ { code: "invalid_request" }
4722
+ );
4399
4723
  }
4400
- return Math.ceil(cjk + other / CHARS_PER_TOKEN);
4401
4724
  }
4402
- var MESSAGE_OVERHEAD_TOKENS = 4;
4403
- function estimateMessageTokens(message) {
4404
- let total = MESSAGE_OVERHEAD_TOKENS + estimateTokens(message.content ?? "");
4405
- if (message.thinking !== void 0) total += estimateTokens(message.thinking);
4406
- if (message.tool_calls !== void 0) {
4407
- for (const call of message.tool_calls) {
4408
- total += estimateTokens(call.function.name);
4409
- total += estimateTokens(JSON.stringify(call.function.arguments ?? {}));
4410
- }
4725
+ async function batchEmbed(client, options) {
4726
+ const { model, input } = options;
4727
+ const batchSize = options.batchSize ?? DEFAULT_EMBED_BATCH_SIZE;
4728
+ const concurrency = options.concurrency ?? DEFAULT_EMBED_CONCURRENCY;
4729
+ assertPositiveInteger(batchSize, "batchSize");
4730
+ assertPositiveInteger(concurrency, "concurrency");
4731
+ if (input.length === 0) {
4732
+ return { model, embeddings: [], batchCount: 0 };
4411
4733
  }
4412
- if (message.images !== void 0) total += message.images.length * IMAGE_TOKEN_ESTIMATE;
4413
- return total;
4414
- }
4415
- function estimateChatRequestTokens(req) {
4416
- let total = 0;
4417
- for (const message of req.messages) total += estimateMessageTokens(message);
4418
- if (req.tools !== void 0) {
4419
- for (const tool of req.tools) {
4420
- total += estimateTokens(tool.function.description ?? "");
4421
- total += estimateTokens(JSON.stringify(tool.function.parameters ?? {}));
4734
+ const batches = [];
4735
+ for (let start = 0; start < input.length; start += batchSize) {
4736
+ batches.push(input.slice(start, start + batchSize));
4737
+ }
4738
+ const embeddings = new Array(input.length);
4739
+ const controller = new AbortController();
4740
+ const propagateExternalAbort = () => {
4741
+ controller.abort(options.signal?.reason);
4742
+ };
4743
+ if (options.signal) {
4744
+ if (options.signal.aborted) {
4745
+ propagateExternalAbort();
4746
+ } else {
4747
+ options.signal.addEventListener("abort", propagateExternalAbort, { once: true });
4422
4748
  }
4423
4749
  }
4424
- return total;
4425
- }
4426
- function estimateGenerateRequestTokens(req) {
4427
- let total = estimateTokens(req.prompt ?? "");
4428
- if (req.system !== void 0) total += estimateTokens(req.system);
4429
- if (req.template !== void 0) total += estimateTokens(req.template);
4430
- if (req.images !== void 0) total += req.images.length * IMAGE_TOKEN_ESTIMATE;
4431
- return total;
4432
- }
4433
- function checkChatContext(req, options = {}) {
4434
- return check(estimateChatRequestTokens(req), req.options?.num_ctx, options);
4435
- }
4436
- function checkGenerateContext(req, options = {}) {
4437
- return check(estimateGenerateRequestTokens(req), req.options?.num_ctx, options);
4438
- }
4439
- function check(estimatedPromptTokens, requestNumCtx, options) {
4440
- const threshold = options.threshold ?? DEFAULT_CONTEXT_WARNING_THRESHOLD;
4441
- if (requestNumCtx !== void 0) {
4442
- return {
4443
- estimatedPromptTokens,
4444
- effectiveContextLength: requestNumCtx,
4445
- windowSource: "request",
4446
- exceedsThreshold: estimatedPromptTokens > requestNumCtx * threshold
4447
- };
4750
+ let nextBatch = 0;
4751
+ let completedBatches = 0;
4752
+ let failure;
4753
+ const worker = async () => {
4754
+ for (; ; ) {
4755
+ if (controller.signal.aborted) {
4756
+ throw failure?.error ?? new OllamaAbortError("Embed batch aborted");
4757
+ }
4758
+ const batchIndex = nextBatch;
4759
+ nextBatch++;
4760
+ if (batchIndex >= batches.length) return;
4761
+ const batch = batches[batchIndex];
4762
+ if (!batch) return;
4763
+ try {
4764
+ const request = {
4765
+ model,
4766
+ input: batch,
4767
+ ...options.truncate !== void 0 ? { truncate: options.truncate } : {},
4768
+ ...options.dimensions !== void 0 ? { dimensions: options.dimensions } : {},
4769
+ ...options.keep_alive !== void 0 ? { keep_alive: options.keep_alive } : {},
4770
+ ...options.options !== void 0 ? { options: options.options } : {},
4771
+ ...options.timeoutMs !== void 0 ? { timeoutMs: options.timeoutMs } : {},
4772
+ signal: controller.signal
4773
+ };
4774
+ const response = await client.embed(request);
4775
+ if (response.embeddings.length !== batch.length) {
4776
+ throw new OllamaClientError(
4777
+ `embedBatch: server returned ${response.embeddings.length} embeddings for a batch of ${batch.length} inputs (batch ${batchIndex}).`,
4778
+ { code: "invalid_response" }
4779
+ );
4780
+ }
4781
+ const offset = batchIndex * batchSize;
4782
+ for (let j = 0; j < batch.length; j++) {
4783
+ embeddings[offset + j] = response.embeddings[j];
4784
+ }
4785
+ completedBatches++;
4786
+ options.onBatchComplete?.(completedBatches, batches.length);
4787
+ } catch (error) {
4788
+ if (!failure) {
4789
+ failure = { error };
4790
+ controller.abort(error);
4791
+ throw error;
4792
+ }
4793
+ throw failure.error;
4794
+ }
4795
+ }
4796
+ };
4797
+ try {
4798
+ const workerCount = Math.min(concurrency, batches.length);
4799
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
4800
+ } finally {
4801
+ if (options.signal) {
4802
+ options.signal.removeEventListener("abort", propagateExternalAbort);
4803
+ }
4448
4804
  }
4449
- if (options.defaultContextLength !== void 0) {
4450
- return {
4451
- estimatedPromptTokens,
4452
- effectiveContextLength: options.defaultContextLength,
4453
- windowSource: "client-default",
4454
- exceedsThreshold: estimatedPromptTokens > options.defaultContextLength * threshold
4455
- };
4805
+ for (let i = 0; i < embeddings.length; i++) {
4806
+ if (embeddings[i] === void 0) {
4807
+ throw new OllamaClientError(
4808
+ `embedBatch: internal invariant violated \u2014 no embedding produced for input #${i}.`,
4809
+ { code: "invalid_response" }
4810
+ );
4811
+ }
4456
4812
  }
4457
4813
  return {
4458
- estimatedPromptTokens,
4459
- effectiveContextLength: OLLAMA_FALLBACK_CONTEXT_LENGTH,
4460
- windowSource: "server-fallback",
4461
- exceedsThreshold: estimatedPromptTokens > OLLAMA_FALLBACK_CONTEXT_LENGTH
4814
+ model,
4815
+ embeddings,
4816
+ batchCount: batches.length
4462
4817
  };
4463
4818
  }
4464
- var OLLAMA_FALLBACK_CONTEXT_LENGTH = 2048;
4465
- function contextWarningMessage(check2, kind) {
4466
- const where = `OllamaClient.${kind}()`;
4467
- const estimate = `~${check2.estimatedPromptTokens} tokens`;
4468
- if (check2.windowSource === "request") {
4469
- 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\`.`;
4470
- }
4471
- if (check2.windowSource === "client-default") {
4472
- 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\`.`;
4473
- }
4474
- 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\`.`;
4475
- }
4476
4819
 
4477
4820
  // src/generated/api/operations.ts
4478
4821
  var blobsOp = {
@@ -4998,6 +5341,14 @@ var OllamaClient = class {
4998
5341
  contextWarningThreshold;
4999
5342
  /** Configured overflow policy — `'warn'` (default) or `'throw'`. */
5000
5343
  onContextOverflow;
5344
+ /**
5345
+ * Controllers for every in-flight request and active stream managed by this
5346
+ * client — see {@link OllamaClient.destroy} and {@link createRequestScope}.
5347
+ * Entries live for exactly as long as the underlying work does: until the
5348
+ * response settles (requests) or the stream's `finalResult` settles
5349
+ * (streams, via `executeWithFailover`'s `holdUntil` release path).
5350
+ */
5351
+ activeRequests = /* @__PURE__ */ new Set();
5001
5352
  constructor(config = {}) {
5002
5353
  this.cloudApiKey = resolveApiKey(config.apiKey);
5003
5354
  const defaultBaseUrl = resolveBaseUrl(config.baseUrl);
@@ -5162,6 +5513,39 @@ var OllamaClient = class {
5162
5513
  }
5163
5514
  return { ...options, num_ctx: this.defaultContextLength };
5164
5515
  }
5516
+ /**
5517
+ * Creates the per-request abort scope registered with {@link destroy}:
5518
+ * a controller that fires when the caller's `signal` aborts OR when
5519
+ * `destroy()` runs, whichever comes first. `dispose()` removes the
5520
+ * registration when the work it guards settles — for plain requests when
5521
+ * the response settles, for streams when `finalResult` settles (the same
5522
+ * moment `executeWithFailover`'s `holdUntil` releases the endpoint slot),
5523
+ * so a destroy() can tear down a stream mid-consumption but never outlives
5524
+ * one that already finished.
5525
+ */
5526
+ createRequestScope(userSignal) {
5527
+ const controller = new AbortController();
5528
+ const propagateUserAbort = () => {
5529
+ controller.abort(userSignal?.reason);
5530
+ };
5531
+ let detach;
5532
+ if (userSignal) {
5533
+ if (userSignal.aborted) {
5534
+ propagateUserAbort();
5535
+ } else {
5536
+ userSignal.addEventListener("abort", propagateUserAbort, { once: true });
5537
+ detach = () => userSignal.removeEventListener("abort", propagateUserAbort);
5538
+ }
5539
+ }
5540
+ this.activeRequests.add(controller);
5541
+ return {
5542
+ signal: controller.signal,
5543
+ dispose: () => {
5544
+ detach?.();
5545
+ this.activeRequests.delete(controller);
5546
+ }
5547
+ };
5548
+ }
5165
5549
  /**
5166
5550
  * Fail-fast guard for `format` (structured output) requests: throws before any network
5167
5551
  * call if the candidate endpoint is inferred as Ollama Cloud, which does not currently
@@ -5178,7 +5562,8 @@ var OllamaClient = class {
5178
5562
  }
5179
5563
  }
5180
5564
  async executeWithFailover(operation, options) {
5181
- const timeout = createTimeoutSignal(options?.timeoutMs ?? this.timeoutMs, options?.signal);
5565
+ const scope = this.createRequestScope(options?.signal);
5566
+ const timeout = createTimeoutSignal(options?.timeoutMs ?? this.timeoutMs, scope.signal);
5182
5567
  const requestId = createLogicalRequestId();
5183
5568
  let deferTimeoutCancel = false;
5184
5569
  try {
@@ -5257,6 +5642,7 @@ var OllamaClient = class {
5257
5642
  if (holdPromise) {
5258
5643
  void holdPromise.catch(() => void 0).finally(() => {
5259
5644
  timeout.cancel();
5645
+ scope.dispose();
5260
5646
  this.registry.release(endpoint.name);
5261
5647
  });
5262
5648
  } else {
@@ -5267,7 +5653,10 @@ var OllamaClient = class {
5267
5653
  throw lastError ?? new Error("No healthy Ollama endpoints available");
5268
5654
  }
5269
5655
  } finally {
5270
- if (!deferTimeoutCancel) timeout.cancel();
5656
+ if (!deferTimeoutCancel) {
5657
+ timeout.cancel();
5658
+ scope.dispose();
5659
+ }
5271
5660
  }
5272
5661
  }
5273
5662
  async chat(req) {
@@ -5427,6 +5816,58 @@ var OllamaClient = class {
5427
5816
  const res = await this.embed({ model, input });
5428
5817
  return res.embeddings;
5429
5818
  }
5819
+ /**
5820
+ * Batch-constrained, order-preserving embedding generation for large
5821
+ * corpora — the safe building block for RAG/vector-index ingestion at
5822
+ * scale. Splits `input` into `batchSize` slices and embeds them through
5823
+ * {@link embed} with at most `concurrency` batches in flight (defaults:
5824
+ * 32 × 3), so `Promise.all`-style floods that OOM local daemons and
5825
+ * saturate `OLLAMA_MAX_QUEUE` can't happen by accident. Fail-fast: the
5826
+ * first batch error aborts every sibling in-flight batch and rejects the
5827
+ * whole operation with the original error.
5828
+ *
5829
+ * Before any request is sent, `defaultContextLength` is injected as
5830
+ * `num_ctx` (same as chat/generate) and each input string's token estimate
5831
+ * is checked against the resolved window: Ollama silently truncates
5832
+ * oversized embedding inputs by default, so the client warns (or throws,
5833
+ * under `onContextOverflow: 'throw'`) with the offending indexes — see
5834
+ * `src/embed-batch.ts` for the exact trip point (no margin: an embedding
5835
+ * prompt IS the whole input).
5836
+ *
5837
+ * ```ts
5838
+ * const { embeddings, batchCount } = await client.embedBatch({
5839
+ * model: 'nomic-embed-text:latest',
5840
+ * input: corpus, // e.g. 50k chunks
5841
+ * batchSize: 32, // inputs per /api/embed request
5842
+ * concurrency: 3, // batches in flight
5843
+ * onBatchComplete: (done, total) => progress.log(`${done}/${total}`),
5844
+ * });
5845
+ * // embeddings[i] always corresponds to corpus[i]
5846
+ * ```
5847
+ */
5848
+ async embedBatch(options) {
5849
+ const resolvedOptions = this.injectDefaultContextLength(options.options);
5850
+ const contextLength = resolvedOptions?.num_ctx;
5851
+ if (contextLength !== void 0) {
5852
+ const oversized = findOversizedEmbedInputs(options.input, contextLength);
5853
+ if (oversized.length > 0) {
5854
+ const message = embedBatchOverflowMessage(
5855
+ oversized,
5856
+ options.input.length,
5857
+ contextLength,
5858
+ options.truncate === false
5859
+ );
5860
+ if (this.onContextOverflow === "throw") {
5861
+ throw new OllamaClientError(message, { code: "context_overflow" });
5862
+ }
5863
+ this.logger.warn(message);
5864
+ }
5865
+ }
5866
+ return batchEmbed(
5867
+ this,
5868
+ resolvedOptions === options.options ? options : { ...options, options: resolvedOptions }
5869
+ );
5870
+ }
5430
5871
  /**
5431
5872
  * @deprecated Ollama's `/api/embeddings` endpoint has been superseded by `/api/embed`
5432
5873
  * (exposed here as {@link OllamaClient.embed}), which additionally supports batch
@@ -5446,7 +5887,15 @@ var OllamaClient = class {
5446
5887
  createModel = (r) => this.models.create(r);
5447
5888
  deleteModel = (req) => this.models.delete(req);
5448
5889
  copyModel = (req) => this.models.copy(req);
5449
- ps = () => this.models.ps();
5890
+ ps = (request) => this.models.ps(request);
5891
+ /**
5892
+ * Discovers the model's real context window — the allocated window of the
5893
+ * running instance (`/api/ps`), the Modelfile `num_ctx` default, and the
5894
+ * native GGUF maximum (`/api/show` `model_info`), resolved by precedence.
5895
+ * Convenience alias for {@link ModelsClient.getContextLength} — see that
5896
+ * method for the full contract and the precedence order.
5897
+ */
5898
+ getContextLength = (request) => this.models.getContextLength(request);
5450
5899
  version = () => this.models.version();
5451
5900
  createBlob = (digest, data) => this.models.createBlob(digest, data);
5452
5901
  checkBlob = (digest) => this.models.checkBlob(digest);
@@ -5596,10 +6045,12 @@ var OllamaClient = class {
5596
6045
  async executeCloudRequest(operation, options) {
5597
6046
  var _stack = [];
5598
6047
  try {
5599
- const timeout = createTimeoutSignal(options.timeoutMs ?? this.timeoutMs, options.signal);
6048
+ const scope = this.createRequestScope(options.signal);
6049
+ const timeout = createTimeoutSignal(options.timeoutMs ?? this.timeoutMs, scope.signal);
5600
6050
  const _timeout = __using(_stack, {
5601
6051
  [Symbol.dispose]() {
5602
6052
  timeout.cancel();
6053
+ scope.dispose();
5603
6054
  }
5604
6055
  });
5605
6056
  const requestId = createLogicalRequestId();
@@ -5660,6 +6111,34 @@ var OllamaClient = class {
5660
6111
  endpointStatus() {
5661
6112
  return this.registry.status();
5662
6113
  }
6114
+ /**
6115
+ * Immediately aborts every in-flight request and active stream managed by
6116
+ * this client — the clean teardown path for short-lived processes, CLI
6117
+ * runners, and `node:worker_threads` tasks where dangling fetch bodies,
6118
+ * unconsumed stream readers, or queued capacity waiters would otherwise
6119
+ * keep the event loop (and thus the thread/process) alive.
6120
+ *
6121
+ * Every aborted operation rejects with an {@link OllamaAbortError}
6122
+ * (`code: 'aborted'`) carrying `reason` as its message — including requests
6123
+ * still queued behind `maxConcurrentPerEndpoint`, not just dispatched ones.
6124
+ * Streams reject their `finalResult` and release their endpoint slots.
6125
+ *
6126
+ * Idempotent: calling it again (or on a client with nothing in flight)
6127
+ * is a no-op that returns `0`. The client remains usable afterward —
6128
+ * `destroy()` is a drain, not a permanent disable; callers that want a
6129
+ * permanently-dead client should discard the instance.
6130
+ *
6131
+ * @returns the number of in-flight operations that were aborted.
6132
+ */
6133
+ destroy(reason = "Ollama client destroyed") {
6134
+ const controllers = [...this.activeRequests];
6135
+ const cause = new OllamaAbortError(reason);
6136
+ for (const controller of controllers) {
6137
+ controller.abort(cause);
6138
+ }
6139
+ this.activeRequests.clear();
6140
+ return controllers.length;
6141
+ }
5663
6142
  // --- Compatibility Adapters ---
5664
6143
  get openai() {
5665
6144
  const ep = this.registry.candidates()[0];
@@ -6664,11 +7143,29 @@ ${systemPrompt}`
6664
7143
  }
6665
7144
 
6666
7145
  // src/agent/agent.ts
7146
+ var SIGNATURE_REPORT_LIMIT = 200;
7147
+ function stableStringify(value) {
7148
+ if (value === null || typeof value !== "object") {
7149
+ return JSON.stringify(value) ?? "null";
7150
+ }
7151
+ if (Array.isArray(value)) {
7152
+ return `[${value.map((entry) => stableStringify(entry)).join(",")}]`;
7153
+ }
7154
+ 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)}`);
7155
+ return `{${entries.join(",")}}`;
7156
+ }
7157
+ function canonicalToolCallSignature(toolCall) {
7158
+ return `${toolCall.function.name}(${stableStringify(toolCall.function.arguments)})`;
7159
+ }
7160
+ function reportableSignature(signature) {
7161
+ return signature.length <= SIGNATURE_REPORT_LIMIT ? signature : `${signature.slice(0, SIGNATURE_REPORT_LIMIT)}\u2026`;
7162
+ }
6667
7163
  var Agent = class {
6668
7164
  client;
6669
7165
  tools;
6670
7166
  maxIterations;
6671
7167
  maxToolCalls;
7168
+ maxRepeatedToolCalls;
6672
7169
  hooks;
6673
7170
  validateToolCapability;
6674
7171
  toolContextSize;
@@ -6677,10 +7174,14 @@ var Agent = class {
6677
7174
  this.tools = config.tools;
6678
7175
  this.maxIterations = config.maxIterations ?? 10;
6679
7176
  this.maxToolCalls = config.maxToolCalls;
7177
+ this.maxRepeatedToolCalls = config.maxRepeatedToolCalls;
6680
7178
  this.hooks = config.hooks;
6681
7179
  if (this.maxToolCalls !== void 0 && (!Number.isInteger(this.maxToolCalls) || this.maxToolCalls < 0)) {
6682
7180
  throw new RangeError("Agent maxToolCalls must be a non-negative integer");
6683
7181
  }
7182
+ if (this.maxRepeatedToolCalls !== void 0 && (!Number.isInteger(this.maxRepeatedToolCalls) || this.maxRepeatedToolCalls < 1)) {
7183
+ throw new RangeError("Agent maxRepeatedToolCalls must be a positive integer");
7184
+ }
6684
7185
  this.validateToolCapability = config.validateToolCapability ?? true;
6685
7186
  this.toolContextSize = config.toolContextSize ?? 32768;
6686
7187
  if (!Number.isInteger(this.toolContextSize) || this.toolContextSize <= 0) {
@@ -6727,6 +7228,7 @@ var Agent = class {
6727
7228
  )
6728
7229
  } : input.options;
6729
7230
  let toolCallsExecuted = 0;
7231
+ const signatureCounts = /* @__PURE__ */ new Map();
6730
7232
  for (let iteration = 1; iteration <= this.maxIterations; iteration++) {
6731
7233
  const outcome = await withSpan(
6732
7234
  "ollama.agent.turn",
@@ -6758,6 +7260,24 @@ var Agent = class {
6758
7260
  { maxToolCalls: this.maxToolCalls, toolCallsExecuted }
6759
7261
  );
6760
7262
  }
7263
+ if (this.maxRepeatedToolCalls !== void 0) {
7264
+ for (const tc of toolCalls) {
7265
+ const signature = canonicalToolCallSignature(tc);
7266
+ const executions = (signatureCounts.get(signature) ?? 0) + 1;
7267
+ if (executions > this.maxRepeatedToolCalls) {
7268
+ throw new OllamaAgentToolLoopError(
7269
+ `Agent would repeat tool call "${tc.function.name}" ${executions} times (max ${this.maxRepeatedToolCalls}) without changing arguments \u2014 stuck in a tool loop`,
7270
+ {
7271
+ toolName: tc.function.name,
7272
+ repeatedExecutions: executions,
7273
+ maxRepeatedToolCalls: this.maxRepeatedToolCalls,
7274
+ signature: reportableSignature(signature)
7275
+ }
7276
+ );
7277
+ }
7278
+ signatureCounts.set(signature, executions);
7279
+ }
7280
+ }
6761
7281
  for (const tc of toolCalls) {
6762
7282
  this.hooks?.onToolCallStart?.(tc);
6763
7283
  }
@@ -6790,6 +7310,160 @@ var Agent = class {
6790
7310
  }
6791
7311
  };
6792
7312
 
7313
+ // src/affinity.ts
7314
+ var DEFAULT_CONCURRENT_MODELS = 1;
7315
+ var DEFAULT_PER_MODEL_CONCURRENCY = 1;
7316
+ var DEFAULT_PS_CACHE_TTL_MS = 3e4;
7317
+ var ModelAffinityScheduler = class {
7318
+ constructor(client, options = {}) {
7319
+ this.client = client;
7320
+ this.concurrentModels = options.concurrentModels ?? DEFAULT_CONCURRENT_MODELS;
7321
+ this.perModelConcurrency = options.perModelConcurrency ?? DEFAULT_PER_MODEL_CONCURRENCY;
7322
+ this.psCacheTtlMs = options.psCacheTtlMs ?? DEFAULT_PS_CACHE_TTL_MS;
7323
+ if (!Number.isInteger(this.concurrentModels) || this.concurrentModels < 1) {
7324
+ throw new OllamaClientError("concurrentModels must be an integer >= 1.", {
7325
+ code: "invalid_request"
7326
+ });
7327
+ }
7328
+ if (!Number.isInteger(this.perModelConcurrency) || this.perModelConcurrency < 1) {
7329
+ throw new OllamaClientError("perModelConcurrency must be an integer >= 1.", {
7330
+ code: "invalid_request"
7331
+ });
7332
+ }
7333
+ if (!Number.isFinite(this.psCacheTtlMs) || this.psCacheTtlMs < 0) {
7334
+ throw new OllamaClientError("psCacheTtlMs must be a non-negative number.", {
7335
+ code: "invalid_request"
7336
+ });
7337
+ }
7338
+ }
7339
+ queues = /* @__PURE__ */ new Map();
7340
+ concurrentModels;
7341
+ perModelConcurrency;
7342
+ psCacheTtlMs;
7343
+ psCache;
7344
+ drainWaiters = [];
7345
+ /**
7346
+ * Runs `task` on the scheduler, serialized per model. When `model` is a
7347
+ * list of candidates, the already-loaded one is preferred (fresh
7348
+ * `/api/ps` data subject to the TTL cache); with none loaded — or when the
7349
+ * lookup fails — the first candidate runs. The chosen model is handed to
7350
+ * the task.
7351
+ */
7352
+ async run(model, task) {
7353
+ const candidates = typeof model === "string" ? [model] : [...model];
7354
+ if (candidates.length === 0 || candidates.some((name) => typeof name !== "string" || name === "")) {
7355
+ throw new OllamaClientError("run() requires at least one non-empty model name.", {
7356
+ code: "invalid_request"
7357
+ });
7358
+ }
7359
+ const chosen = candidates.length === 1 ? candidates[0] : await this.chooseCandidate(candidates);
7360
+ return new Promise((resolve, reject) => {
7361
+ const queue = this.queues.get(chosen) ?? { items: [], active: 0 };
7362
+ this.queues.set(chosen, queue);
7363
+ queue.items.push({
7364
+ task,
7365
+ resolve,
7366
+ reject
7367
+ });
7368
+ this.pump();
7369
+ });
7370
+ }
7371
+ /**
7372
+ * Names of the models currently loaded, per `GET /api/ps`, cached for
7373
+ * `psCacheTtlMs`. Degrades to an empty list when the server can't answer
7374
+ * (never throws; candidate selection treats it as "nothing loaded").
7375
+ */
7376
+ async loadedModels() {
7377
+ const names = await this.refreshPsNames();
7378
+ return [...names];
7379
+ }
7380
+ /** Current scheduling state — active models and queued task count. */
7381
+ get stats() {
7382
+ const activeModels = [];
7383
+ let queuedTasks = 0;
7384
+ for (const [name, queue] of this.queues) {
7385
+ if (queue.active > 0) activeModels.push(name);
7386
+ queuedTasks += queue.items.length;
7387
+ }
7388
+ return { activeModels, queuedTasks };
7389
+ }
7390
+ /**
7391
+ * Resolves once every queue is empty and no task is running. Does not
7392
+ * abort anything — tasks are caller-owned; abort them via their own
7393
+ * signals before disposing if needed.
7394
+ */
7395
+ async dispose() {
7396
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) return;
7397
+ await new Promise((resolve) => {
7398
+ this.drainWaiters.push(resolve);
7399
+ });
7400
+ }
7401
+ async chooseCandidate(candidates) {
7402
+ const loaded = await this.refreshPsNames();
7403
+ for (const candidate of candidates) {
7404
+ if (loaded.has(candidate)) return candidate;
7405
+ }
7406
+ return candidates[0];
7407
+ }
7408
+ async refreshPsNames() {
7409
+ const now = Date.now();
7410
+ if (this.psCache !== void 0 && now - this.psCache.at <= this.psCacheTtlMs) {
7411
+ return this.psCache.names;
7412
+ }
7413
+ let names;
7414
+ try {
7415
+ const ps = await this.client.models.ps();
7416
+ names = new Set(ps.models.map((entry) => entry.name));
7417
+ } catch {
7418
+ return /* @__PURE__ */ new Set();
7419
+ }
7420
+ this.psCache = { at: now, names };
7421
+ return names;
7422
+ }
7423
+ pump() {
7424
+ for (const [name, queue] of this.queues) {
7425
+ while (queue.items.length > 0 && queue.active < this.perModelConcurrency) {
7426
+ if (queue.active === 0 && this.activeModelCount() >= this.concurrentModels) {
7427
+ break;
7428
+ }
7429
+ const entry = queue.items.shift();
7430
+ queue.active += 1;
7431
+ this.execute(name, queue, entry);
7432
+ }
7433
+ }
7434
+ }
7435
+ activeModelCount() {
7436
+ let count = 0;
7437
+ for (const queue of this.queues.values()) {
7438
+ if (queue.active > 0) count += 1;
7439
+ }
7440
+ return count;
7441
+ }
7442
+ execute(name, queue, entry) {
7443
+ void (async () => {
7444
+ try {
7445
+ entry.resolve(await entry.task(name));
7446
+ } catch (err) {
7447
+ entry.reject(err);
7448
+ } finally {
7449
+ queue.active -= 1;
7450
+ if (queue.items.length === 0 && queue.active === 0) {
7451
+ this.queues.delete(name);
7452
+ }
7453
+ if (this.stats.queuedTasks === 0 && this.stats.activeModels.length === 0) {
7454
+ const waiters = [...this.drainWaiters];
7455
+ this.drainWaiters.length = 0;
7456
+ for (const resolve of waiters) resolve();
7457
+ }
7458
+ this.pump();
7459
+ }
7460
+ })();
7461
+ }
7462
+ };
7463
+ function createModelAffinityScheduler(client, options) {
7464
+ return new ModelAffinityScheduler(client, options);
7465
+ }
7466
+
6793
7467
  // src/system-one.ts
6794
7468
  var MAX_SYSTEM_ONE_IMAGES_BYTES = 32 * 1024 * 1024;
6795
7469
  var MAX_SYSTEM_ONE_REQUEST_BYTES = 64 * 1024;
@@ -6821,6 +7495,8 @@ exports.ConversationSession = ConversationSession;
6821
7495
  exports.DEFAULT_BACKOFF = DEFAULT_BACKOFF;
6822
7496
  exports.DEFAULT_BASE_URL = DEFAULT_BASE_URL;
6823
7497
  exports.DEFAULT_CONTEXT_WARNING_THRESHOLD = DEFAULT_CONTEXT_WARNING_THRESHOLD;
7498
+ exports.DEFAULT_EMBED_BATCH_SIZE = DEFAULT_EMBED_BATCH_SIZE;
7499
+ exports.DEFAULT_EMBED_CONCURRENCY = DEFAULT_EMBED_CONCURRENCY;
6824
7500
  exports.DEFAULT_FAILOVER_CODES = DEFAULT_FAILOVER_CODES;
6825
7501
  exports.DEFAULT_FETCH_BACKOFF_CONFIG = DEFAULT_FETCH_BACKOFF_CONFIG;
6826
7502
  exports.DEFAULT_RETRY_CONFIG = DEFAULT_RETRY_CONFIG;
@@ -6834,7 +7510,9 @@ exports.KEEP_ALIVE_UNLOAD = KEEP_ALIVE_UNLOAD;
6834
7510
  exports.KNOWN_DONE_REASONS = KNOWN_DONE_REASONS;
6835
7511
  exports.MAX_SYSTEM_ONE_IMAGES_BYTES = MAX_SYSTEM_ONE_IMAGES_BYTES;
6836
7512
  exports.MAX_SYSTEM_ONE_REQUEST_BYTES = MAX_SYSTEM_ONE_REQUEST_BYTES;
7513
+ exports.MESSAGE_OVERHEAD_TOKENS = MESSAGE_OVERHEAD_TOKENS;
6837
7514
  exports.McpBridge = McpBridge;
7515
+ exports.ModelAffinityScheduler = ModelAffinityScheduler;
6838
7516
  exports.ModelsClient = ModelsClient;
6839
7517
  exports.NANOS_PER_MS = NANOS_PER_MS;
6840
7518
  exports.NANOS_PER_SECOND = NANOS_PER_SECOND;
@@ -6844,6 +7522,7 @@ exports.OLLAMA_FALLBACK_CONTEXT_LENGTH = OLLAMA_FALLBACK_CONTEXT_LENGTH;
6844
7522
  exports.OllamaAbortError = OllamaAbortError;
6845
7523
  exports.OllamaAgentMaxIterationsError = OllamaAgentMaxIterationsError;
6846
7524
  exports.OllamaAgentMaxToolCallsError = OllamaAgentMaxToolCallsError;
7525
+ exports.OllamaAgentToolLoopError = OllamaAgentToolLoopError;
6847
7526
  exports.OllamaAuthError = OllamaAuthError;
6848
7527
  exports.OllamaBadGatewayError = OllamaBadGatewayError;
6849
7528
  exports.OllamaClient = OllamaClient;
@@ -6882,25 +7561,35 @@ exports.ToolCallAccumulator = ToolCallAccumulator;
6882
7561
  exports.ToolRegistry = ToolRegistry;
6883
7562
  exports.addSpanEvent = addSpanEvent;
6884
7563
  exports.applySkill = applySkill;
7564
+ exports.batchEmbed = batchEmbed;
6885
7565
  exports.calculateBackoff = calculateBackoff;
7566
+ exports.canonicalToolCallSignature = canonicalToolCallSignature;
6886
7567
  exports.checkChatContext = checkChatContext;
6887
7568
  exports.checkEndpointHealth = checkEndpointHealth;
6888
7569
  exports.checkGenerateContext = checkGenerateContext;
7570
+ exports.compactConversationHistory = compactConversationHistory;
6889
7571
  exports.composeMiddleware = composeMiddleware;
6890
7572
  exports.contextWarningMessage = contextWarningMessage;
6891
7573
  exports.createConsoleLogger = createConsoleLogger;
6892
7574
  exports.createDecision = createDecision;
7575
+ exports.createModelAffinityScheduler = createModelAffinityScheduler;
6893
7576
  exports.createOllamaCloudFreeTierQuota = createOllamaCloudFreeTierQuota;
6894
7577
  exports.createTimeoutSignal = createTimeoutSignal;
6895
7578
  exports.defineTool = defineTool;
6896
7579
  exports.detectModelCapabilities = detectModelCapabilities;
7580
+ exports.embedBatchOverflowMessage = embedBatchOverflowMessage;
6897
7581
  exports.encodeImage = encodeImage;
6898
7582
  exports.estimateChatRequestTokens = estimateChatRequestTokens;
6899
7583
  exports.estimateGenerateRequestTokens = estimateGenerateRequestTokens;
7584
+ exports.estimateMessageTokens = estimateMessageTokens;
6900
7585
  exports.estimateSystemOneRequestBytes = estimateSystemOneRequestBytes;
6901
7586
  exports.estimateTokens = estimateTokens;
7587
+ exports.extractNativeContextLength = extractContextLength;
7588
+ exports.extractParameterNumCtx = extractParameterNumCtx;
6902
7589
  exports.extractUsage = extractUsage;
6903
7590
  exports.fetchWithBackoff = fetchWithBackoff;
7591
+ exports.findOversizedEmbedInputs = findOversizedEmbedInputs;
7592
+ exports.findRunningModelContextLength = findRunningModelContextLength;
6904
7593
  exports.imageStringNeedsResolution = imageStringNeedsResolution;
6905
7594
  exports.inferRuntimeMode = inferRuntimeMode;
6906
7595
  exports.isKeepAliveSugar = isKeepAliveSugar;
@@ -6922,8 +7611,10 @@ exports.parseNdjsonStream = parseNdjsonStream;
6922
7611
  exports.parseSseStream = parseSseStream;
6923
7612
  exports.parseStructuredOutput = parseStructuredOutput;
6924
7613
  exports.registerMcpTools = registerMcpTools;
7614
+ exports.resolveContextLength = resolveContextLength;
6925
7615
  exports.resolveCredentialEndpoints = resolveCredentialEndpoints;
6926
7616
  exports.resolveImageInput = resolveImageInput;
7617
+ exports.sanitizeHistoryForNextTurn = sanitizeHistoryForNextTurn;
6927
7618
  exports.setSpanError = setSpanError;
6928
7619
  exports.toDataStream = toDataStream;
6929
7620
  exports.toResponse = toResponse;