@nemesis-oss/ollama-sdk 1.1.0 → 1.3.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
@@ -5,6 +5,7 @@ var zod = require('zod');
5
5
  // src/config.ts
6
6
  var DEFAULT_BASE_URL = "http://localhost:11434";
7
7
  var DEFAULT_TIMEOUT_MS = 3e4;
8
+ var OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
8
9
  function readEnv(name) {
9
10
  return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
10
11
  }
@@ -14,6 +15,34 @@ function resolveBaseUrl(configBaseUrl) {
14
15
  function resolveApiKey(configApiKey) {
15
16
  return configApiKey ?? readEnv("OLLAMA_API_KEY");
16
17
  }
18
+ function resolveCredentialEndpoints(config) {
19
+ if (config.credentials === void 0) return [];
20
+ const modelsByCredential = /* @__PURE__ */ new Map();
21
+ for (const [model, bound] of Object.entries(config.modelBindings ?? {})) {
22
+ for (const id of typeof bound === "string" ? [bound] : bound) {
23
+ if (!(id in config.credentials)) {
24
+ throw new Error(
25
+ `modelBindings["${model}"] references unknown credential "${id}" \u2014 it isn't in \`credentials\`. Known credentials: ${Object.keys(config.credentials).join(", ") || "(none)"}.`
26
+ );
27
+ }
28
+ const list = modelsByCredential.get(id) ?? [];
29
+ list.push(model);
30
+ modelsByCredential.set(id, list);
31
+ }
32
+ }
33
+ return Object.entries(config.credentials).map(([id, credential]) => {
34
+ const isDefault = id === config.defaultCredential;
35
+ const boundModels = isDefault ? void 0 : modelsByCredential.get(id);
36
+ return {
37
+ name: `credential:${id}`,
38
+ baseUrl: resolveBaseUrl(credential.baseUrl ?? config.baseUrl),
39
+ apiKey: credential.apiKey,
40
+ ...credential.headers !== void 0 ? { headers: credential.headers } : {},
41
+ ...boundModels !== void 0 ? { models: boundModels } : {},
42
+ priority: isDefault ? -1 : 0
43
+ };
44
+ });
45
+ }
17
46
  var DEFAULT_FAILOVER_CODES = [
18
47
  "network_error",
19
48
  "timeout",
@@ -72,6 +101,15 @@ var OllamaRateLimitError = class extends OllamaClientError {
72
101
  this.retryAfterMs = options?.retryAfterMs;
73
102
  }
74
103
  };
104
+ var OllamaQuotaExceededError = class extends OllamaClientError {
105
+ windowId;
106
+ resetAt;
107
+ constructor(message, options) {
108
+ super(message, { ...options, code: "quota_exceeded", retryable: false });
109
+ this.windowId = options.windowId;
110
+ this.resetAt = options.resetAt;
111
+ }
112
+ };
75
113
  var OllamaServerError = class extends OllamaClientError {
76
114
  constructor(message, options) {
77
115
  super(message, { ...options, code: "server_error", retryable: true });
@@ -107,6 +145,15 @@ var OllamaUnsupportedCapabilityError = class extends OllamaClientError {
107
145
  this.capability = options.capability;
108
146
  }
109
147
  };
148
+ var OllamaModelRoutingError = class extends OllamaClientError {
149
+ model;
150
+ availableModels;
151
+ constructor(message, options) {
152
+ super(message, { ...options, code: "model_routing_error", retryable: false });
153
+ this.model = options.model;
154
+ this.availableModels = options.availableModels;
155
+ }
156
+ };
110
157
  var OllamaAgentMaxIterationsError = class extends OllamaClientError {
111
158
  maxIterations;
112
159
  constructor(message, options) {
@@ -218,17 +265,143 @@ var EndpointRegistry = class {
218
265
  failureThreshold;
219
266
  cooldownMs;
220
267
  now;
268
+ strategy;
269
+ roundRobinCounter = 0;
270
+ activeCounts = /* @__PURE__ */ new Map();
271
+ maxConcurrentPerEndpoint;
272
+ waiters = [];
221
273
  constructor(endpoints, options = {}) {
222
274
  this.endpoints = endpoints.length > 0 ? endpoints : [{ name: "default", baseUrl: "http://localhost:11434" }];
223
275
  this.failureThreshold = options.failureThreshold ?? 3;
224
276
  this.cooldownMs = options.cooldownMs ?? 3e4;
225
277
  this.now = options.now ?? Date.now;
278
+ this.strategy = options.strategy ?? "priority";
279
+ this.maxConcurrentPerEndpoint = options.maxConcurrentPerEndpoint;
226
280
  }
227
- candidates() {
281
+ /**
282
+ * Marks one more request as in-flight against `name`. Callers (`OllamaClient`) must
283
+ * pair every `acquire` with a `release` once that attempt finishes, success or not —
284
+ * see `OllamaClient.executeWithFailover`. Only affects candidate ordering when
285
+ * `strategy` is `'least-connections'`; harmless (just bookkeeping) otherwise, and
286
+ * always reflected in `status()`'s `activeRequests`.
287
+ */
288
+ acquire(name) {
289
+ this.activeCounts.set(name, (this.activeCounts.get(name) ?? 0) + 1);
290
+ }
291
+ /** Releases one in-flight request marked via `acquire`, waking one waiter for `name` if any. */
292
+ release(name) {
293
+ const next = (this.activeCounts.get(name) ?? 0) - 1;
294
+ if (next <= 0) {
295
+ this.activeCounts.delete(name);
296
+ } else {
297
+ this.activeCounts.set(name, next);
298
+ }
299
+ const idx = this.waiters.findIndex((w) => w.names.includes(name));
300
+ if (idx !== -1) {
301
+ const [waiter] = this.waiters.splice(idx, 1);
302
+ waiter?.cleanup();
303
+ waiter?.resolve();
304
+ }
305
+ }
306
+ /**
307
+ * `candidates` filtered down to those currently under `maxConcurrentPerEndpoint` (all
308
+ * of them, unchanged, if that option is unset).
309
+ */
310
+ filterWithCapacity(candidates) {
311
+ const cap = this.maxConcurrentPerEndpoint;
312
+ if (cap === void 0) return [...candidates];
313
+ return candidates.filter((ep) => (this.activeCounts.get(ep.name) ?? 0) < cap);
314
+ }
315
+ /**
316
+ * Resolves once any endpoint named in `names` has spare capacity under
317
+ * `maxConcurrentPerEndpoint` (a no-op resolve if that option is unset, or if one
318
+ * already does). Otherwise queues (FIFO per name) until a matching `release()` frees a
319
+ * slot, or rejects if `signal` aborts first — mirroring whatever aborted it
320
+ * (`OllamaTimeoutError`/the caller's own abort reason) so callers see the same error
321
+ * shape as any other in-flight request that times out or is cancelled. FIFO order is
322
+ * best-effort (a newly-arriving request can still win a race for a slot that just
323
+ * freed) — the capacity cap itself, not fairness, is the guarantee this exists for.
324
+ */
325
+ waitForCapacity(names, signal) {
326
+ const cap = this.maxConcurrentPerEndpoint;
327
+ if (cap === void 0) return Promise.resolve();
328
+ if (names.some((n) => (this.activeCounts.get(n) ?? 0) < cap)) return Promise.resolve();
329
+ return new Promise((resolve, reject) => {
330
+ const waiter = { names, resolve, cleanup: () => void 0 };
331
+ const onAbort = () => {
332
+ const idx = this.waiters.indexOf(waiter);
333
+ if (idx !== -1) this.waiters.splice(idx, 1);
334
+ reject(
335
+ signal?.reason instanceof Error ? signal.reason : new OllamaAbortError("Aborted while waiting for endpoint capacity")
336
+ );
337
+ };
338
+ if (signal !== void 0) {
339
+ if (signal.aborted) {
340
+ onAbort();
341
+ return;
342
+ }
343
+ signal.addEventListener("abort", onAbort, { once: true });
344
+ waiter.cleanup = () => signal.removeEventListener("abort", onAbort);
345
+ }
346
+ this.waiters.push(waiter);
347
+ });
348
+ }
349
+ /**
350
+ * Rotates each same-`priority` group in `sorted` (already sorted highest-priority-first)
351
+ * by one position further per call, wrapping around within the group. Priority tiers
352
+ * themselves are never reordered — only candidates that were already tied.
353
+ */
354
+ applyRoundRobin(sorted) {
355
+ const offset = this.roundRobinCounter++;
356
+ const result = [];
357
+ let i = 0;
358
+ while (i < sorted.length) {
359
+ const priority = sorted[i]?.priority ?? 0;
360
+ let j = i + 1;
361
+ while (j < sorted.length && (sorted[j]?.priority ?? 0) === priority) j++;
362
+ const tier = sorted.slice(i, j);
363
+ const rotation = offset % tier.length;
364
+ result.push(...tier.slice(rotation), ...tier.slice(0, rotation));
365
+ i = j;
366
+ }
367
+ return result;
368
+ }
369
+ /**
370
+ * Reorders each same-`priority` group in `sorted` by ascending in-flight request count
371
+ * (from `acquire`/`release`); a stable sort, so candidates tied on active count keep
372
+ * their relative (priority-then-registration) order. Priority tiers themselves are
373
+ * never reordered.
374
+ */
375
+ applyLeastConnections(sorted) {
376
+ const result = [];
377
+ let i = 0;
378
+ while (i < sorted.length) {
379
+ const priority = sorted[i]?.priority ?? 0;
380
+ let j = i + 1;
381
+ while (j < sorted.length && (sorted[j]?.priority ?? 0) === priority) j++;
382
+ const tier = [...sorted.slice(i, j)].sort(
383
+ (a, b) => (this.activeCounts.get(a.name) ?? 0) - (this.activeCounts.get(b.name) ?? 0)
384
+ );
385
+ result.push(...tier);
386
+ i = j;
387
+ }
388
+ return result;
389
+ }
390
+ /**
391
+ * Endpoints eligible for `model`, sorted healthy-first-by-priority (falling back to
392
+ * soonest-to-recover if every eligible endpoint is cooling down); same-priority
393
+ * candidates are further reordered per `options.strategy` (see
394
+ * `EndpointRegistryOptions.strategy`). When `model` is given, endpoints whose `models`
395
+ * allow-list doesn't include it are excluded entirely — see `OllamaEndpoint.models`.
396
+ * Omit `model` to consider every configured endpoint, regardless of any `models`
397
+ * restriction (used for non-model-scoped operations).
398
+ */
399
+ candidates(model) {
228
400
  const currentTime = this.now();
401
+ const scoped = model === void 0 ? this.endpoints : this.endpoints.filter((ep) => ep.models === void 0 || ep.models.includes(model));
229
402
  const healthy = [];
230
403
  const coolingDown = [];
231
- for (const ep of this.endpoints) {
404
+ for (const ep of scoped) {
232
405
  const failures = this.failureCounts.get(ep.name) ?? 0;
233
406
  const lastFail = this.lastFailures.get(ep.name) ?? 0;
234
407
  const isCooling = failures >= this.failureThreshold && currentTime - lastFail < this.cooldownMs;
@@ -239,7 +412,10 @@ var EndpointRegistry = class {
239
412
  }
240
413
  }
241
414
  if (healthy.length > 0) {
242
- return healthy.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
415
+ const sorted = healthy.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
416
+ if (this.strategy === "round-robin") return this.applyRoundRobin(sorted);
417
+ if (this.strategy === "least-connections") return this.applyLeastConnections(sorted);
418
+ return sorted;
243
419
  }
244
420
  return coolingDown.sort((a, b) => a.recoveredAt - b.recoveredAt).map((c) => c.endpoint);
245
421
  }
@@ -255,6 +431,10 @@ var EndpointRegistry = class {
255
431
  list() {
256
432
  return this.endpoints;
257
433
  }
434
+ /** True if any configured endpoint restricts itself to a `models` allow-list. */
435
+ hasModelScopedEndpoints() {
436
+ return this.endpoints.some((ep) => ep.models !== void 0);
437
+ }
258
438
  status() {
259
439
  const currentTime = this.now();
260
440
  return this.endpoints.map((ep) => {
@@ -265,7 +445,8 @@ var EndpointRegistry = class {
265
445
  endpoint: ep,
266
446
  failureCount: failures,
267
447
  ...lastFail !== void 0 ? { lastFailureTimestamp: lastFail } : {},
268
- isCoolingDown
448
+ isCoolingDown,
449
+ activeRequests: this.activeCounts.get(ep.name) ?? 0
269
450
  };
270
451
  });
271
452
  }
@@ -1024,7 +1205,7 @@ var ModelsClient = class {
1024
1205
  });
1025
1206
  return normalizeProgressStream(stream);
1026
1207
  },
1027
- { ...request, singleEndpoint: true }
1208
+ { ...request, singleEndpoint: true, holdUntil: (stream) => stream.finalResult }
1028
1209
  );
1029
1210
  }
1030
1211
  return this.runner(
@@ -1047,7 +1228,7 @@ var ModelsClient = class {
1047
1228
  });
1048
1229
  return normalizeProgressStream(stream);
1049
1230
  },
1050
- { ...request, singleEndpoint: true }
1231
+ { ...request, singleEndpoint: true, holdUntil: (stream) => stream.finalResult }
1051
1232
  );
1052
1233
  }
1053
1234
  return this.runner(
@@ -1070,7 +1251,7 @@ var ModelsClient = class {
1070
1251
  });
1071
1252
  return normalizeProgressStream(stream);
1072
1253
  },
