auto-model-router 0.3.1 → 0.3.2

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.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.3.1",
10
+ "version": "0.3.2",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.3.1",
17
+ "version": "0.3.2",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -623,7 +623,7 @@ an OpenRouter sibling). See [Ollama Cloud](#ollama-cloud) below.
623
623
  | `usagePollMs` | `600000` (10 min) | How often plan usage is re-read. `0` disables it (static bias). Needs the API key; the daemon path without one keeps a static bias. |
624
624
  | `quotaCooldownMs` | `900000` | Route around Ollama this long after a 402 (credits exhausted). |
625
625
  | `rateLimitCooldownMs` | `60000` | Route around Ollama this long after a 429 (concurrency cap). |
626
- | `planCreditsUsd` | `0` | Dollar value of the plan's included monthly credits (Pro 60, Max 300). Lets `/health` and `/router status` show ollama.com's plan reading as dollars next to the ledger's figure. `0` shows the share only. |
626
+ | `planCreditsUsd` | `0` | Override for the plan's included monthly credits. `0` detects the plan from ollama.com (`POST /api/me`) and applies its published allowance (Pro $60, Max $300), so `/health` and `/router status` show ollama.com's reading as dollars next to the ledger's figure. Set it for a plan the router does not know. |
627
627
 
628
628
  ### `tiers` — per-tier economic envelope
629
629
 
@@ -824,8 +824,9 @@ What happens once it is on:
824
824
  previous prompt is taken as the cached prefix and priced at the cached
825
825
  rate; a first turn, a switch, or a longer gap is priced cold. The ledger
826
826
  flags these rows (`usage.cachedEstimated`) and reports show their cache
827
- rate as `~N%`. Set `planCreditsUsd` (Pro 60, Max 300) to see ollama.com's
828
- own dollar reading in `/router status` as the cross-check.
827
+ rate as `~N%`. `/router status` shows ollama.com's own dollar reading as
828
+ the cross-check: the plan is read from `POST /api/me` and its published
829
+ allowance applied (`planCreditsUsd` overrides it).
829
830
  - **Same economics, same failover.** Candidates from both providers are ranked
830
831
  together; `costBias` tilts the comparison while a plan's included credits
831
832
  would otherwise go unused. **Credit-aware by default:** the router reads the
@@ -74,8 +74,8 @@ export interface HealthSnapshot {
74
74
  available?: boolean;
75
75
  cooldownUntilMs?: number | null;
76
76
  lastTrip?: { kind?: string; atMs?: number; message?: string } | null;
77
- usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; fetchedAtMs?: number | null } | null;
78
- meter?: { usedUsd?: number; creditsUsd?: number } | null;
77
+ usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; plan?: string | null; fetchedAtMs?: number | null } | null;
78
+ meter?: { usedUsd?: number; creditsUsd?: number; plan?: string | null } | null;
79
79
  costBias?: { configured?: number; effective?: number; biasUntilUsage?: number };
80
80
  } | null;
