auto-model-router 0.2.32 → 0.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.
Files changed (67) hide show
  1. package/.omp-plugin/marketplace.json +2 -2
  2. package/README.md +208 -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 +115 -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 +189 -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 +43 -2
  23. package/src/config/load.ts +25 -1
  24. package/src/config/omp-credentials.ts +31 -7
  25. package/src/config/schema.ts +27 -0
  26. package/src/config/types.ts +114 -2
  27. package/src/cost/ledger.ts +73 -4
  28. package/src/cost/report.ts +340 -0
  29. package/src/cost/types.ts +33 -1
  30. package/src/index.ts +5 -8
  31. package/src/router/candidates.ts +52 -4
  32. package/src/router/classify.ts +33 -6
  33. package/src/router/features.ts +13 -1
  34. package/src/router/select.ts +55 -8
  35. package/src/router/state.ts +6 -2
  36. package/src/router/tier-plan.ts +49 -11
  37. package/src/router/types.ts +10 -0
  38. package/src/server/http.ts +47 -6
  39. package/src/server/providers.ts +54 -0
  40. package/src/server/turn.ts +122 -34
  41. package/src/tokens/estimate.ts +16 -0
  42. package/src/upstream/multi.ts +26 -0
  43. package/src/upstream/ollama-usage.ts +157 -0
  44. package/src/upstream/ollama.ts +275 -0
  45. package/src/upstream/openrouter.ts +19 -1
  46. package/src/upstream/types.ts +2 -0
  47. package/src/util/sqlite.ts +25 -1
  48. package/test/catalog.test.ts +44 -0
  49. package/test/classify.test.ts +41 -5
  50. package/test/compaction.test.ts +1 -0
  51. package/test/config-wizard.test.ts +77 -1
  52. package/test/configure-logic.test.ts +129 -33
  53. package/test/embed-lifecycle.test.ts +1 -0
  54. package/test/failover.test.ts +148 -3
  55. package/test/features.test.ts +35 -0
  56. package/test/http-resilience.test.ts +24 -0
  57. package/test/ollama.test.ts +506 -0
  58. package/test/omp-credentials.test.ts +43 -1
  59. package/test/report-hub.test.ts +341 -0
  60. package/test/report-logic.test.ts +92 -0
  61. package/test/report.test.ts +217 -0
  62. package/test/select.test.ts +151 -1
  63. package/test/tier-plan.test.ts +159 -1
  64. package/test/toast-logic.test.ts +11 -2
  65. package/test/tokens.test.ts +71 -1
  66. package/test/trust-attribution.test.ts +2 -2
  67. package/test/turn.test.ts +124 -7
@@ -11,9 +11,11 @@
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 { computeCost } from "../cost/forecast.ts";
14
15
  import { EMPTY_USAGE, type Ledger, type UsageCounts } from "../cost/types.ts";
15
16
  import { createProbe, type Probe } from "../router/escalate.ts";
16
17
  import { resolveHoldTurns } from "../router/explore.ts";
18
+ import { adjustPendingEstimate } from "../tokens/estimate.ts";
17
19
  import {
18
20
  TIER_ORDER,
19
21
  type ConversationStore,
@@ -36,6 +38,23 @@ import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk } from "../w
36
38
  */
37
39
  const MAX_SAME_TIER_FAILOVERS = 2;
38
40
 
41
+ /**
42
+ * Probe signals that indict the PROVIDER rather than the tier: an empty
43
+ * stream, a refusal, or an error finish says nothing about whether the work
44
+ * needed a stronger model. Measured on 7 days of live traffic, 49 of 71
45
+ * escalations re-dispatched the very slug that had just failed, one tier up,
46
+ * paying the tier premium for a provider hiccup. These signals get the same
47
+ * same-tier failover an HTTP 5xx gets, and only then step up a tier. Structural
48
+ * signals (malformed arguments, a repeated call) still escalate directly — a
49
+ * stronger model is the remedy there.
50
+ */
51
+ const PROVIDER_SIGNALS: ReadonlySet<string> = new Set(["empty_completion", "refusal", "upstream_error"]);
52
+
53
+ /** A client hang-up: the request signal fired, or the transport reported the abort. */
54
+ function isClientAbort(err: unknown, signal: AbortSignal): boolean {
55
+ return signal.aborted || (err instanceof UpstreamError && err.kind === "aborted");
56
+ }
57
+
39
58
  export interface TurnDeps {
40
59
  config: RouterConfig;
41
60
  router: Router;
@@ -170,6 +189,14 @@ export async function runTurn(
170
189
  ...(decision.compactionPlan.length > 0 ? { compactionPlan: decision.compactionPlan } : {}),
171
190
  });
