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
package/test/turn.test.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDisabledBridge } from "../src/context/bridge.ts";
3
3
  import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
4
- import type { CatalogSource } from "../src/catalog/types.ts";
4
+ import type { CatalogModel, CatalogSource } from "../src/catalog/types.ts";
5
5
  import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
6
6
  import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
7
7
  import type {
@@ -31,6 +31,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
31
31
  return {
32
32
  server: { host: "127.0.0.1", port: 8787, maxConcurrentTurns: 24 },
33
33
  openrouter: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "", title: "test", timeoutMs: 30_000, catalogTtlMs: 3_600_000, catalogRefreshMs: 0 },
34
+ ollama: { enabled: false, baseUrl: "http://127.0.0.1:11434/v1", apiKey: "", timeoutMs: 30_000, catalogTtlMs: 300_000, includeLocal: false, prices: {}, twins: {}, costBias: 1, biasUntilUsage: 0.9, usagePollMs: 0, quotaCooldownMs: 0, rateLimitCooldownMs: 0, planCreditsUsd: 0 },
34
35
  benchmarks: { enabled: false, artificialAnalysisApiKey: "", benchlm: true, refreshMs: 86_400_000, timeoutMs: 30_000, useLocalScores: false },
35
36
  tiers: {
36
37
  trivial: { minQuality: 0, maxInputPerMtok: 0.3, qualityExponent: 0, pin: [] },
@@ -45,7 +46,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
45
46
  data: { axis: "intelligence", minQuality: 0 },
46
47
  chat: { axis: "intelligence", minQuality: 0 },
47
48
  },
48
- filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20 },
49
+ filters: { allow: [], deny: [], includeFree: false, requireToolSupport: true, minTrust: 0.6, minTrustSamples: 5, trustScopedByHarness: false, trustWindowDays: 0, contextHeadroom: 1.2, latencyWeight: 0, latencyReferenceMs: 5000, latencyReferenceTokensPerSec: 30, latencyMinSamples: 20, escalationCostWeight: 0 },
49
50
  classifier: {
50
51
  ambiguityThreshold: 0,
51
52
  model: "test/adjudicator",
@@ -56,6 +57,7 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
56
57
  toolAxis: "coding",
57
58
  chatAxis: "intelligence",
58
59
  agenticLoopDepth: 3,
60
+ mechanicalRetryFactor: 0.2,
59
61
  reasoningWeights: { medium: 0.14, high: 0.24, xhigh: 0.3, max: 0.34 },
60
62
  },
61
63
  escalation: {
@@ -68,11 +70,11 @@ function mkConfig(escalation: Partial<EscalationConfig> = {}): RouterConfig {
68
70
  escalateOnLengthStop: false,
69
71
  ...escalation,
70
72
  },
71
- hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false },
73
+ hysteresis: { holdTurns: 2, holdTurnsAfterEscalation: 4, switchMargin: 1.5, cacheWarmTtlMs: 600_000, maxDowngradePerTurn: 1, breakHoldOnMechanical: false, switchHorizonTurns: 1 },
72
74
  exploration: { enabled: false, rates: {}, stickyPolicy: "never", holdTurns: { enabled: false, values: [2, 3, 4] } },
73
75
  cache: { injectBreakpoints: true, maxBreakpoints: 4, minPromptTokens: 1024, milestoneTokens: 20_000 },
74
76
  context: { enabled: false, baseUrl: "", token: "", defaultScope: "", timeoutMs: 3_000, maxStalenessMs: 900_000, maxBlockChars: 24_000, memoryLimit: 8, docsLimit: 2, sessionLimit: 6, briefChars: 0, recordTurns: false, maxQueue: 64 },
75
- compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true },
77
+ compaction: { enabled: false, budgetTokens: 40_000, floorRatio: 1, fitToWindow: true, protectRecentTurns: 4, maxToolResultBytes: 4_096, keepHeadBytes: 512, keepTailBytes: 512, elideSupersededReads: true, collapseDuplicateResults: true, replanGrowthRatio: 1 },
76
78
  budget: { onExceeded: "downgrade" },
77
79
  profiles: [],
