auto-model-router 0.2.11 → 0.2.13
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.
- package/.omp-plugin/marketplace.json +2 -2
- package/package.json +1 -1
- package/src/router/state.ts +29 -6
- package/src/router/types.ts +14 -0
- package/src/server/turn.ts +22 -3
- package/test/failover.test.ts +1 -0
- package/test/state.test.ts +90 -0
- package/test/turn.test.ts +98 -2
|
@@ -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.
|
|
10
|
+
"version": "0.2.13",
|
|
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.
|
|
17
|
+
"version": "0.2.13",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/package.json
CHANGED
package/src/router/state.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
$
|
|
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
|
},
|
package/src/router/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/turn.ts
CHANGED
|
@@ -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
|
|
@@ -328,7 +335,15 @@ export async function runTurn(
|
|
|
328
335
|
if (ttftMs === null) ttftMs = Date.now() - startedAt;
|
|
329
336
|
if (doxActive) assistantText += ev.delta;
|
|
330
337
|
break;
|
|
338
|
+
// A tool call is output too. Only text/reasoning used to stamp
|
|
339
|
+
// TTFT, so a dispatch that emitted nothing BUT tool calls —
|
|
340
|
+
// the dominant shape in an agentic loop, 83% of dispatches —
|
|
341
|
+
// recorded ttft_ms NULL and was then discarded by
|
|
342
|
+
// LATENCY_SELECT, which requires it. Latency/throughput
|
|
343
|
+
// scoring was therefore measuring the minority of turns that
|
|
344
|
+
// happened to narrate, and steering all the rest with it.
|
|
331
345
|
case "reasoning":
|
|
346
|
+
case "tool_call":
|
|
332
347
|
if (ttftMs === null) ttftMs = Date.now() - startedAt;
|
|
333
348
|
break;
|
|
334
349
|
case "finish":
|
|
@@ -422,7 +437,9 @@ export async function runTurn(
|
|
|
422
437
|
// hysteresis re-arm below can tell whether this turn changed tier.
|
|
423
438
|
const prevTier = state.currentTier;
|
|
424
439
|
state.currentTier = decision.tier;
|
|
425
|
-
|
|
440
|
+
// Escalations accumulate in SQL for the same reason spend does: `save`
|
|
441
|
+
// below no longer writes this column, so a snapshot cannot clobber it.
|
|
442
|
+
conversations.accrue(req.conversationKey, { escalations });
|
|
426
443
|
// Hysteresis window. Only re-arm when the served tier actually changed
|
|
427
444
|
// (or this turn escalated). Re-arming on EVERY turn — even a trivial one
|
|
428
445
|
// served by a held hard model — extends the lock forever: the classifier
|
|
@@ -434,9 +451,11 @@ export async function runTurn(
|
|
|
434
451
|
if (tierChanged || escalations > 0) {
|
|
435
452
|
state.stickyUntilTurn = turnNumber + resolveHoldTurns(config, req.conversationKey, escalations > 0).turns;
|
|
436
453
|
}
|
|
437
|
-
//
|
|
438
|
-
//
|
|
454
|
+
// Spend is already booked in `writeEntry`, beside the ledger row, so it is
|
|
455
|
+
// deliberately NOT accumulated here — doing both would double-count.
|
|
456
|
+
// Keep the in-memory copy coherent for anything reading `state` later.
|
|
439
457
|
state.spentUsd += reportedUsd ?? decision.forecast.expectedUsd;
|
|
458
|
+
state.escalations += escalations;
|
|
440
459
|
state.lastPromptTokens = usage.promptTokens;
|
|
441
460
|
if (usage.cachedTokens > 0 || usage.cacheWriteTokens > 0) {
|
|
442
461
|
// Non-zero cache traffic is direct evidence the upstream cache exists.
|
package/test/failover.test.ts
CHANGED
|
@@ -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(): {
|
|
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,87 @@ 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
|
+
});
|
|
697
|
+
|
|
698
|
+
describe("latency measurement covers the work the router actually does", () => {
|
|
699
|
+
test("a tool-call-only dispatch still records TTFT", async () => {
|
|
700
|
+
// 83% of dispatches in an agentic loop finish with `tool_calls`, and 64.5%
|
|
701
|
+
// of those recorded ttft_ms NULL because only text/reasoning stamped it.
|
|
702
|
+
// LATENCY_SELECT requires ttft_ms, so throughput scoring (weight 0.75) was
|
|
703
|
+
// measuring the narrating minority and steering everything else with it.
|
|
704
|
+
const { router } = mkRouter([mkDecision("simple", "cheap/model", { maxTokens: 1, escalateTo: null })]);
|
|
705
|
+
const { upstream } = mkUpstream([
|
|
706
|
+
{
|
|
707
|
+
kind: "chunks",
|
|
708
|
+
chunks: [
|
|
709
|
+
startChunk("cheap/model"),
|
|
710
|
+
chunk([{ type: "tool_call", index: 0, id: "c1", name: "read", argsDelta: '{"path":"a.ts"}' }]),
|
|
711
|
+
finishChunk("tool_calls"),
|
|
712
|
+
usageChunk({ promptTokens: 50_000, completionTokens: 160 }, 0.004),
|
|
713
|
+
],
|
|
714
|
+
},
|
|
715
|
+
]);
|
|
716
|
+
const { ledger, entries } = mkLedger();
|
|
717
|
+
const { store } = mkConversations();
|
|
718
|
+
const { sink, errors } = mkSink();
|
|
719
|
+
|
|
720
|
+
await runTurn(mkReq(), sink, { config: mkConfig(), router, upstream, ledger, conversations: store, catalog, context: createDisabledBridge() }, new AbortController().signal);
|
|
721
|
+
|
|
722
|
+
expect(errors).toHaveLength(0);
|
|
723
|
+
expect(entries).toHaveLength(1);
|
|
724
|
+
expect(entries[0]?.finishReason).toBe("tool_calls");
|
|
725
|
+
// The qualifying condition for latency stats: a real, positive TTFT.
|
|
726
|
+
expect(entries[0]?.ttftMs).not.toBeNull();
|
|
727
|
+
expect(entries[0]?.ttftMs ?? -1).toBeGreaterThanOrEqual(0);
|
|
728
|
+
});
|
|
729
|
+
});
|