172
191
 
192
+ // Calibrate the token estimate against the bytes that actually go out —
193
+ // after compaction shrank the prompt and the context block was appended
194
+ // — not the raw request the estimate was taken from.
195
+ adjustPendingEstimate(
196
+ req.conversationKey,
197
+ req.promptBytes - decision.compactionSavedBytes + (contextBlock === undefined ? 0 : Buffer.byteLength(contextBlock)),
198
+ );
199
+
173
200
  // Our own abort composes with the client's: escalation teardown and
174
201
  // client disconnect both kill the upstream connection.
175
202
  const attemptAbort = new AbortController();
@@ -196,6 +223,7 @@ export async function runTurn(
196
223
  if (generationId === null && dispatch !== null) {
197
224
  generationId = await dispatch.generationId().catch(() => null);
198
225
  }
226
+ const priceModel = deps.catalog.find(servedSlug ?? decision.slug);
199
227
  ledger.record({
200
228
  id: crypto.randomUUID(),
201
229
  createdAtMs: Date.now(),
@@ -229,6 +257,9 @@ export async function runTurn(
229
257
  upstreamGenerationId: generationId,
230
258
  error: fields.error,
231
259
  promptTokensSaved: decision.promptTokensSaved,
260
+ // The ledger prices OpenRouter slugs from its own cached payload; any
261
+ // other provider's model exists only in the live catalog.
262
+ ...(priceModel === undefined ? {} : { priceModel }),
232
263
  });
233
264
  // Book the money HERE, beside the ledger row, so the two can never
234
265
  // disagree. Every dispatch that reaches this point was billed —
@@ -239,6 +270,28 @@ export async function runTurn(
239
270
  conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
240
271
  };
241
272
 
273
+ // Same-tier failover: re-route with every failed slug excluded and accept
274
+ // the result only if it stays in this tier on a different model. A
275
+ // 404/429/5xx, an empty stream, or a refusal indicts the slug, not the
276
+ // tier, so a sibling is tried before the tier is abandoned. Returns null
277
+ // when the bound is hit or the router widened tiers on its own; the
278
+ // caller then falls through to tier escalation.
279
+ const trySameTierFailover = async (why: string): Promise<Decision | null> => {
280
+ if (sameTierFailovers >= MAX_SAME_TIER_FAILOVERS) return null;
281
+ let failover: Decision | null = null;
282
+ try {
283
+ failover = await router.route(req, { attempt: attempt + 1, excludeSlugs: failedSlugs });
284
+ } catch {
285
+ // A routing failure here must not kill the turn; tier escalation
286
+ // may still find a model.
287
+ return null;
288
+ }
289
+ if (failover === null || failover.tier !== decision.tier || failedSlugs.includes(failover.slug)) return null;
290
+ sameTierFailovers++;
291
+ failover.reasons = [...failover.reasons, `failover: ${decision.slug} ${why}; retrying ${failover.slug} in ${failover.tier}`];
292
+ return failover;
293
+ };
294
+
242
295
  // "retry" re-enters the attempt loop; "done" means the turn is settled
243
296
  // (client told, or client gone) and runTurn must return.
244
297
  const onUpstreamError = async (err: unknown): Promise<"retry" | "done"> => {
@@ -261,30 +314,15 @@ export async function runTurn(
261
314
  }
262
315
  if (uerr.retryable && attempt + 1 < maxAttempts) {
263
316
  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.
317
+ // Before jumping a tier, try a DIFFERENT model in the same tier:
318
+ // a 404/429/5xx indicts the slug, not the tier. Null means no other
319
+ // candidate at this tier the router widened on its own or only
320
+ // the failed slug qualifies so fall through to tier escalation.
321
+ const failover = await trySameTierFailover(`returned ${uerr.kind}`);
322
+ if (failover !== null) {
323
+ pendingDecision = failover;
324
+ await writeEntry({ wasted: true, escalationSignal: null, error: `${uerr.kind}: ${uerr.message}` });
325
+ return "retry";
288
326
  }
289
327
  const topTier = TIER_ORDER[TIER_ORDER.length - 1];
290
328
  if (decision.tier !== topTier) {
@@ -307,6 +345,11 @@ export async function runTurn(
307
345
  let escalateVerdict: Extract<ProbeVerdict, { action: "escalate" }> | null = null;
308
346
  let streamError: unknown = null;
309
347
  let sinkDied = false;
348
+ // The upstream generation ran to its end — either the stream closed
349
+ // normally, or the client hung up after the finish event had already
350
+ // arrived. Both are settled generations; only the second used to be
351
+ // recorded as an error.
352
+ let streamEnded = false;
310
353
 
311
354
  try {
312
355
  dispatch = await upstream.dispatch({ body, sessionId: decision.sessionId, signal: attemptSignal });
@@ -374,21 +417,22 @@ export async function runTurn(
374
417
  attemptAbort.abort();
375
418
  break;
376
419
  }
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
- }
420
+ if (escalateVerdict === null) streamEnded = true;
387
421
  } catch (err) {
388
422
  if (escalateVerdict !== null || attemptAbort.signal.aborted) {
389
423
  // Teardown noise from our own abort; the escalate path owns the outcome.
390
424
  } else if (err instanceof SinkError) {
391
425
  sinkDied = true;
426
+ } else if (finishReason !== null && isClientAbort(err, signal)) {
427
+ // The client closed the connection AFTER the finish event: the
428
+ // generation is complete and billed, the client already has its
429
+ // answer, only the trailing `[DONE]` went unread. Measured live:
430
+ // 1,842 of 2,068 "request aborted" rows carried a finish reason
431
+ // and full usage, and every one skipped the state save below —
432
+ // stale hysteresis, cache-warmth, and compaction state on 12% of
433
+ // turns. A settled generation is settled however the socket
434
+ // closed.
435
+ streamEnded = true;
392
436
  } else {
393
437
  streamError = err;
394
438
  }
@@ -396,6 +440,29 @@ export async function runTurn(
396
440
  // Never leak the upstream connection, whatever happened above.
397
441
  attemptAbort.abort();
398
442
  }
443
+ if (streamEnded && !committed && escalateVerdict === null) {
444
+ const verdict = probe.verdictOnEnd();
445
+ if (verdict.action === "commit") {
446
+ committed = true;
447
+ try {
448
+ for (const heldChunk of probe.held()) await emit(heldChunk);
449
+ } catch (err) {
450
+ if (err instanceof SinkError) sinkDied = true;
451
+ else streamError = err;
452
+ }
453
+ } else {
454
+ escalateVerdict = verdict;
455
+ }
456
+ }
457
+ }
458
+
459
+ // A provider that reports no cost (Ollama) still reports usage: price the
460
+ // actual tokens at the served model's catalog rate rather than leaving the
461
+ // pre-dispatch forecast (with its assumed 1,024 completion tokens) to stand
462
+ // in for a turn that produced 26.
463
+ if (reportedUsd === null && usage.promptTokens > 0) {
464
+ const served = deps.catalog.find(servedSlug ?? decision.slug);
465
+ if (served !== undefined) reportedUsd = computeCost(served, usage).total;
399
466
  }
400
467
 
401
468
  if (sinkDied) {
@@ -408,6 +475,26 @@ export async function runTurn(
408
475
  }
409
476
 
410
477
  if (escalateVerdict !== null) {
478
+ // The model that produced the rejected output must not serve the
479
+ // retry, at this tier or the next: without this, 49 of 71 measured
480
+ // escalations re-dispatched the same slug one tier up.
481
+ failedSlugs.push(decision.slug);
482
+ if (PROVIDER_SIGNALS.has(escalateVerdict.signal) && attempt + 1 < maxAttempts) {
483
+ const failover = await trySameTierFailover(`${escalateVerdict.signal} (${escalateVerdict.reason})`);
484
+ if (failover !== null) {
485
+ pendingDecision = failover;
486
+ await writeEntry({ wasted: true, escalationSignal: escalateVerdict.signal, error: null });
487
+ log.info("same-tier failover", {
488
+ signal: escalateVerdict.signal,
489
+ reason: escalateVerdict.reason,
490
+ from: decision.slug,
491
+ to: failover.slug,
492
+ tier: decision.tier,
493
+ attempt,
494
+ });
495
+ continue;
496
+ }
497
+ }
411
498
  const hasRunway = attempt + 1 < maxAttempts && decision.probe.escalateTo !== null;
412
499
  if (hasRunway) {
413
500
  escalations++;
@@ -461,6 +548,7 @@ export async function runTurn(
461
548
  // it verbatim (after byte-length validation), keeping shrunk tool results
462
549
  // shrunk so the prompt cache survives and the savings compound.
463
550
  state.compactionPlan = decision.compactionPlan.length > 0 ? decision.compactionPlan : null;
551
+ state.compactionPlanTokens = decision.compactionPlanTokens;
464
552
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
465
553
  // Non-zero cache traffic is direct evidence the upstream cache exists.
466
554
  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,157 @@
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
+ }