78
80
  ledger: { path: ":memory:", blendWindowDays: 7, blendMinSamples: 20, fallbackBlend: { inputPerMtok: 1, outputPerMtok: 4 }, conversationTtlMs: 86_400_000 },
@@ -147,6 +149,8 @@ function mkDecision(tier: Tier, slug: string, probe: Partial<ProbePlan> = {}): D
147
149
  cacheBreakpointMessageIndices: [],
148
150
  compactionPlan: [],
149
151
  promptTokensSaved: 0,
152
+ compactionSavedBytes: 0,
153
+ compactionPlanTokens: 0,
150
154
  reasoning: undefined,
151
155
  maxTokens: undefined,
152
156
  stripAssistantReasoning: false,
@@ -403,10 +407,13 @@ describe("runTurn", () => {
403
407
  expect(entries[1]!.attempt).toBe(1);
404
408
  expect(entries[1]!.slug).toBe("better/model");
405
409
 
406
- // The escalated attempt re-routed one tier up.
407
- expect(calls).toHaveLength(2);
410
+ // A refusal indicts the provider, so a same-tier sibling is probed first;
411
+ // this fake router only has the simple-tier decision left, which is the
412
+ // wrong tier, so the turn then escalates one tier up for real.
413
+ expect(calls).toHaveLength(3);
408
414
  expect(calls[0]).toEqual({ attempt: 0 });
409
- expect(calls[1]).toEqual({ attempt: 1, escalateFrom: "trivial" });
415
+ expect(calls[1]).toEqual({ attempt: 1 });
416
+ expect(calls[2]).toEqual({ attempt: 1, escalateFrom: "trivial" });
410
417
 
411
418
  // The held refusal text never reached the client.
412
419
  expect(textOut(chunks)).toBe("Here is the answer.");
@@ -729,4 +736,163 @@ describe("latency measurement covers the work the router actually does", () => {
729
736
  expect(entries[0]?.ttftMs).not.toBeNull();
730
737
  expect(entries[0]?.ttftMs ?? -1).toBeGreaterThanOrEqual(0);
731
738
  });
739
+
740
+ test("a client hang-up AFTER the finish event is a completed turn, not an error", async () => {
741
+ // Measured live: 1,842 of 2,068 "request aborted" rows carried a finish
742
+ // reason and full usage — the generation had finished and the client
743
+ // closed before the trailing [DONE] was read. Recording that as an error
744
+ // skipped the state save on 12% of turns.
745
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple" })]);
746
+ const { upstream } = mkUpstream([
747
+ {
748
+ kind: "die",
749
+ chunks: [
750
+ startChunk("cheap/model"),
751
+ textChunk("all done here"),
752
+ finishChunk("stop"),
753
+ usageChunk({ promptTokens: 120, cachedTokens: 100, completionTokens: 4 }, 0.0004),
754
+ ],
755
+ error: new UpstreamError("aborted", 0, "request aborted", false),
756
+ },
757
+ ]);
758
+ const { ledger, entries } = mkLedger();
759
+ const { store, map } = mkConversations();
760
+ const { sink, errors, finishes } = mkSink();
761
+
762
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
763
+
764
+ expect(errors).toHaveLength(0);
765
+ expect(entries).toHaveLength(1);
766
+ expect(entries[0]!.error).toBeNull();
767
+ expect(entries[0]!.wasted).toBe(false);
768
+ expect(entries[0]!.finishReason).toBe("stop");
769
+ expect(entries[0]!.reportedUsd).toBe(0.0004);
770
+ // The turn settled: hysteresis, cache warmth and the turn counter advance.
771
+ const state = map.get("conv-test")!;
772
+ expect(state.turn).toBe(1);
773
+ expect(state.currentSlug).toBe("cheap/model");
774
+ expect(state.cacheWarmSlug).toBe("cheap/model");
775
+ expect(state.lastPromptTokens).toBe(120);
776
+ expect(finishes).toHaveLength(1);
777
+ });
778
+
779
+ test("a client hang-up BEFORE the finish event is still recorded as aborted", async () => {
780
+ const { router } = mkRouter([mkDecision("trivial", "cheap/model", { escalateTo: "simple", maxTokens: 1 })]);
781
+ const { upstream } = mkUpstream([
782
+ {
783
+ kind: "die",
784
+ chunks: [startChunk("cheap/model"), textChunk("partial answer that committed")],
785
+ error: new UpstreamError("aborted", 0, "request aborted", false),
786
+ },
787
+ ]);
788
+ const { ledger, entries } = mkLedger();
789
+ const { store, map } = mkConversations();
790
+ const { sink, finishes } = mkSink();
791
+
792
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
793
+
794
+ expect(entries).toHaveLength(1);
795
+ expect(entries[0]!.error).toBe("request aborted");
796
+ expect(entries[0]!.finishReason).toBeNull();
797
+ expect(map.get("conv-test")!.turn).toBe(0);
798
+ expect(finishes).toHaveLength(0);
799
+ });
800
+
801
+
802
+ test("a provider that reports usage but no cost gets its actual tokens priced at the catalog rate", async () => {
803
+ // Ollama returns usage without `cost`. Leaving the forecast (assumed 1,024
804
+ // completion tokens) as the recorded figure overstated a 26-token turn ~3x.
805
+ const { router } = mkRouter([mkDecision("trivial", "ollama/glm-5.3-flash")]);
806
+ const { upstream } = mkUpstream([
807
+ {
808
+ kind: "chunks",
809
+ chunks: [startChunk("ollama/glm-5.3-flash"), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: 1_000_000, completionTokens: 1_000_000 }, null)],
810
+ },
811
+ ]);
812
+ const { ledger, entries } = mkLedger();
813
+ const { store } = mkConversations();
814
+ const { sink, finishes } = mkSink();
815
+ const priced = {
816
+ ...catalog,
817
+ find: (slug: string) =>
818
+ slug === "ollama/glm-5.3-flash"
819
+ ? ({
820
+ slug,
821
+ provider: "ollama",
822
+ canonicalSlug: slug,
823
+ name: slug,
824
+ contextLength: 1_000_000,
825
+ supportsTools: true,
826
+ supportsReasoning: true,
827
+ reasoningMandatory: false,
828
+ supportsToolChoice: false,
829
+ inputModalities: ["text"],
830
+ price: { prompt: 0.15 / 1e6, completion: 0.5 / 1e6 },
831
+ priceTiers: [],
832
+ quality: {},
833
+ tokenizer: "Other",
834
+ isFree: false,
835
+ createdAtMs: 0,
836
+ author: "ollama",
837
+ } satisfies CatalogModel)
838
+ : undefined,
839
+ };
840
+
841
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog: priced, context: createDisabledBridge() }, new AbortController().signal);
842
+
843
+ // 1M prompt tokens at $0.15/M + 1M completion tokens at $0.50/M.
844
+ expect(entries[0]!.reportedUsd).toBeCloseTo(0.65, 6);
845
+ expect(entries[0]!.priceModel?.slug).toBe("ollama/glm-5.3-flash");
846
+ expect(finishes[0]!.reportedUsd).toBeCloseTo(0.65, 6);
847
+ });
848
+
849
+ test("an Ollama-served turn after another on the same model prices the previous prompt as cached", async () => {
850
+ // ollama.com caches prefixes and bills them at the cached rate without
851
+ // reporting a count; the router estimates it with its warm-cache rule.
852
+ const ollamaModel: CatalogModel = {
853
+ slug: "ollama/glm-5.3-flash",
854
+ provider: "ollama",
855
+ canonicalSlug: "ollama/glm-5.3-flash",
856
+ name: "glm",
857
+ contextLength: 1_000_000,
858
+ supportsTools: true,
859
+ supportsReasoning: true,
860
+ reasoningMandatory: false,
861
+ supportsToolChoice: false,
862
+ inputModalities: ["text"],
863
+ price: { prompt: 0.15 / 1e6, cacheRead: 0.03 / 1e6, completion: 0.5 / 1e6 },
864
+ priceTiers: [],
865
+ quality: {},
866
+ tokenizer: "Other",
867
+ isFree: false,
868
+ createdAtMs: 0,
869
+ author: "ollama",
870
+ };
871
+ const priced = { ...catalog, find: (slug: string) => (slug === ollamaModel.slug ? ollamaModel : undefined) };
872
+ const turn = (prompt: number) => ({
873
+ kind: "chunks" as const,
874
+ chunks: [startChunk(ollamaModel.slug), textChunk("ok"), finishChunk("stop"), usageChunk({ promptTokens: prompt, completionTokens: 0 }, null)],
875
+ });
876
+ const { router } = mkRouter([mkDecision("simple", ollamaModel.slug)]);
877
+ const { upstream } = mkUpstream([turn(100_000), turn(120_000)]);
878
+ const { ledger, entries } = mkLedger();
879
+ const { store, map } = mkConversations();
880
+ const deps = { config: mkConfig(), router, upstream, ledger, conversations: store, catalog: priced, context: createDisabledBridge() };
881
+
882
+ await runTurn(mkReq(), mkSink().sink, deps, new AbortController().signal);
883
+ // First turn: nothing to be cached yet, full input rate.
884
+ expect(entries[0]!.usage.cachedTokens).toBe(0);
885
+ expect(entries[0]!.usage.cachedEstimated).toBeUndefined();
886
+ expect(entries[0]!.reportedUsd).toBeCloseTo(0.015, 6);
887
+
888
+ await runTurn(mkReq(), mkSink().sink, deps, new AbortController().signal);
889
+ // Second turn on the same model: the 100k previous prompt is the cached
890
+ // prefix at $0.03/M, the 20k of growth is fresh at $0.15/M.
891
+ expect(entries[1]!.usage.cachedTokens).toBe(100_000);
892
+ expect(entries[1]!.usage.cachedEstimated).toBe(true);
893
+ expect(entries[1]!.reportedUsd).toBeCloseTo(0.003 + 0.003, 6);
894
+ // The estimate is evidence enough to keep the Ollama model warm for the stay/switch comparison.
895
+ expect(map.get("conv-test")!.cacheWarmSlug).toBe(ollamaModel.slug);
896
+ });
897
+
732
898
  });
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * One-off backfill: re-price historical Ollama ledger rows with the router's
4
+ * cache estimate (`src/cost/cache-estimate.ts`).
5
+ *
6
+ * Rows recorded before the estimate existed booked every prompt token at the
7
+ * full input rate, overstating Ollama spend ~3.7x against ollama.com's meter.
8
+ * This walks each conversation's Ollama rows in order, applies the same rule
9
+ * the live path applies (same model as the previous kept row within
10
+ * `hysteresis.cacheWarmTtlMs` ⇒ the previous prompt is the cached prefix),
11
+ * and rewrites `usage`, `cost_breakdown` and `reported_usd` for rows whose
12
+ * cost the router itself computed (Ollama never reports a cost). Rows that
13
+ * already carry a cache count are left alone, so it is safe to re-run.
14
+ *
15
+ * Usage:
16
+ * bun tools/recompute-ollama-cache.ts # dry run: prints the delta
17
+ * bun tools/recompute-ollama-cache.ts --apply # backs up router.db, then writes
18
+ */
19
+
20
+ import { copyFileSync, mkdirSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { Database } from "bun:sqlite";
23
+ import { ollamaRateFor } from "../src/catalog/ollama-prices.ts";
24
+ import { loadConfig } from "../src/config/load.ts";
25
+ import { estimateUnreportedCache } from "../src/cost/cache-estimate.ts";
26
+ import type { UsageCounts } from "../src/cost/types.ts";
27
+
28
+ const apply = process.argv.includes("--apply");
29
+ const cfg = loadConfig({});
30
+ const ttl = cfg.hysteresis.cacheWarmTtlMs;
31
+
32
+ interface Row {
33
+ id: string;
34
+ ck: string;
35
+ t: number;
36
+ latency: number;
37
+ slug: string;
38
+ usage: string;
39
+ reported: number | null;
40
+ wasted: number;
41
+ error: string | null;
42
+ }
43
+
44
+ const db = new Database(cfg.ledger.path);
45
+ db.exec("PRAGMA busy_timeout = 5000");
46
+ const rows = db
47
+ .query(
48
+ `SELECT id, conversation_key ck, created_at_ms t, latency_ms latency, COALESCE(served_slug, slug) slug, usage, reported_usd reported, wasted, error
49
+ FROM ledger WHERE COALESCE(served_slug, slug) LIKE 'ollama/%' ORDER BY conversation_key, created_at_ms`,
50
+ )
51
+ .all() as Row[];
52
+
53
+ let before = 0;
54
+ let after = 0;
55
+ let changed = 0;
56
+ const updates: { id: string; usage: string; breakdown: string; usd: number }[] = [];
57
+ let prev: Row | undefined;
58
+ let prevPrompt = 0;
59
+ for (const r of rows) {
60
+ const usage = JSON.parse(r.usage) as UsageCounts;
61
+ const sameConv = prev !== undefined && prev.ck === r.ck;
62
+ const rate = ollamaRateFor(r.slug.slice("ollama/".length), cfg.ollama.prices);
63
+ before += r.reported ?? 0;
64
+ if (rate === null || r.reported === null || r.error !== null) {
65
+ after += r.reported ?? 0;
66
+ if (r.error === null) {
67
+ prev = r;
68
+ prevPrompt = usage.promptTokens;
69
+ }
70
+ continue;
71
+ }
72
+ const est = estimateUnreportedCache(usage, {
73
+ previousSlug: sameConv ? (prev?.slug ?? null) : null,
74
+ previousPromptTokens: sameConv ? prevPrompt : 0,
75
+ previousAtMs: sameConv ? (prev?.t ?? 0) : 0,
76
+ servedSlug: r.slug,
77
+ nowMs: r.t,
78
+ cacheWarmTtlMs: ttl,
79
+ });
80
+ const input = rate.rate.input / 1e6;
81
+ const cached = (rate.rate.cachedInput ?? rate.rate.input) / 1e6;
82
+ const output = rate.rate.output / 1e6;
83
+ const fresh = Math.max(0, est.promptTokens - est.cachedTokens);
84
+ const breakdown = {
85
+ freshPrompt: fresh * input,
86
+ cacheRead: est.cachedTokens * cached,
87
+ cacheWrite: 0,
88
+ completion: est.completionTokens * output,
89
+ reasoning: 0,
90
+ images: 0,
91
+ request: 0,
92
+ total: 0,
93
+ tierAtPromptTokens: 0,
94
+ };
95
+ breakdown.total = breakdown.freshPrompt + breakdown.cacheRead + breakdown.completion;
96
+ after += breakdown.total;
97
+ if (est.cachedTokens !== usage.cachedTokens) {
98
+ changed++;
99
+ updates.push({ id: r.id, usage: JSON.stringify(est), breakdown: JSON.stringify(breakdown), usd: breakdown.total });
100
+ }
101
+ // The next row's "previous" is this row as dispatched, whether or not it was wasted:
102
+ // a wasted probe still warmed the prefix.
103
+ prev = r;
104
+ prevPrompt = est.promptTokens;
105
+ }
106
+
107
+ console.log(`ollama rows: ${rows.length}, re-priced: ${changed}`);
108
+ console.log(`ledger Ollama spend: $${before.toFixed(2)} → $${after.toFixed(2)}`);
109
+ if (!apply) {
110
+ console.log("dry run; pass --apply to write (router.db is backed up first)");
111
+ db.close();
112
+ process.exit(0);
113
+ }
114
+
115
+ const backupDir = join(dirname(cfg.ledger.path), "backups");
116
+ mkdirSync(backupDir, { recursive: true });
117
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "");
118
+ const backup = join(backupDir, `router-pre-ollama-cache-${stamp}.db`);
119
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
120
+ copyFileSync(cfg.ledger.path, backup);
121
+ console.log(`backup: ${backup}`);
122
+
123
+ const stmt = db.prepare("UPDATE ledger SET usage = $usage, cost_breakdown = $breakdown, reported_usd = $usd WHERE id = $id");
124
+ const tx = db.transaction((list: typeof updates) => {
125
+ for (const u of list) stmt.run({ $usage: u.usage, $breakdown: u.breakdown, $usd: u.usd, $id: u.id });
126
+ });
127
+ tx(updates);
128
+ console.log(`updated ${updates.length} rows`);
129
+ db.close();