auto-model-router 0.2.32 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +225 -29
  3. package/docs/review-2026-09-05.md +267 -0
  4. package/omp-extension/configure-logic.ts +71 -15
  5. package/omp-extension/pi-coding-agent.d.ts +79 -2
  6. package/omp-extension/report-hub.ts +376 -0
  7. package/omp-extension/report-logic.ts +117 -0
  8. package/omp-extension/router-configure.ts +203 -51
  9. package/omp-extension/router-url.ts +52 -0
  10. package/omp-extension/toast-logic.ts +14 -2
  11. package/package.json +1 -1
  12. package/src/catalog/composite.ts +97 -0
  13. package/src/catalog/ollama-catalog.ts +309 -0
  14. package/src/catalog/ollama-prices.ts +85 -0
  15. package/src/catalog/openrouter-catalog.ts +39 -1
  16. package/src/catalog/types.ts +31 -1
  17. package/src/cli/args.ts +1 -0
  18. package/src/cli/config-wizard.ts +190 -28
  19. package/src/cli/explain.ts +2 -4
  20. package/src/cli/models.ts +2 -4
  21. package/src/cli/report.ts +37 -0
  22. package/src/config/defaults.ts +46 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +28 -0
  26. package/src/config/types.ts +120 -2
  27. package/src/cost/cache-estimate.ts +52 -0
  28. package/src/cost/ledger.ts +73 -4
  29. package/src/cost/report.ts +351 -0
  30. package/src/cost/types.ts +39 -1
  31. package/src/index.ts +5 -8
  32. package/src/router/candidates.ts +52 -4
  33. package/src/router/classify.ts +33 -6
  34. package/src/router/features.ts +13 -1
  35. package/src/router/select.ts +55 -8
  36. package/src/router/state.ts +6 -2
  37. package/src/router/tier-plan.ts +49 -11
  38. package/src/router/types.ts +10 -0
  39. package/src/server/http.ts +50 -6
  40. package/src/server/providers.ts +54 -0
  41. package/src/server/turn.ts +138 -34
  42. package/src/tokens/estimate.ts +16 -0
  43. package/src/upstream/multi.ts +26 -0
  44. package/src/upstream/ollama-usage.ts +163 -0
  45. package/src/upstream/ollama.ts +275 -0
  46. package/src/upstream/openrouter.ts +19 -1
  47. package/src/upstream/types.ts +2 -0
  48. package/src/util/sqlite.ts +25 -1
  49. package/test/cache-estimate.test.ts +48 -0
  50. package/test/catalog.test.ts +44 -0
  51. package/test/classify.test.ts +41 -5
  52. package/test/compaction.test.ts +1 -0
  53. package/test/config-wizard.test.ts +77 -1
  54. package/test/configure-logic.test.ts +129 -33
  55. package/test/embed-lifecycle.test.ts +1 -0
  56. package/test/failover.test.ts +148 -3
  57. package/test/features.test.ts +35 -0
  58. package/test/http-resilience.test.ts +24 -0
  59. package/test/ollama.test.ts +521 -0
  60. package/test/omp-credentials.test.ts +43 -1
  61. package/test/report-hub.test.ts +343 -0
  62. package/test/report-logic.test.ts +93 -0
  63. package/test/report.test.ts +233 -0
  64. package/test/select.test.ts +151 -1
  65. package/test/tier-plan.test.ts +159 -1
  66. package/test/toast-logic.test.ts +11 -2
  67. package/test/tokens.test.ts +71 -1
  68. package/test/trust-attribution.test.ts +2 -2
  69. package/test/turn.test.ts +173 -7
  70. package/tools/recompute-ollama-cache.ts +129 -0
@@ -11,9 +11,12 @@
11
11
  import type { CatalogSource } from "../catalog/types.ts";
12
12
  import type { ContextBridge } from "../context/types.ts";
13
13
  import type { RouterConfig } from "../config/types.ts";
