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