1073
- { ...request, singleEndpoint: true }
1254
+ { ...request, singleEndpoint: true, holdUntil: (stream) => stream.finalResult }
1074
1255
  );
1075
1256
  }
1076
1257
  return this.runner(
@@ -1226,17 +1407,27 @@ var OllamaClient = class {
1226
1407
  failoverCodes;
1227
1408
  fetchImpl;
1228
1409
  logger;
1410
+ /**
1411
+ * API key for Ollama's hosted web tools (`webSearch`/`webFetch`), which always target
1412
+ * `OLLAMA_CLOUD_BASE_URL` regardless of `config.endpoints` — an Ollama account API key
1413
+ * is inherently a single global credential, not tied to any one inference endpoint.
1414
+ * Resolved the same way as a single-endpoint `apiKey` (`config.apiKey` falling back to
1415
+ * `OLLAMA_API_KEY`), independent of whether `config.endpoints` was used instead.
1416
+ */
1417
+ cloudApiKey;
1229
1418
  constructor(config = {}) {
1230
1419
  const resolvedApiKey = resolveApiKey(config.apiKey);
1231
- const endpoints = config.endpoints ?? [
1420
+ this.cloudApiKey = resolvedApiKey;
1421
+ const credentialEndpoints = resolveCredentialEndpoints(config);
1422
+ const endpoints = config.endpoints ?? (credentialEndpoints.length > 0 ? [] : [
1232
1423
  {
1233
1424
  name: "default",
1234
1425
  baseUrl: resolveBaseUrl(config.baseUrl),
1235
1426
  ...resolvedApiKey !== void 0 ? { apiKey: resolvedApiKey } : {},
1236
1427
  ...config.headers !== void 0 ? { headers: config.headers } : {}
1237
1428
  }
1238
- ];
1239
- this.registry = new EndpointRegistry(endpoints, config.endpointHealth);
1429
+ ]);
1430
+ this.registry = new EndpointRegistry([...endpoints, ...credentialEndpoints], config.endpointHealth);
1240
1431
  this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1241
1432
  this.failoverCodes = new Set(config.failoverOn ?? DEFAULT_FAILOVER_CODES);
1242
1433
  this.fetchImpl = config.fetch ?? globalThis.fetch;
@@ -1265,38 +1456,64 @@ var OllamaClient = class {
1265
1456
  async executeWithFailover(operation, options) {
1266
1457
  const timeout = createTimeoutSignal(options?.timeoutMs ?? this.timeoutMs, options?.signal);
1267
1458
  try {
1268
- const allCandidates = this.registry.candidates();
1269
- const candidates = options?.singleEndpoint ? allCandidates.slice(0, 1) : allCandidates;
1270
1459
  let lastError;
1271
- for (const [attemptIndex, endpoint] of candidates.entries()) {
1272
- this.logger.debug(`Executing on endpoint "${endpoint.name}" (${endpoint.baseUrl})`);
1273
- const http = new HttpClient({
1274
- baseUrl: endpoint.baseUrl,
1275
- ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
1276
- ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
1277
- fetch: this.fetchImpl
1278
- });
1279
- try {
1280
- const result = await withSpan(
1281
- "ollama.endpoint.attempt",
1282
- {
1283
- [ATTR_OLLAMA_ENDPOINT_NAME]: endpoint.name,
1284
- [ATTR_OLLAMA_ENDPOINT_ATTEMPT]: attemptIndex
1285
- },
1286
- () => withRetry(() => operation(http, timeout.signal), this.retryConfig)
1460
+ for (; ; ) {
1461
+ const allCandidates = this.registry.candidates(options?.model);
1462
+ if (allCandidates.length === 0 && options?.model !== void 0 && this.registry.hasModelScopedEndpoints()) {
1463
+ const configured = this.registry.list().flatMap((ep) => ep.models ?? []).filter((m, i, arr) => arr.indexOf(m) === i);
1464
+ throw new OllamaModelRoutingError(
1465
+ `No configured endpoint is authorized for model "${options.model}". ` + (configured.length > 0 ? `Endpoints are scoped to: ${configured.join(", ")}.` : "No endpoint declares a `models` allow-list that includes it.") + " Add or widen an `OllamaEndpoint.models` entry to route this model.",
1466
+ { model: options.model, availableModels: configured }
1287
1467
  );
1288
- this.registry.reportSuccess(endpoint.name);
1289
- return result;
1290
- } catch (err) {
1291
- const error = err instanceof Error ? err : new Error(String(err));
1292
- lastError = error;
1293
- this.registry.reportFailure(endpoint.name);
1294
- this.logger.warn(`Failed on "${endpoint.name}": ${error.message}`);
1295
- if (!(error instanceof OllamaClientError && this.failoverCodes.has(error.code)))
1296
- throw error;
1297
1468
  }
1469
+ const candidates = options?.singleEndpoint ? allCandidates.slice(0, 1) : allCandidates;
1470
+ const runnable = this.registry.filterWithCapacity(candidates);
1471
+ if (runnable.length === 0 && candidates.length > 0) {
1472
+ await this.registry.waitForCapacity(
1473
+ candidates.map((c) => c.name),
1474
+ timeout.signal
1475
+ );
1476
+ continue;
1477
+ }
1478
+ for (const [attemptIndex, endpoint] of runnable.entries()) {
1479
+ this.logger.debug(`Executing on endpoint "${endpoint.name}" (${endpoint.baseUrl})`);
1480
+ const http = new HttpClient({
1481
+ baseUrl: endpoint.baseUrl,
1482
+ ...endpoint.apiKey !== void 0 ? { apiKey: endpoint.apiKey } : {},
1483
+ ...endpoint.headers !== void 0 ? { headers: endpoint.headers } : {},
1484
+ fetch: this.fetchImpl
1485
+ });
1486
+ this.registry.acquire(endpoint.name);
1487
+ let holdPromise;
1488
+ try {
1489
+ const result = await withSpan(
1490
+ "ollama.endpoint.attempt",
1491
+ {
1492
+ [ATTR_OLLAMA_ENDPOINT_NAME]: endpoint.name,
1493
+ [ATTR_OLLAMA_ENDPOINT_ATTEMPT]: attemptIndex
1494
+ },
1495
+ () => withRetry(() => operation(http, timeout.signal), this.retryConfig)
1496
+ );
1497
+ this.registry.reportSuccess(endpoint.name);
1498
+ if (options?.holdUntil) holdPromise = options.holdUntil(result);
1499
+ return result;
1500
+ } catch (err) {
1501
+ const error = err instanceof Error ? err : new Error(String(err));
1502
+ lastError = error;
1503
+ this.registry.reportFailure(endpoint.name);
1504
+ this.logger.warn(`Failed on "${endpoint.name}": ${error.message}`);
1505
+ if (!(error instanceof OllamaClientError && this.failoverCodes.has(error.code)))
1506
+ throw error;
1507
+ } finally {
1508
+ if (holdPromise) {
1509
+ void holdPromise.catch(() => void 0).finally(() => this.registry.release(endpoint.name));
1510
+ } else {
1511
+ this.registry.release(endpoint.name);
1512
+ }
1513
+ }
1514
+ }
1515
+ throw lastError ?? new Error("No healthy Ollama endpoints available");
1298
1516
  }
1299
- throw lastError ?? new Error("No healthy Ollama endpoints available");
1300
1517
  } finally {
1301
1518
  timeout.cancel();
1302
1519
  }
@@ -1304,15 +1521,27 @@ var OllamaClient = class {
1304
1521
  async chat(req) {
1305
1522
  const messages = await withEncodedMessageImages(req.messages);
1306
1523
  if (req.stream) {
1307
- return this.executeWithFailover(async (http, signal) => {
1308
- if (req.format !== void 0) this.assertStructuredOutputSupported(http.baseUrl, req.model);
1309
- const stream = await http.requestStream({
1310
- path: "/api/chat",
1311
- body: { ...req, messages, stream: true },
1312
- signal
1313
- });
1314
- return normalizeChatStream(stream);
1315
- }, req);
1524
+ return this.executeWithFailover(
1525
+ async (http, signal) => {
1526
+ if (req.format !== void 0)
1527
+ this.assertStructuredOutputSupported(http.baseUrl, req.model);
1528
+ const stream = await http.requestStream({
1529
+ path: "/api/chat",
1530
+ body: { ...req, messages, stream: true },
1531
+ signal
1532
+ });
1533
+ return normalizeChatStream(stream);
1534
+ },
1535
+ {
1536
+ signal: req.signal,
1537
+ timeoutMs: req.timeoutMs,
1538
+ model: req.model,
1539
+ // The endpoint's concurrency slot must stay held for as long as the stream is
1540
+ // actually being consumed, not just until the initial response/headers arrive
1541
+ // — see `executeWithFailover`'s `holdUntil` doc.
1542
+ holdUntil: (stream) => stream.finalResult
1543
+ }
1544
+ );
1316
1545
  }
1317
1546
  return withSpan(
1318
1547
  `chat ${req.model}`,
@@ -1356,16 +1585,24 @@ var OllamaClient = class {
1356
1585
  async generate(req) {
1357
1586
  const encodedReq = await withEncodedImages(req);
1358
1587
  if (encodedReq.stream) {
1359
- return this.executeWithFailover(async (http, signal) => {
1360
- if (encodedReq.format !== void 0)
1361
- this.assertStructuredOutputSupported(http.baseUrl, encodedReq.model);
1362
- const stream = await http.requestStream({
1363
- path: "/api/generate",
1364
- body: { ...encodedReq, stream: true },
1365
- signal
1366
- });
1367
- return normalizeGenerateStream(stream);
1368
- }, encodedReq);
1588
+ return this.executeWithFailover(
1589
+ async (http, signal) => {
1590
+ if (encodedReq.format !== void 0)
1591
+ this.assertStructuredOutputSupported(http.baseUrl, encodedReq.model);
1592
+ const stream = await http.requestStream({
1593
+ path: "/api/generate",
1594
+ body: { ...encodedReq, stream: true },
1595
+ signal
1596
+ });
1597
+ return normalizeGenerateStream(stream);
1598
+ },
1599
+ {
1600
+ signal: encodedReq.signal,
1601
+ timeoutMs: encodedReq.timeoutMs,
1602
+ model: encodedReq.model,
1603
+ holdUntil: (stream) => stream.finalResult
1604
+ }
1605
+ );
1369
1606
  }
1370
1607
  return withSpan(
1371
1608
  `text_completion ${encodedReq.model}`,
@@ -1439,22 +1676,60 @@ var OllamaClient = class {
1439
1676
  createBlob = (digest, data) => this.models.createBlob(digest, data);
1440
1677
  checkBlob = (digest) => this.models.checkBlob(digest);
1441
1678
  // --- Web Endpoints ---
1442
- webSearch(req) {
1443
- return this.executeWithFailover(
1444
- (http, signal) => http.request({ path: "/api/websearch", body: req, signal }),
1679
+ /**
1680
+ * Ollama's hosted web search tool (`POST https://ollama.com/api/web_search`) — a fixed
1681
+ * Ollama Cloud service, unrelated to `config.endpoints`/`baseUrl` and not proxied
1682
+ * through a local server. Requires an Ollama account API key: pass `apiKey` to the
1683
+ * client constructor or set `OLLAMA_API_KEY`. Applies the same default `timeoutMs` and
1684
+ * retry policy as inference calls, but never cross-endpoint-fails-over — there is only
1685
+ * ever the one cloud endpoint to call.
1686
+ */
1687
+ async webSearch(req) {
1688
+ const { count, max_results, ...rest } = req;
1689
+ const res = await this.executeCloudRequest(
1690
+ (http, signal) => http.request({
1691
+ path: "/api/web_search",
1692
+ body: { ...rest, max_results: max_results ?? count },
1693
+ signal
1694
+ }),
1445
1695
  req
1446
1696
  );
1697
+ return { results: res.results.map((r) => ({ ...r, snippet: r.content })) };
1447
1698
  }
1699
+ /**
1700
+ * Ollama's hosted web fetch tool (`POST https://ollama.com/api/web_fetch`) — see
1701
+ * {@link OllamaClient.webSearch} for the cloud-endpoint/auth/timeout/retry behavior,
1702
+ * which applies identically here.
1703
+ */
1448
1704
  webFetch(req) {
1449
- return this.executeWithFailover(
1450
- (http, signal) => http.request({ path: "/api/webfetch", body: req, signal }),
1705
+ return this.executeCloudRequest(
1706
+ (http, signal) => http.request({ path: "/api/web_fetch", body: req, signal }),
1451
1707
  req
1452
1708
  );
1453
1709
  }
1710
+ /**
1711
+ * Runs `operation` against the fixed Ollama Cloud host with this client's default
1712
+ * timeout and retry policy — the same primitives `executeWithFailover` uses, minus the
1713
+ * multi-candidate loop, since `webSearch`/`webFetch` only ever have the one endpoint.
1714
+ */
1715
+ async executeCloudRequest(operation, options) {
1716
+ const timeout = createTimeoutSignal(options.timeoutMs ?? this.timeoutMs, options.signal);
1717
+ try {
1718
+ const http = new HttpClient({
1719
+ baseUrl: OLLAMA_CLOUD_BASE_URL,
1720
+ apiKey: this.cloudApiKey,
1721
+ fetch: this.fetchImpl
1722
+ });
1723
+ return await withRetry(() => operation(http, timeout.signal), this.retryConfig);
1724
+ } finally {
1725
+ timeout.cancel();
1726
+ }
1727
+ }
1454
1728
  // --- Capabilities & Health ---
1455
1729
  capabilities(model) {
1456
1730
  return this.executeWithFailover((http) => detectModelCapabilities(http, model), {
1457
- singleEndpoint: true
1731
+ singleEndpoint: true,
1732
+ model
1458
1733
  });
1459
1734
  }
1460
1735
  runtimeMode() {
@@ -1572,6 +1847,117 @@ function toResponse(stream, init = {}) {
1572
1847
  return new Response(body, { ...init, headers });
1573
1848
  }
1574
1849
 
1850
+ // src/quota.ts
1851
+ var QuotaManager = class {
1852
+ now;
1853
+ windows;
1854
+ constructor(options) {
1855
+ if (options.windows.length === 0) {
1856
+ throw new Error("QuotaManager requires at least one window configuration.");
1857
+ }
1858
+ this.now = options.now ?? Date.now;
1859
+ this.windows = options.windows.map((config) => ({
1860
+ config,
1861
+ tokensUsed: 0,
1862
+ requestsMade: 0,
1863
+ windowStartedAt: this.now()
1864
+ }));
1865
+ }
1866
+ rollover(state) {
1867
+ if (this.now() - state.windowStartedAt >= state.config.windowMs) {
1868
+ state.tokensUsed = 0;
1869
+ state.requestsMade = 0;
1870
+ state.windowStartedAt = this.now();
1871
+ }
1872
+ }
1873
+ /** Current usage, budgets, and reset time for every configured window. */
1874
+ status() {
1875
+ return this.windows.map((state) => {
1876
+ this.rollover(state);
1877
+ const { config } = state;
1878
+ return {
1879
+ ...config,
1880
+ tokensUsed: state.tokensUsed,
1881
+ requestsMade: state.requestsMade,
1882
+ windowStartedAt: state.windowStartedAt,
1883
+ windowResetAt: state.windowStartedAt + config.windowMs,
1884
+ ...config.maxTokens !== void 0 ? { remainingTokens: Math.max(0, config.maxTokens - state.tokensUsed) } : {},
1885
+ ...config.maxRequests !== void 0 ? { remainingRequests: Math.max(0, config.maxRequests - state.requestsMade) } : {}
1886
+ };
1887
+ });
1888
+ }
1889
+ /**
1890
+ * True if one more request — optionally estimated at `estimatedTokens` — would stay
1891
+ * within every configured window's budget. Read-only; does not record usage.
1892
+ */
1893
+ canProceed(estimatedTokens = 0) {
1894
+ return this.windows.every((state) => {
1895
+ this.rollover(state);
1896
+ const { config } = state;
1897
+ if (config.maxRequests !== void 0 && state.requestsMade + 1 > config.maxRequests) {
1898
+ return false;
1899
+ }
1900
+ return !(config.maxTokens !== void 0 && state.tokensUsed + estimatedTokens > config.maxTokens);
1901
+ });
1902
+ }
1903
+ /**
1904
+ * Throws `OllamaQuotaExceededError` for the first window that `canProceed(estimatedTokens)`
1905
+ * would fail on; otherwise a no-op. Call before issuing a request.
1906
+ */
1907
+ assertCanProceed(estimatedTokens = 0) {
1908
+ for (const state of this.windows) {
1909
+ this.rollover(state);
1910
+ const { config } = state;
1911
+ if (config.maxRequests !== void 0 && state.requestsMade + 1 > config.maxRequests) {
1912
+ throw new OllamaQuotaExceededError(
1913
+ `Client-side quota window "${config.id}" would exceed its request budget (${state.requestsMade}/${config.maxRequests} requests used).`,
1914
+ { windowId: config.id, resetAt: state.windowStartedAt + config.windowMs }
1915
+ );
1916
+ }
1917
+ if (config.maxTokens !== void 0 && state.tokensUsed + estimatedTokens > config.maxTokens) {
1918
+ throw new OllamaQuotaExceededError(
1919
+ `Client-side quota window "${config.id}" would exceed its token budget (${state.tokensUsed}/${config.maxTokens} tokens used).`,
1920
+ { windowId: config.id, resetAt: state.windowStartedAt + config.windowMs }
1921
+ );
1922
+ }
1923
+ }
1924
+ }
1925
+ /** Records one request's actual usage — a raw API response or a pre-normalized `TokenUsage` — against every window. */
1926
+ recordUsage(usage) {
1927
+ const normalized = "totalTokens" in usage ? usage : extractUsage(usage);
1928
+ for (const state of this.windows) {
1929
+ this.rollover(state);
1930
+ state.tokensUsed += normalized.totalTokens;
1931
+ state.requestsMade += 1;
1932
+ }
1933
+ }
1934
+ /** Resets one window (by `id`) or, if omitted, all windows — zeroing usage and restarting their clocks. */
1935
+ reset(windowId) {
1936
+ for (const state of this.windows) {
1937
+ if (windowId === void 0 || state.config.id === windowId) {
1938
+ state.tokensUsed = 0;
1939
+ state.requestsMade = 0;
1940
+ state.windowStartedAt = this.now();
1941
+ }
1942
+ }
1943
+ }
1944
+ };
1945
+ function createOllamaCloudFreeTierQuota(budgets) {
1946
+ const windows = [];
1947
+ if (budgets.session !== void 0) {
1948
+ windows.push({ id: "session", windowMs: 5 * 60 * 60 * 1e3, ...budgets.session });
1949
+ }
1950
+ if (budgets.weekly !== void 0) {
1951
+ windows.push({ id: "weekly", windowMs: 7 * 24 * 60 * 60 * 1e3, ...budgets.weekly });
1952
+ }
1953
+ if (windows.length === 0) {
1954
+ throw new Error(
1955
+ "createOllamaCloudFreeTierQuota requires at least a `session` or `weekly` budget."
1956
+ );
1957
+ }
1958
+ return new QuotaManager({ windows, ...budgets.now !== void 0 ? { now: budgets.now } : {} });
1959
+ }
1960
+
1575
1961
  // src/tools/define-tool.ts
1576
1962
  function defineTool(options) {
1577
1963
  const jsonSchema = zodToJsonSchema(options.schema);
@@ -2038,6 +2424,7 @@ exports.ModelsClient = ModelsClient;
2038
2424
  exports.NANOS_PER_MS = NANOS_PER_MS;
2039
2425
  exports.NANOS_PER_SECOND = NANOS_PER_SECOND;
2040
2426
  exports.NOOP_LOGGER = NOOP_LOGGER;
2427
+ exports.OLLAMA_CLOUD_BASE_URL = OLLAMA_CLOUD_BASE_URL;
2041
2428
  exports.OllamaAbortError = OllamaAbortError;
2042
2429
  exports.OllamaAgentMaxIterationsError = OllamaAgentMaxIterationsError;
2043
2430
  exports.OllamaAuthError = OllamaAuthError;
@@ -2045,8 +2432,10 @@ exports.OllamaClient = OllamaClient;
2045
2432
  exports.OllamaClientError = OllamaClientError;
2046
2433
  exports.OllamaGenericClientError = OllamaGenericClientError;
2047
2434
  exports.OllamaMcpError = OllamaMcpError;
2435
+ exports.OllamaModelRoutingError = OllamaModelRoutingError;
2048
2436
  exports.OllamaNetworkError = OllamaNetworkError;
2049
2437
  exports.OllamaNotFoundError = OllamaNotFoundError;
2438
+ exports.OllamaQuotaExceededError = OllamaQuotaExceededError;
2050
2439
  exports.OllamaRateLimitError = OllamaRateLimitError;
2051
2440
  exports.OllamaServerError = OllamaServerError;
2052
2441
  exports.OllamaSkillInvalidError = OllamaSkillInvalidError;
@@ -2057,6 +2446,7 @@ exports.OllamaToolTimeoutError = OllamaToolTimeoutError;
2057
2446
  exports.OllamaToolValidationError = OllamaToolValidationError;
2058
2447
  exports.OllamaUnsupportedCapabilityError = OllamaUnsupportedCapabilityError;
2059
2448
  exports.OpenAICompatClient = OpenAICompatClient;
2449
+ exports.QuotaManager = QuotaManager;
2060
2450
  exports.ToolRegistry = ToolRegistry;
2061
2451
  exports.addSpanEvent = addSpanEvent;
2062
2452
  exports.applySkill = applySkill;
@@ -2064,6 +2454,7 @@ exports.calculateBackoff = calculateBackoff;
2064
2454
  exports.checkEndpointHealth = checkEndpointHealth;
2065
2455
  exports.composeMiddleware = composeMiddleware;
2066
2456
  exports.createConsoleLogger = createConsoleLogger;
2457
+ exports.createOllamaCloudFreeTierQuota = createOllamaCloudFreeTierQuota;
2067
2458
  exports.createTimeoutSignal = createTimeoutSignal;
2068
2459
  exports.defineTool = defineTool;
2069
2460
  exports.detectModelCapabilities = detectModelCapabilities;
@@ -2080,6 +2471,7 @@ exports.parseFrontmatter = parseFrontmatter;
2080
2471
  exports.parseNdjsonStream = parseNdjsonStream;
2081
2472
  exports.parseStructuredOutput = parseStructuredOutput;
2082
2473
  exports.registerMcpTools = registerMcpTools;
2474
+ exports.resolveCredentialEndpoints = resolveCredentialEndpoints;
2083
2475
  exports.setSpanError = setSpanError;
2084
2476
  exports.toDataStream = toDataStream;
2085
2477
  exports.toResponse = toResponse;