14
+ import { estimateUnreportedCache } from "../cost/cache-estimate.ts";
15
+ import { computeCost } from "../cost/forecast.ts";
14
16
  import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
15
17
  import { createProbe, type Probe } from "../router/escalate.ts";
16
18
  import { resolveHoldTurns } from "../router/explore.ts";
19
+ import { adjustPendingEstimate } from "../tokens/estimate.ts";
17
20
  import {
18
21
  TIER_ORDER,
19
22
  type ConversationStore,
@@ -36,6 +39,23 @@ import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../w
36
39
  */
37
40
  const MAX_SAME_TIER_FAILOVERS = 2;
38
41
 
42
+ /**
43
+ * Probe signals that indict the PROVIDER rather than the tier: an empty
44
+ * stream, a refusal, or an error finish says nothing about whether the work
45
+ * needed a stronger model. Measured on 7 days of live traffic, 49 of 71
46
+ * escalations re-dispatched the very slug that had just failed, one tier up,
47
+ * paying the tier premium for a provider hiccup. These signals get the same
48
+ * same-tier failover an HTTP 5xx gets, and only then step up a tier. Structural
49
+ * signals (malformed arguments, a repeated call) still escalate directly — a
50
+ * stronger model is the remedy there.
51
+ */
52
+ const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error"]);
53
+
54
+ /** A client hang-up: the request signal fired, or the transport reported the abort. */
55
+ function isClientAbort(err: unknown, signal: AbortSignal): boolean {
56
+ return signal.aborted || (err instanceof UpstreamError && err.kind === "aborted");
57
+ }
58
+
39
59
  export interface TurnDeps {
40
60
  config: RouterConfig;
41
61
  router: Router;
@@ -170,6 +190,14 @@ export async function runTurn(
170
190
  ...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
171
191
  });
172
192
 
193
+ // Calibrate the token estimate against the bytes that actually go out —
194
+ // after compaction shrank the prompt and the context block was appended
195
+ // — not the raw request the estimate was taken from.
196
+ adjustPendingEstimate(
197
+ req.conversationKey,
198
+ req.promptBytes - decision.compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
199
+ );
200
+
173
201
  // Our own abort composes with the client's: escalation teardown and
174
202
  // client disconnect both kill the upstream connection.
175
203
  const attemptAbort = new AbortController();
@@ -196,6 +224,7 @@ export async function runTurn(
196
224
  if (generationId === null && dispatch !== null) {
197
225
  generationId = await dispatch.generationId().catch(() => null);
198
226
  }
227
+ const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
199
228
  ledger.record({
200
229
  id: crypto.randomUUID(),
201
230
  createdAtMs: Date.now(),
@@ -229,6 +258,9 @@ export async function runTurn(
229
258
  upstreamGenerationId: generationId,
230
259
  error: fields.error,
231
260
  promptTokensSaved: decision.promptTokensSaved,
261
+ // The ledger prices OpenRouter slugs from its own cached payload; any
262
+ // other provider's model exists only in the live catalog.
263
+ ...(priceModel === undefined ? {} : { priceModel }),
232
264
  });
233
265
  // Book the money HERE, beside the ledger row, so the two can never
234
266
  // disagree. Every dispatch that reaches this point was billed —
@@ -239,6 +271,28 @@ export async function runTurn(
239
271
  conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
240
272
  };
241
273
 
274
+ // Same-tier failover: re-route with every failed slug excluded and accept
275
+ // the result only if it stays in this tier on a different model. A
276
+ // 404/429/5xx, an empty stream, or a refusal indicts the slug, not the
277
+ // tier, so a sibling is tried before the tier is abandoned. Returns null
278
+ // when the bound is hit or the router widened tiers on its own; the
279
+ // caller then falls through to tier escalation.
280
+ const trySameTierFailover = async (why: string): Promise<Decision | null> => {
281
+ if (sameTierFailovers >= MAX_SAME_TIER_FAILOVERS) return null;
282
+ let failover: Decision | null = null;
283
+ try {
284
+ failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
285
+ } catch {
286
+ // A routing failure here must not kill the turn; tier escalation
287
+ // may still find a model.
288
+ return null;
289
+ }
290
+ if (failover === null || failover.tier !== decision.tier || failedSlugs.includes(failover.slug)) return null;
291
+ sameTierFailovers++;
292
+ failover.reasons = [...failover.reasons, `failover: ${decision.slug} ${why}; retrying ${failover.slug} in ${failover.tier}`];
293
+ return failover;
294
+ };
295
+
242
296
  // "retry" re-enters the attempt loop; "done" means the turn is settled
243
297
  // (client told, or client gone) and runTurn must return.
244
298
  const onUpstreamError = async (err: unknown): Promise<"retry" | "done"> => {
@@ -261,30 +315,15 @@ export async function runTurn(
261
315
  }
262
316
  if (uerr.retryable && attempt + 1 < maxAttempts) {
263
317
  failedSlugs.push(decision.slug);
264
- if (sameTierFailovers < MAX_SAME_TIER_FAILOVERS) {
265
- // Before jumping a tier, try a DIFFERENT model in the same
266
- // tier: a 404/429/5xx indicts the slug, not the tier.
267
- let failover: Decision | null = null;
268
- try {
269
- failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
270
- } catch {
271
- // A routing failure here must not kill the turn; tier
272
- // escalation below may still find a model.
273
- failover = null;
274
- }
275
- if (failover !== null && failover.tier === decision.tier && !failedSlugs.includes(failover.slug)) {
276
- sameTierFailovers++;
277
- failover.reasons = [
278
- ...failover.reasons,
279
- `failover: ${decision.slug} returned ${uerr.kind}; retrying ${failover.slug} in ${failover.tier}`,
280
- ];
281
- pendingDecision = failover;
282
- await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
283
- return "retry";
284
- }
285
- // No different candidate at this tier — the router widened on
286
- // its own or only the failed slug qualifies. Fall through to
287
- // tier escalation.
318
+ // Before jumping a tier, try a DIFFERENT model in the same tier:
319
+ // a 404/429/5xx indicts the slug, not the tier. Null means no other
320
+ // candidate at this tier the router widened on its own or only
321
+ // the failed slug qualifies so fall through to tier escalation.
322
+ const failover = await trySameTierFailover(`returned ${uerr.kind}`);
323
+ if (failover !== null) {
324
+ pendingDecision = failover;
325
+ await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
326
+ return "retry";
288
327
  }
289
328
  const topTier = TIER_ORDER[TIER_ORDER.length - 1];
290
329
  if (decision.tier !== topTier) {
@@ -307,6 +346,11 @@ export async function runTurn(
307
346
  let escalateVerdict: Extract<ProbeVerdict, { action: "escalate" }> | null = null;
308
347
  let streamError: unknown = null;
309
348
  let sinkDied = false;
349
+ // The upstream generation ran to its end — either the stream closed
350
+ // normally, or the client hung up after the finish event had already
351
+ // arrived. Both are settled generations; only the second used to be
352
+ // recorded as an error.
353
+ let streamEnded = false;
310
354
 
311
355
  try {
312
356
  dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
@@ -374,21 +418,22 @@ export async function runTurn(
374
418
  attemptAbort.abort();
375
419
  break;
376
420
  }
377
- if (!committed && escalateVerdict === null) {
378
- const verdict = probe.verdictOnEnd();
379
- if (verdict.action === "commit") {
380
- committed = true;
381
- for (const heldChunk of probe.held()) await emit(heldChunk);
382
- } else {
383
- escalateVerdict = verdict;
384
- attemptAbort.abort();
385
- }
386
- }
421
+ if (escalateVerdict === null) streamEnded = true;
387
422
  } catch (err) {
388
423
  if (escalateVerdict !== null || attemptAbort.signal.aborted) {
389
424
  // Teardown noise from our own abort; the escalate path owns the outcome.
390
425
  } else if (err instanceof SinkError) {
391
426
  sinkDied = true;
427
+ } else if (finishReason !== null && isClientAbort(err, signal)) {
428
+ // The client closed the connection AFTER the finish event: the
429
+ // generation is complete and billed, the client already has its
430
+ // answer, only the trailing `[DONE]` went unread. Measured live:
431
+ // 1,842 of 2,068 "request aborted" rows carried a finish reason
432
+ // and full usage, and every one skipped the state save below —
433
+ // stale hysteresis, cache-warmth, and compaction state on 12% of
434
+ // turns. A settled generation is settled however the socket
435
+ // closed.
436
+ streamEnded = true;
392
437
  } else {
393
438
  streamError = err;
394
439
  }
@@ -396,6 +441,44 @@ export async function runTurn(
396
441
  // Never leak the upstream connection, whatever happened above.
397
442
  attemptAbort.abort();
398
443
  }
444
+ if (streamEnded && !committed && escalateVerdict === null) {
445
+ const verdict = probe.verdictOnEnd();
446
+ if (verdict.action === "commit") {
447
+ committed = true;
448
+ try {
449
+ for (const heldChunk of probe.held()) await emit(heldChunk);
450
+ } catch (err) {
451
+ if (err instanceof SinkError) sinkDied = true;
452
+ else streamError = err;
453
+ }
454
+ } else {
455
+ escalateVerdict = verdict;
456
+ }
457
+ }
458
+ }
459
+
460
+ // A provider that reports no cost (Ollama) still reports usage: price the
461
+ // actual tokens at the served model's catalog rate rather than leaving the
462
+ // pre-dispatch forecast (with its assumed 1,024 completion tokens) to stand
463
+ // in for a turn that produced 26.
464
+ if (reportedUsd === null && usage.promptTokens > 0) {
465
+ const served = deps.catalog.find(servedSlug ?? decision.slug);
466
+ if (served !== undefined) {
467
+ // Ollama caches prompt prefixes and bills them at its cached rate
468
+ // without reporting a count; estimate it with the router's own
469
+ // warm-cache rule so the ledger stops booking every token fresh.
470
+ if (served.provider === "ollama") {
471
+ usage = estimateUnreportedCache(usage, {
472
+ previousSlug: state.currentSlug,
473
+ previousPromptTokens: state.lastPromptTokens,
474
+ previousAtMs: state.updatedAtMs,
475
+ servedSlug: served.slug,
476
+ nowMs: Date.now(),
477
+ cacheWarmTtlMs: config.hysteresis.cacheWarmTtlMs,
478
+ });
479
+ }
480
+ reportedUsd = computeCost(served, usage).total;
481
+ }
399
482
  }
400
483
 
401
484
  if (sinkDied) {
@@ -408,6 +491,26 @@ export async function runTurn(
408
491
  }
409
492
 
410
493
  if (escalateVerdict !== null) {
494
+ // The model that produced the rejected output must not serve the
495
+ // retry, at this tier or the next: without this, 49 of 71 measured
496
+ // escalations re-dispatched the same slug one tier up.
497
+ failedSlugs.push(decision.slug);
498
+ if (PROVIDER_SIGNALS.has(escalateVerdict.signal) && attempt + 1 < maxAttempts) {
499
+ const failover = await trySameTierFailover(`${escalateVerdict.signal} (${escalateVerdict.reason})`);
500
+ if (failover !== null) {
501
+ pendingDecision = failover;
502
+ await writeEntry({ wasted: true, escalationSignal: escalateVerdict.signal, error: null });
503
+ log.info("same-tier failover", {
504
+ signal: escalateVerdict.signal,
505
+ reason: escalateVerdict.reason,
506
+ from: decision.slug,
507
+ to: failover.slug,
508
+ tier: decision.tier,
509
+ attempt,
510
+ });
511
+ continue;
512
+ }
513
+ }
411
514
  const hasRunway = attempt + 1 < maxAttempts && decision.probe.escalateTo !== null;
412
515
  if (hasRunway) {
413
516
  escalations++;
@@ -461,6 +564,7 @@ export async function runTurn(
461
564
  // it verbatim (after byte-length validation), keeping shrunk tool results
462
565
  // shrunk so the prompt cache survives and the savings compound.
463
566
  state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
567
+ state.compactionPlanTokens = decision.compactionPlanTokens;
464
568
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
465
569
  // Non-zero cache traffic is direct evidence the upstream cache exists.
466
570
  state.cacheWarmSlug = servedSlug ?? decision.slug;
@@ -66,6 +66,22 @@ export function estimatePromptTokens(req: NormRequest, tokenizer: string, ledger
66
66
  return tokens;
67
67
  }
68
68
 
69
+ /**
70
+ * Replaces the byte count the pending estimate will be calibrated against.
71
+ *
72
+ * The estimate is taken from the RAW request, but what the upstream bills is
73
+ * the prompt after compaction shrank it and the context block was appended.
74
+ * Pairing raw bytes with billed tokens taught every family a ratio that had
75
+ * compaction baked in (measured: the compacted estimate ran 33% under the
76
+ * billed count). The turn orchestrator calls this with the dispatched size
77
+ * once it knows it, so the ratio describes the tokenizer and nothing else.
78
+ */
79
+ export function adjustPendingEstimate(conversationKey: string, dispatchedBytes: number): void {
80
+ const pending = pendingEstimates.get(conversationKey);
81
+ if (pending === undefined || dispatchedBytes <= 0) return;
82
+ pending.bytes = dispatchedBytes;
83
+ }
84
+
69
85
  /** Internal: called by cost/ledger.ts when recording a turn. */
70
86
  export function consumePendingEstimate(conversationKey: string): { tokenizer: string; bytes: number } | null {
71
87
  const pending = pendingEstimates.get(conversationKey) ?? null;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * One `UpstreamClient` over several providers, keyed by catalog slug prefix.
3
+ *
4
+ * `ollama/…` slugs go to the Ollama client; everything else is OpenRouter.
5
+ * Catalog fetches and the adjudicator's `complete` stay on OpenRouter, whose
6
+ * catalog is the router's baseline; Ollama's own listing is read by its
7
+ * catalog source, not through this seam.
8
+ */
9
+
10
+ import { isOllamaSlug } from "../catalog/ollama-catalog.ts";
11
+ import type { Dispatch, DispatchOptions, UpstreamClient } from "./types.ts";
12
+
13
+ export function createMultiUpstream(openrouter: UpstreamClient, ollama: UpstreamClient): UpstreamClient {
14
+ return {
15
+ dispatch(opts: DispatchOptions): Promise<Dispatch> {
16
+ const model = opts.body.model;
17
+ return typeof model === "string" && isOllamaSlug(model) ? ollama.dispatch(opts) : openrouter.dispatch(opts);
18
+ },
19
+ complete(body, signal) {
20
+ const model = body.model;
21
+ return typeof model === "string" && isOllamaSlug(model) ? ollama.complete(body, signal) : openrouter.complete(body, signal);
22
+ },
23
+ fetchModels: (signal) => openrouter.fetchModels(signal),
24
+ fetchModelsForUser: (signal) => openrouter.fetchModelsForUser(signal),
25
+ };
26
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Ollama Cloud plan usage, read from `GET https://ollama.com/api/usage`.
3
+ *
4
+ * The endpoint is what ollama.com's own dashboard reads. It is not in the
5
+ * public docs (two open feature requests ask for exactly this), so everything
6
+ * here is defensive: unknown shape ⇒ null, never a throw, and the raw value is
7
+ * surfaced on /health so an operator can check it against the dashboard.
8
+ *
9
+ * Observed shape (2026-09-06, a Pro account):
10
+ *
11
+ * { activity: { cost: "0.00000", period: {type: "last_4_weeks", …}, models: [] },
12
+ * limits: { monthly: { usage: 0, models: [{ name, request_count }] } } }
13
+ *
14
+ * `limits.monthly.usage` is the share of the plan's included monthly credits
15
+ * consumed. It is reported relative to the plan, which is the point: the
16
+ * router never needs to know whether the account is Pro ($60) or Max ($300)
17
+ * to know how close it is to the edge. `request_count` moves immediately;
18
+ * `usage` and `activity.cost` are aggregated with a lag and round to whole
19
+ * units, so a few cents of test traffic reads as 0.
20
+ *
21
+ * The scale of `usage` is inferred, not documented: a value above 1 is a
22
+ * percentage; 0 is 0; a non-integer at or below 1 is a fraction. The one
23
+ * ambiguous reading, exactly 1, is taken as 1% (dashboards show whole
24
+ * percents) rather than 100%, which errs toward keeping the bias on.
25
+ */
26
+
27
+ import type { Logger } from "../util/log.ts";
28
+
29
+ export interface OllamaUsage {
30
+ /** Share of the plan's included monthly credits used, 0-1. Null when the payload lacks it. */
31
+ monthlyUsedFraction: number | null;
32
+ /** `limits.monthly.usage` exactly as reported, for the dashboard cross-check. */
33
+ monthlyUsageRaw: number | null;
34
+ /** `activity.cost` (rolling 4 weeks, USD) as reported, or null. */
35
+ activityCostUsd: number | null;
36
+ /** Requests this billing month, summed over models. */
37
+ requestsThisMonth: number;
38
+ fetchedAtMs: number;
39
+ }
40
+
41
+ function asRec(v: unknown): Record<string, unknown> | null {
42
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
43
+ }
44
+
45
+ /** See the module comment for the scale inference. */
46
+ export function usageFraction(raw: number): number {
47
+ if (!Number.isFinite(raw) || raw <= 0) return 0;
48
+ if (raw > 1) return Math.min(1, raw / 100);
49
+ if (raw === 1) return 0.01;
50
+ return raw;
51
+ }
52
+
53
+ export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage | null {
54
+ const root = asRec(json);
55
+ if (root === null) return null;
56
+ const limits = asRec(root.limits);
57
+ const monthly = limits === null ? null : asRec(limits.monthly);
58
+ const usageRaw = monthly !== null && typeof monthly.usage === "number" && Number.isFinite(monthly.usage) ? monthly.usage : null;
59
+ let requests = 0;
60
+ if (monthly !== null && Array.isArray(monthly.models)) {
61
+ for (const m of monthly.models) {
62
+ const rec = asRec(m);
63
+ if (rec !== null && typeof rec.request_count === "number") requests += rec.request_count;
64
+ }
65
+ }
66
+ const activity = asRec(root.activity);
67
+ const costRaw = activity?.cost;
68
+ const cost = typeof costRaw === "number" ? costRaw : typeof costRaw === "string" ? Number(costRaw) : NaN;
69
+ if (usageRaw === null && limits === null && activity === null) return null;
70
+ return {
71
+ monthlyUsedFraction: usageRaw === null ? null : usageFraction(usageRaw),
72
+ monthlyUsageRaw: usageRaw,
73
+ activityCostUsd: Number.isFinite(cost) ? cost : null,
74
+ requestsThisMonth: requests,
75
+ fetchedAtMs: nowMs,
76
+ };
77
+ }
78
+
79
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
80
+
81
+ export interface OllamaUsageSource {
82
+ /** Latest usage, refreshed when older than the poll interval; last good value on failure. */
83
+ get(): Promise<OllamaUsage | null>;
84
+ /** Last fetched value without touching the network. */
85
+ peek(): OllamaUsage | null;
86
+ }
87
+
88
+ /** Inert source for setups with no key (the daemon path without `/login ollama-cloud`). */
89
+ export const NO_USAGE: OllamaUsageSource = { get: async () => null, peek: () => null };
90
+
91
+ export function createOllamaUsageSource(
92
+ opts: { apiKey: string; pollMs: number; timeoutMs: number; log: Logger; fetchImpl?: FetchLike; root?: string },
93
+ ): OllamaUsageSource {
94
+ if (opts.apiKey === "" || opts.pollMs <= 0) return NO_USAGE;
95
+ const fetchImpl = opts.fetchImpl ?? fetch;
96
+ const root = (opts.root ?? "https://ollama.com").replace(/\/+$/, "");
97
+ let current: OllamaUsage | null = null;
98
+ let checkedAtMs = 0;
99
+ let inflight: Promise<OllamaUsage | null> | null = null;
100
+ let warned = false;
101
+
102
+ async function refresh(): Promise<OllamaUsage | null> {
103
+ try {
104
+ const res = await fetchImpl(`${root}/api/usage`, {
105
+ headers: { authorization: `Bearer ${opts.apiKey}` },
106
+ signal: AbortSignal.timeout(opts.timeoutMs),
107
+ });
108
+ if (res.ok) {
109
+ const parsed = parseOllamaUsage(await res.json());
110
+ if (parsed !== null) {
111
+ current = parsed;
112
+ warned = false;
113
+ } else if (!warned) {
114
+ warned = true;
115
+ opts.log.warn("ollama usage payload had no recognisable fields; credit-aware bias stays on its last reading");
116
+ }
117
+ } else if (!warned) {
118
+ warned = true;
119
+ opts.log.warn("ollama usage endpoint unavailable; credit-aware bias stays on its last reading", { status: res.status });
120
+ }
121
+ } catch (err) {
122
+ if (!warned) {
123
+ warned = true;
124
+ opts.log.warn("ollama usage fetch failed; credit-aware bias stays on its last reading", {
125
+ error: err instanceof Error ? err.message : String(err),
126
+ });
127
+ }
128
+ }
129
+ checkedAtMs = Date.now();
130
+ return current;
131
+ }
132
+
133
+ return {
134
+ async get() {
135
+ if (Date.now() - checkedAtMs < opts.pollMs) return current;
136
+ inflight ??= refresh().finally(() => {
137
+ inflight = null;
138
+ });
139
+ return inflight;
140
+ },
141
+ peek: () => current,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The cost multiplier to apply to Ollama candidates right now: `costBias`
147
+ * while the plan's included credits are below `biasUntilUsage`, list price
148
+ * (1) once they are exhausted. Unknown usage keeps the bias — the plan is far
149
+ * more often under its allowance than over it, and a 402 still trips the
150
+ * breaker if that guess is wrong.
151
+ */
152
+ export function effectiveOllamaBias(costBias: number, biasUntilUsage: number, usage: OllamaUsage | null): number {
153
+ if (costBias >= 1) return costBias;
154
+ const used = usage?.monthlyUsedFraction ?? null;
155
+ if (used === null) return costBias;
156
+ return used >= biasUntilUsage ? 1 : costBias;
157
+ }
158
+
159
+ /** The dashboard's dollar reading: plan share × included credits, when both are known. */
160
+ export function ollamaMeter(usage: OllamaUsage | null, planCreditsUsd: number): { usedUsd: number; creditsUsd: number } | null {
161
+ if (usage === null || usage.monthlyUsedFraction === null || !(planCreditsUsd > 0)) return null;
162
+ return { usedUsd: Math.round(usage.monthlyUsedFraction * planCreditsUsd * 100) / 100, creditsUsd: planCreditsUsd };
163
+ }