auto-model-router 0.2.11 → 0.2.12

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.2.11",
10
+ "version": "0.2.12",
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.2.11",
17
+ "version": "0.2.12",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.11",
3
+ "version": "0.2.12",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -57,13 +57,17 @@ export function createConversationStore(db: Database): ConversationStore {
57
57
  const insertOne: Statement<unknown, [string, string, number]> = db.query(
58
58
  "INSERT INTO conversations (key, session_id, updated_at_ms) VALUES (?, ?, ?)",
59
59
  );
60
+ // `spent_usd` and `escalations` are ABSENT from this statement on purpose.
61
+ // They accumulate through `accrueOne` below, so writing a turn-start snapshot
62
+ // back here would erase whatever a billed-but-uncommitted dispatch added.
63
+ // The schema defaults both to 0, so the INSERT arm still works.
60
64
  const upsert = db.query(`
61
65
  INSERT INTO conversations (
62
66
  key, session_id, turn, current_slug, current_tier, sticky_until_turn,
63
- escalations, spent_usd, last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
67
+ last_prompt_tokens, cache_warm_slug, cache_warm_at_ms,
64
68
  context_version, context_fetched_at_ms, updated_at_ms
65
69
  ) VALUES ($key, $sessionId, $turn, $currentSlug, $currentTier, $stickyUntilTurn,
66
- $escalations, $spentUsd, $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
70
+ $lastPromptTokens, $cacheWarmSlug, $cacheWarmAtMs,
67
71
  $contextVersion, $contextFetchedAtMs, $updatedAtMs)
68
72
  ON CONFLICT(key) DO UPDATE SET
69
73
  session_id = excluded.session_id,
@@ -71,8 +75,6 @@ export function createConversationStore(db: Database): ConversationStore {
71
75
  current_slug = excluded.current_slug,
72
76
  current_tier = excluded.current_tier,
73
77
  sticky_until_turn = excluded.sticky_until_turn,
74
- escalations = excluded.escalations,
75
- spent_usd = excluded.spent_usd,
76
78
  last_prompt_tokens = excluded.last_prompt_tokens,
77
79
  cache_warm_slug = excluded.cache_warm_slug,
78
80
  cache_warm_at_ms = excluded.cache_warm_at_ms,
@@ -80,6 +82,19 @@ export function createConversationStore(db: Database): ConversationStore {
80
82
  context_fetched_at_ms = excluded.context_fetched_at_ms,
81
83
  updated_at_ms = excluded.updated_at_ms
82
84
  `);
85
+ // Read-modify-write in JS lost money: an aborted or failed dispatch is still
86
+ // billed by the upstream, but it returns before the commit path, so the next
87
+ // dispatch loaded a stale total and overwrote it. Measured on live data:
88
+ // 152 aborted dispatches billing $0.9985 — 30% of all spend — never reached
89
+ // `spent_usd`, leaving the per-conversation budget guard blind to it.
90
+ // Accumulating in SQL is correct regardless of who raced whom.
91
+ const accrueOne = db.query(`
92
+ UPDATE conversations
93
+ SET spent_usd = spent_usd + $spentUsd,
94
+ escalations = escalations + $escalations,
95
+ updated_at_ms = $updatedAtMs
96
+ WHERE key = $key
97
+ `);
83
98
  const deleteStale: Statement<unknown, [number]> = db.query("DELETE FROM conversations WHERE updated_at_ms < ?");
84
99
 
85
100
  return {
@@ -103,6 +118,7 @@ export function createConversationStore(db: Database): ConversationStore {
103
118
  save(state) {
104
119
  // bun:sqlite matches named parameters by their literal `$name` key;
105
120
  // bare keys bind nothing at all and every column silently lands NULL.
121
+ // No $spentUsd / $escalations here — see the statement above.
106
122
  upsert.run({
107
123
  $key: state.key,
108
124
  $sessionId: state.sessionId,
@@ -110,8 +126,6 @@ export function createConversationStore(db: Database): ConversationStore {
110
126
  $currentSlug: state.currentSlug,
111
127
  $currentTier: state.currentTier,
112
128
  $stickyUntilTurn: state.stickyUntilTurn,
113
- $escalations: state.escalations,
114
- $spentUsd: state.spentUsd,
115
129
  $lastPromptTokens: state.lastPromptTokens,
116
130
  $cacheWarmSlug: state.cacheWarmSlug,
117
131
  $cacheWarmAtMs: state.cacheWarmAtMs,
@@ -121,6 +135,15 @@ export function createConversationStore(db: Database): ConversationStore {
121
135
  });
122
136
  },
123
137
 
138
+ accrue(key, delta) {
139
+ const spentUsd = delta.spentUsd ?? 0;
140
+ const escalations = delta.escalations ?? 0;
141
+ // Nothing to add: skip the write rather than bump updated_at_ms and
142
+ // keep a dead conversation alive against `prune`.
143
+ if (spentUsd === 0 && escalations === 0) return;
144
+ accrueOne.run({ $key: key, $spentUsd: spentUsd, $escalations: escalations, $updatedAtMs: Date.now() });
145
+ },
146
+
124
147
  prune(maxAgeMs) {
125
148
  return deleteStale.run(Date.now() - maxAgeMs).changes;
126
149
  },
@@ -179,7 +179,21 @@ export interface ConversationStore {
179
179
  get(key: string): ConversationState | null;
180
180
  /** Loads existing state or creates a fresh record. */
181
181
  load(key: string): ConversationState;
182
+ /**
183
+ * Persists the latest-wins fields. Deliberately does NOT write `spentUsd` or
184
+ * `escalations` — those accumulate via `accrue`, and writing back a snapshot
185
+ * here would clobber what a concurrent or already-billed dispatch added.
186
+ */
182
187
  save(state: ConversationState): void;
188
+ /**
189
+ * Adds to the persisted counters, atomically in SQL.
190
+ *
191
+ * Separate from `save` because a dispatch that never reaches the commit path
192
+ * — a client abort, an upstream error — was still BILLED, and its cost must
193
+ * reach the per-conversation budget guard anyway. Requires `load` to have
194
+ * created the row.
195
+ */
196
+ accrue(key: string, delta: { spentUsd?: number; escalations?: number }): void;
183
197
  /** Drops records untouched for longer than `maxAgeMs`. */
184
198
  prune(maxAgeMs: number): number;
185
199
  }
@@ -230,6 +230,13 @@ export async function runTurn(
230
230
  error: fields.error,
231
231
  promptTokensSaved: decision.promptTokensSaved,
232
232
  });
233
+ // Book the money HERE, beside the ledger row, so the two can never
234
+ // disagree. Every dispatch that reaches this point was billed —
235
+ // committed, wasted by an escalation, or aborted mid-stream — but only
236
+ // the committed path used to reach the state update below, so aborted
237
+ // dispatches (30% of real spend on live data) stayed invisible to the
238
+ // per-conversation budget guard.
239
+ conversations.accrue(req.conversationKey, { spentUsd: reportedUsd ?? decision.forecast.expectedUsd });
233
240
  };
234
241
 
235
242
  // "retry" re-enters the attempt loop; "done" means the turn is settled
@@ -422,7 +429,9 @@ export async function runTurn(
422
429
  // hysteresis re-arm below can tell whether this turn changed tier.
423
430
  const prevTier = state.currentTier;
424
431
  state.currentTier = decision.tier;
425
- state.escalations += escalations;
432
+ // Escalations accumulate in SQL for the same reason spend does: `save`
433
+ // below no longer writes this column, so a snapshot cannot clobber it.
434
+ conversations.accrue(req.conversationKey, { escalations });
426
435
  // Hysteresis window. Only re-arm when the served tier actually changed
427
436
  // (or this turn escalated). Re-arming on EVERY turn — even a trivial one
428
437
  // served by a held hard model — extends the lock forever: the classifier
@@ -434,9 +443,11 @@ export async function runTurn(
434
443
  if (tierChanged || escalations > 0) {
435
444
  state.stickyUntilTurn = turnNumber + resolveHoldTurns(config, req.conversationKey, escalations > 0).turns;
436
445
  }
437
- // Reported cost is authoritative; fall back to the forecast so the
438
- // budget guard still works when the provider omits cost.
446
+ // Spend is already booked in `writeEntry`, beside the ledger row, so it is
447
+ // deliberately NOT accumulated here doing both would double-count.
448
+ // Keep the in-memory copy coherent for anything reading `state` later.
439
449
  state.spentUsd += reportedUsd ?? decision.forecast.expectedUsd;
450
+ state.escalations += escalations;
440
451
  state.lastPromptTokens = usage.promptTokens;
441
452
  if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
442
453
  // Non-zero cache traffic is direct evidence the upstream cache exists.
@@ -275,6 +275,7 @@ function mkConversations(): { store: ConversationStore; map: Map<string, Convers
275
275
  save: (s) => {
276
276
  map.set(s.key, s);
277
277
  },
278
+ accrue: () => {},
278
279
  prune: () => 0,
279
280
  };
280
281
  return { store, map };
@@ -0,0 +1,90 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { createConversationStore } from "../src/router/state.ts";
4
+ import { openDb } from "../src/util/sqlite.ts";
5
+
6
+ function mkStore() {
7
+ const db = openDb(":memory:");
8
+ return { db, store: createConversationStore(db) };
9
+ }
10
+
11
+ describe("conversation spend accounting", () => {
12
+ test("accrue accumulates instead of overwriting", () => {
13
+ const { db, store } = mkStore();
14
+ try {
15
+ store.load("k1");
16
+ store.accrue("k1", { spentUsd: 0.25 });
17
+ store.accrue("k1", { spentUsd: 0.5 });
18
+ store.accrue("k1", { escalations: 1 });
19
+ store.accrue("k1", { escalations: 2 });
20
+
21
+ const state = store.get("k1");
22
+ expect(state?.spentUsd).toBeCloseTo(0.75, 10);
23
+ expect(state?.escalations).toBe(3);
24
+ } finally {
25
+ db.close();
26
+ }
27
+ });
28
+
29
+ test("save cannot clobber spend booked by a dispatch that never committed", () => {
30
+ // The live bug: a dispatch is billed by the upstream, aborts mid-stream,
31
+ // and returns before the commit path. The NEXT dispatch had already loaded
32
+ // a turn-start snapshot, and `save` wrote that snapshot's stale total back
33
+ // over the aborted dispatch's cost. 30% of real spend vanished this way.
34
+ const { db, store } = mkStore();
35
+ try {
36
+ const snapshot = store.load("k1");
37
+ expect(snapshot.spentUsd).toBe(0);
38
+
39
+ // An aborted dispatch books its cost while `snapshot` is still in hand.
40
+ store.accrue("k1", { spentUsd: 0.4, escalations: 1 });
41
+
42
+ // The in-flight turn now commits using the state it loaded earlier.
43
+ snapshot.turn = 1;
44
+ snapshot.currentSlug = "cheap/model";
45
+ store.save(snapshot);
46
+
47
+ const after = store.get("k1");
48
+ expect(after?.spentUsd).toBeCloseTo(0.4, 10);
49
+ expect(after?.escalations).toBe(1);
50
+ // The latest-wins fields still persist normally.
51
+ expect(after?.turn).toBe(1);
52
+ expect(after?.currentSlug).toBe("cheap/model");
53
+ } finally {
54
+ db.close();
55
+ }
56
+ });
57
+
58
+ test("interleaved dispatches both keep their money", () => {
59
+ const { db, store } = mkStore();
60
+ try {
61
+ const a = store.load("k1");
62
+ const b = store.get("k1");
63
+ expect(b).not.toBeNull();
64
+
65
+ store.accrue("k1", { spentUsd: 0.1 });
66
+ store.save(a);
67
+ store.accrue("k1", { spentUsd: 0.2 });
68
+ if (b !== null) store.save(b);
69
+
70
+ expect(store.get("k1")?.spentUsd).toBeCloseTo(0.3, 10);
71
+ } finally {
72
+ db.close();
73
+ }
74
+ });
75
+
76
+ test("a zero delta does not touch the row", () => {
77
+ // Bumping updated_at_ms for a no-op write would keep a dead conversation
78
+ // alive against `prune`, which reaps on that timestamp.
79
+ const { db, store } = mkStore();
80
+ try {
81
+ store.load("k1");
82
+ const before = store.get("k1")?.updatedAtMs ?? 0;
83
+ expect(before).toBeGreaterThan(0);
84
+ store.accrue("k1", { spentUsd: 0, escalations: 0 });
85
+ expect(store.get("k1")?.updatedAtMs).toBe(before);
86
+ } finally {
87
+ db.close();
88
+ }
89
+ });
90
+ });
package/test/turn.test.ts CHANGED
@@ -241,8 +241,14 @@ function mkLedger(): { ledger: Ledger; entries: LedgerEntry[] } {
241
241
  return { ledger, entries };
242
242
  }
243
243
 
244
- function mkConversations(): { store: ConversationStore; map: Map<string, ConversationState> } {
244
+ function mkConversations(): {
245
+ store: ConversationStore;
246
+ map: Map<string, ConversationState>;
247
+ accrued: Map<string, { spentUsd: number; escalations: number }>;
248
+ } {
245
249
  const map = new Map<string, ConversationState>();
250
+ // Mirrors the real store: money accumulates here, NOT through `save`.
251
+ const accrued = new Map<string, { spentUsd: number; escalations: number }>();
246
252
  const store: ConversationStore = {
247
253
  get: (k) => map.get(k) ?? null,
248
254
  load: (k) => {
@@ -270,9 +276,15 @@ function mkConversations(): { store: ConversationStore; map: Map<string, Convers
270
276
  save: (s) => {
271
277
  map.set(s.key, s);
272
278
  },
279
+ accrue: (k, d) => {
280
+ const cur = accrued.get(k) ?? { spentUsd: 0, escalations: 0 };
281
+ cur.spentUsd += d.spentUsd ?? 0;
282
+ cur.escalations += d.escalations ?? 0;
283
+ accrued.set(k, cur);
284
+ },
273
285
  prune: () => 0,
274
286
  };
275
- return { store, map };
287
+ return { store, map, accrued };
276
288
  }
277
289
 
278
290
  function mkSink(): { sink: ResponseSink; chunks: UpstreamChunk[]; errors: WireError[]; finishes: TurnSummary[] } {
@@ -631,3 +643,54 @@ describe("agentdox write-back sees the shape of the turn", () => {
631
643
  expect(records).toHaveLength(0);
632
644
  });
633
645
  });
646
+
647
+ describe("spend reaches the conversation total however the dispatch ends", () => {
648
+ test("a dispatch that dies mid-stream still books what it was billed", async () => {
649
+ // Live data: 152 aborted dispatches billed $0.9985 — 30% of all spend —
650
+ // and none of it reached the conversation's running total, because an abort
651
+ // returns before the commit path. The ledger row and the per-conversation
652
+ // budget guard must never disagree about money. Probe maxTokens 1 commits
653
+ // on the first token, so the retryable error below cannot re-enter the
654
+ // attempt loop and confuse the accounting.
655
+ const { router } = mkRouter([mkDecision("simple", "cheap/model", { maxTokens: 1, escalateTo: null })]);
656
+ const { upstream } = mkUpstream([
657
+ {
658
+ kind: "die",
659
+ chunks: [
660
+ startChunk("cheap/model"),
661
+ textChunk("plenty of text here, enough to commit on"),
662
+ usageChunk({ promptTokens: 47_700, completionTokens: 154 }, 0.0071),
663
+ ],
664
+ error: new UpstreamError("network", 0, "request aborted", true),
665
+ },
666
+ ]);
667
+ const { ledger, entries } = mkLedger();
668
+ const { store, accrued } = mkConversations();
669
+ const { sink } = mkSink();
670
+
671
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
672
+
673
+ // The turn never committed: the ledger row carries the error.
674
+ expect(entries).toHaveLength(1);
675
+ expect(entries[0]?.error).not.toBeNull();
676
+ // ...but the money was still booked.
677
+ expect(accrued.get("conv-test")?.spentUsd).toBeCloseTo(0.0071, 10);
678
+ });
679
+
680
+ test("a committed turn books its cost exactly once", async () => {
681
+ const { router } = mkRouter([mkDecision("simple", "cheap/model", { escalateTo: null })]);
682
+ const { upstream } = mkUpstream([
683
+ { kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("done"), finishChunk("stop"), usageChunk({}, 0.002)] },
684
+ ]);
685
+ const { ledger, entries } = mkLedger();
686
+ const { store, accrued } = mkConversations();
687
+ const { sink, errors } = mkSink();
688
+
689
+ await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
690
+
691
+ expect(errors).toHaveLength(0);
692
+ expect(entries).toHaveLength(1);
693
+ // Booked in writeEntry only — the commit path must not add it again.
694
+ expect(accrued.get("conv-test")?.spentUsd).toBeCloseTo(0.002, 10);
695
+ });
696
+ });