81
81
  catalog?: {
@@ -106,7 +106,8 @@ export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.no
106
106
  const avail = o.available === true ? "available" : `COOLING DOWN${o.cooldownUntilMs ? ` until ${new Date(o.cooldownUntilMs).toLocaleTimeString()}` : ""}`;
107
107
  const frac = o.usage?.monthlyUsedFraction;
108
108
  const meter = o.meter !== undefined && o.meter !== null && o.meter.usedUsd !== undefined ? ` ($${o.meter.usedUsd.toFixed(2)} of $${o.meter.creditsUsd ?? "?"})` : "";
109
- const usage = frac === undefined || frac === null ? "plan usage unknown" : `plan usage ${(frac * 100).toFixed(1)}%${meter}`;
109
+ const planName = o.meter?.plan ?? o.usage?.plan ?? null;
110
+ const usage = frac === undefined || frac === null ? "plan usage unknown" : `${planName === null ? "plan" : `${planName} plan`} usage ${(frac * 100).toFixed(1)}%${meter}`;
110
111
  const bias = o.costBias === undefined ? "" : ` · cost bias ×${o.costBias.effective ?? o.costBias.configured ?? 1} (until ${((o.costBias.biasUntilUsage ?? 1) * 100).toFixed(0)}%)`;
111
112
  const trip = o.lastTrip !== undefined && o.lastTrip !== null ? ` · last trip ${o.lastTrip.kind ?? "?"}${o.lastTrip.atMs ? ` ${mins(nowMs - o.lastTrip.atMs)} ago` : ""}` : "";
112
113
  out.push(`ollama cloud: ${o.models ?? 0} models · ${avail} · key ${o.apiKeySource ?? "?"} · ${usage}${bias}${trip}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -143,7 +143,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
143
143
  { path: "ollama.usagePollMs", label: "Plan usage poll", kind: "number", min: 0, hint: "ms, 0=off" },
144
144
  { path: "ollama.quotaCooldownMs", label: "Quota (402) cooldown", kind: "number", min: 0, hint: "ms" },
145
145
  { path: "ollama.rateLimitCooldownMs", label: "Rate-limit (429) cooldown", kind: "number", min: 0, hint: "ms" },
146
- { path: "ollama.planCreditsUsd", label: "Plan credits per month $", kind: "number", min: 0, hint: "Pro 60, Max 300; 0=unknown" },
146
+ { path: "ollama.planCreditsUsd", label: "Plan credits per month $", kind: "number", min: 0, hint: "0=detect plan (Pro 60, Max 300)" },
147
147
  ],
148
148
  },
149
149
  {
@@ -115,9 +115,9 @@ export interface OllamaConfig {
115
115
  /** How long to route around Ollama after a 429 (concurrency cap), ms. */
116
116
  rateLimitCooldownMs: number;
117
117
  /**
118
- * Dollar value of the plan's included monthly credits (Pro 60, Max 300), so
119
- * the plan-relative usage reading can be shown as dollars beside the
120
- * ledger's own Ollama figure. 0 unknown: usage is shown as a share only.
118
+ * Override for the plan's included monthly credits, USD. 0 (default) reads
119
+ * the plan from ollama.com (`POST /api/me`) and applies its published
120
+ * allowance (Pro 60, Max 300); set this for a plan the router does not know.
121
121
  */
122
122
  planCreditsUsd: number;
123
123
  }
@@ -35,9 +35,26 @@ export interface OllamaUsage {
35
35
  activityCostUsd: number | null;
36
36
  /** Requests this billing month, summed over models. */
37
37
  requestsThisMonth: number;
38
+ /** Subscription name from `POST /api/me` (`pro`, `max`, …), or null when unknown. */
39
+ plan: string | null;
38
40
  fetchedAtMs: number;
39
41
  }
40
42
 
43
+ /**
44
+ * Included monthly credits per plan, USD, from ollama.com/pricing (2026-09-07):
45
+ * Pro $20/mo carries $60 of usage, Max $100/mo carries $300. The dashboard's
46
+ * dollar figure is `limits.monthly.usage` × this. A plan not listed here
47
+ * (free, team, an unseen tier) yields no dollar reading rather than a guess.
48
+ */
49
+ export const PLAN_CREDITS_USD: Readonly<Record<string, number>> = { pro: 60, max: 300 };
50
+
51
+ /** The plan named by an `/api/me` payload, lower-cased, or null. */
52
+ export function parseOllamaPlan(json: unknown): string | null {
53
+ const root = asRec(json);
54
+ const plan = root?.Plan ?? root?.plan;
55
+ return typeof plan === "string" && plan.trim() !== "" ? plan.trim().toLowerCase() : null;
56
+ }
57
+
41
58
  function asRec(v: unknown): Record<string, unknown> | null {
42
59
  return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
43
60
  }
@@ -72,6 +89,7 @@ export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage
72
89
  monthlyUsageRaw: usageRaw,
73
90
  activityCostUsd: Number.isFinite(cost) ? cost : null,
74
91
  requestsThisMonth: requests,
92
+ plan: null,
75
93
  fetchedAtMs: nowMs,
76
94
  };
77
95
  }
@@ -98,8 +116,35 @@ export function createOllamaUsageSource(
98
116
  let checkedAtMs = 0;
99
117
  let inflight: Promise<OllamaUsage | null> | null = null;
100
118
  let warned = false;
119
+ let plan: string | null = null;
120
+ let planWarned = false;
121
+
122
+ /** Best-effort: a missing plan only costs the dollar reading, never the bias. */
123
+ async function refreshPlan(): Promise<void> {
124
+ try {
125
+ const res = await fetchImpl(`${root}/api/me`, {
126
+ method: "POST",
127
+ headers: { authorization: `Bearer ${opts.apiKey}` },
128
+ signal: AbortSignal.timeout(opts.timeoutMs),
129
+ });
130
+ if (res.ok) {
131
+ const parsed = parseOllamaPlan(await res.json());
132
+ if (parsed !== null) plan = parsed;
133
+ } else if (!planWarned) {
134
+ planWarned = true;
135
+ opts.log.warn("ollama account endpoint unavailable; plan stays unknown", { status: res.status });
136
+ }
137
+ } catch (err) {
138
+ if (!planWarned) {
139
+ planWarned = true;
140
+ opts.log.warn("ollama account fetch failed; plan stays unknown", { error: err instanceof Error ? err.message : String(err) });
141
+ }
142
+ }
143
+ }
101
144
 
102
145
  async function refresh(): Promise<OllamaUsage | null> {
146
+ // The plan changes rarely, but the call is one small request per poll.
147
+ await refreshPlan();
103
148
  try {
104
149
  const res = await fetchImpl(`${root}/api/usage`, {
105
150
  headers: { authorization: `Bearer ${opts.apiKey}` },
@@ -108,7 +153,7 @@ export function createOllamaUsageSource(
108
153
  if (res.ok) {
109
154
  const parsed = parseOllamaUsage(await res.json());
110
155
  if (parsed !== null) {
111
- current = parsed;
156
+ current = { ...parsed, plan };
112
157
  warned = false;
113
158
  } else if (!warned) {
114
159
  warned = true;
@@ -156,8 +201,21 @@ export function effectiveOllamaBias(costBias: number, biasUntilUsage: number, us
156
201
  return used >= biasUntilUsage ? 1 : costBias;
157
202
  }
158
203
 
204
+ /**
205
+ * Included credits for this account: the configured override when set, else
206
+ * the detected plan's published allowance, else null.
207
+ */
208
+ export function ollamaPlanCredits(usage: OllamaUsage | null, overrideUsd: number): number | null {
209
+ if (overrideUsd > 0) return overrideUsd;
210
+ const plan = usage?.plan ?? null;
211
+ if (plan === null) return null;
212
+ return PLAN_CREDITS_USD[plan] ?? null;
213
+ }
214
+
159
215
  /** 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 };
216
+ export function ollamaMeter(usage: OllamaUsage | null, overrideUsd: number): { usedUsd: number; creditsUsd: number; plan: string | null } | null {
217
+ if (usage === null || usage.monthlyUsedFraction === null) return null;
218
+ const credits = ollamaPlanCredits(usage, overrideUsd);
219
+ if (credits === null) return null;
220
+ return { usedUsd: Math.round(usage.monthlyUsedFraction * credits * 100) / 100, creditsUsd: credits, plan: usage.plan };
163
221
  }
@@ -22,7 +22,7 @@ import { buildCandidates } from "../src/router/candidates.ts";
22
22
  import { extractFeatures } from "../src/router/features.ts";
23
23
  import { createMultiUpstream } from "../src/upstream/multi.ts";
24
24
  import { classifyOllamaStatus, createOllamaClient, toOllamaBody } from "../src/upstream/ollama.ts";
25
- import { createOllamaUsageSource, effectiveOllamaBias, NO_USAGE, ollamaMeter, parseOllamaUsage, usageFraction } from "../src/upstream/ollama-usage.ts";
25
+ import { createOllamaUsageSource, effectiveOllamaBias, NO_USAGE, ollamaMeter, parseOllamaPlan, parseOllamaUsage, usageFraction } from "../src/upstream/ollama-usage.ts";
26
26
  import type { Dispatch, DispatchOptions, UpstreamClient } from "../src/upstream/types.ts";
27
27
  import { createLogger } from "../src/util/log.ts";
28
28
  import { parseChatRequest } from "../src/wire/openai/request.ts";
@@ -444,7 +444,7 @@ describe("ollama plan usage (credit-aware bias)", () => {
444
444
  });
445
445
 
446
446
  test("the bias holds under the threshold, switches to list price above it, and stays on when usage is unknown", () => {
447
- const at = (f: number | null) => (f === null ? null : { monthlyUsedFraction: f, monthlyUsageRaw: f, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 });
447
+ const at = (f: number | null) => (f === null ? null : { monthlyUsedFraction: f, monthlyUsageRaw: f, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 });
448
448
  expect(effectiveOllamaBias(0.1, 0.9, at(0.5))).toBe(0.1);
449
449
  expect(effectiveOllamaBias(0.1, 0.9, at(0.9))).toBe(1);
450
450
  expect(effectiveOllamaBias(0.1, 0.9, at(1))).toBe(1);
@@ -457,15 +457,21 @@ describe("ollama plan usage (credit-aware bias)", () => {
457
457
  let calls = 0;
458
458
  let fail = false;
459
459
  const fetchImpl = async (url: string, init?: RequestInit): Promise<Response> => {
460
+ expect((init?.headers as Record<string, string>).authorization).toBe("Bearer k");
461
+ // The plan rides along on every poll: POST /api/me (GET answers 405).
462
+ if (url === "https://ollama.com/api/me") {
463
+ expect(init?.method).toBe("POST");
464
+ return Response.json({ ID: "x", Email: "e", Plan: "Pro" });
465
+ }
460
466
  calls++;
461
467
  expect(url).toBe("https://ollama.com/api/usage");
462
- expect((init?.headers as Record<string, string>).authorization).toBe("Bearer k");
463
468
  if (fail) return new Response("down", { status: 503 });
464
469
  return Response.json({ ...PAYLOAD, limits: { monthly: { usage: 42, models: [] } } });
465
470
  };
466
471
  const src = createOllamaUsageSource({ apiKey: "k", pollMs: 20, timeoutMs: 1000, log, fetchImpl });
467
472
  expect(src.peek()).toBeNull();
468
473
  expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6);
474
+ expect(src.peek()?.plan).toBe("pro");
469
475
  await src.get();
470
476
  expect(calls).toBe(1); // within the interval
471
477
  fail = true;
@@ -482,7 +488,7 @@ describe("ollama plan usage (credit-aware bias)", () => {
482
488
  const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
483
489
  const breaker = { available: () => true, cooldownUntilMs: () => null, lastTrip: () => null };
484
490
  let used = 0.2;
485
- const usage = { get: async () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }), peek: () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }) };
491
+ const usage = { get: async () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 }), peek: () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 }) };
486
492
  const catalog = createCompositeCatalog(openrouter, source, breaker, { costBias: 0.1, biasUntilUsage: 0.9, usage });
487
493
 
488
494
  const a = await catalog.get();
@@ -506,16 +512,29 @@ describe("ollama plan usage (credit-aware bias)", () => {
506
512
 
507
513
 
508
514
  describe("ollamaMeter", () => {
509
- test("plan share times credits is the dashboard's dollar figure", () => {
510
- const usage = { monthlyUsedFraction: 0.104, monthlyUsageRaw: 0.104, activityCostUsd: 0, requestsThisMonth: 1250, fetchedAtMs: 1 };
511
- // 10.4% of Pro's $60 is the $6.24 ollama.com shows.
512
- expect(ollamaMeter(usage, 60)).toEqual({ usedUsd: 6.24, creditsUsd: 60 });
515
+ const usage = (plan: string | null, frac: number | null = 0.104) => ({ monthlyUsedFraction: frac, monthlyUsageRaw: frac, activityCostUsd: 0, requestsThisMonth: 1250, plan, fetchedAtMs: 1 });
516
+
517
+ test("a detected plan applies its published allowance: 10.4% of Pro's $60 is the $6.24 ollama.com shows", () => {
518
+ expect(ollamaMeter(usage("pro"), 0)).toEqual({ usedUsd: 6.24, creditsUsd: 60, plan: "pro" });
519
+ expect(ollamaMeter(usage("max"), 0)).toEqual({ usedUsd: 31.2, creditsUsd: 300, plan: "max" });
513
520
  });
514
521
 
515
- test("unknown credits or usage yields no meter", () => {
516
- const usage = { monthlyUsedFraction: 0.5, monthlyUsageRaw: 0.5, activityCostUsd: 0, requestsThisMonth: 1, fetchedAtMs: 1 };
517
- expect(ollamaMeter(usage, 0)).toBeNull();
522
+ test("a configured override wins over the detected plan; an unknown plan without one yields no meter", () => {
523
+ expect(ollamaMeter(usage("pro"), 100)).toEqual({ usedUsd: 10.4, creditsUsd: 100, plan: "pro" });
524
+ expect(ollamaMeter(usage("team"), 0)).toBeNull();
525
+ expect(ollamaMeter(usage("team"), 500)?.creditsUsd).toBe(500);
526
+ expect(ollamaMeter(usage(null), 0)).toBeNull();
527
+ });
528
+
529
+ test("no usage reading yields no meter", () => {
518
530
  expect(ollamaMeter(null, 60)).toBeNull();
519
- expect(ollamaMeter({ ...usage, monthlyUsedFraction: null }, 60)).toBeNull();
531
+ expect(ollamaMeter(usage("pro", null), 60)).toBeNull();
532
+ });
533
+
534
+ test("parseOllamaPlan reads the account payload case-insensitively", () => {
535
+ expect(parseOllamaPlan({ ID: "x", Plan: "Pro" })).toBe("pro");
536
+ expect(parseOllamaPlan({ plan: "max" })).toBe("max");
537
+ expect(parseOllamaPlan({ Plan: "" })).toBeNull();
538
+ expect(parseOllamaPlan("nope")).toBeNull();
520
539
  });
521
540
  });
@@ -65,7 +65,7 @@ describe("renderStatus", () => {
65
65
  apiKeySource: "omp",
66
66
  lastTrip: { kind: "quota", atMs: now - 120_000, message: "402" },
67
67
  usage: { monthlyUsedFraction: 0.42, activityCostUsd: 3.1, fetchedAtMs: now },
68
- meter: { usedUsd: 25.2, creditsUsd: 60 },
68
+ meter: { usedUsd: 25.2, creditsUsd: 60, plan: "pro" },
69
69
  costBias: { configured: 0.1, effective: 0.1, biasUntilUsage: 0.9 },
70
70
  },
71
71
  catalog: { models: 240, ageMs: 5 * 60_000, keyScoped: true, shrink: { fromModels: 300, toModels: 120, atMs: now } },
@@ -76,7 +76,7 @@ describe("renderStatus", () => {
76
76
  expect(text).toContain("refreshed 5m ago");
77
77
  expect(text).toContain("SHRANK 300 -> 120");
78
78
  expect(text).toContain("COOLING DOWN");
79
- expect(text).toContain("plan usage 42.0% ($25.20 of $60)");
79
+ expect(text).toContain("pro plan usage 42.0% ($25.20 of $60)");
80
80
  expect(text).toContain("cost bias ×0.1 (until 90%)");
81
81
  expect(text).toContain("last trip quota 2m ago");
82
82
  expect(text).toContain("scope omp-router");