From b0ea3b165487e6e7afbef9014670edd23e0bf2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 00:33:18 +0200 Subject: [PATCH] fix(ai): charge what failed hops spent when a later hop answers A chain whose first provider reasoned without answering and whose second one answered billed only the answer: result.tokensUsed is the answering call's alone, and the failed hop's spend, already reported on its FallbackHop, was dropped by every caller that reconciles the day's ledger. The raw runners now report tokensSpent on success too, the sum the failed hops before the answer reported. The briefing, status, Coach (no-tools path and every tool-loop round) and medication-extract reconciles charge it on top of the answer. On failure, the Coach turn ledger's close keeps what the failed chain reported spending instead of refunding everything, and the medication-extract and document-chat routes charge it too, as the status and briefing paths already did. Document indexing resolves a single provider and already charged its failed call. Refs #1279 --- .../baselines/source-function-lines.json | 9 ++-- .../inbound/[id]/chat/__tests__/route.test.ts | 3 ++ .../insights/chat/__tests__/dialog-harness.ts | 1 + .../insights/chat/__tests__/golden-harness.ts | 1 + .../chat/__tests__/route-guard.test.ts | 1 + .../__tests__/route-snapshot-once.test.ts | 2 + .../chat/__tests__/route-tool-mode.test.ts | 1 + .../__tests__/route-workout-scope.test.ts | 1 + .../extract/__tests__/route.test.ts | 4 ++ src/app/api/medications/extract/route.ts | 46 +++++++++---------- .../provider-runner-background-bench.test.ts | 30 ++++++++++++ ...ools-loop-always-reasoning-gateway.test.ts | 1 + .../coach/__tests__/tools-loop-budget.test.ts | 1 + .../__tests__/tools-loop-callbacks.test.ts | 1 + .../tools-loop-final-round-wire.test.ts | 1 + .../tools-loop-miss-payloads.test.ts | 4 ++ src/lib/ai/coach/__tests__/tools-loop.test.ts | 1 + .../__tests__/loop-result-tables.test.ts | 1 + src/lib/ai/coach/tools/loop.ts | 2 +- .../turn/__tests__/ledger-reasoning.test.ts | 8 ++++ src/lib/ai/coach/turn/budget.ts | 8 ++-- src/lib/ai/coach/turn/model.ts | 29 ++++++------ src/lib/ai/provider-runner.ts | 7 +++ .../__tests__/fenced-chat-pipeline.test.ts | 2 + src/lib/documents/fenced-chat.ts | 13 +++--- .../briefing-provider-budget.test.ts | 14 ++++++ ...ensive-briefing-grounding-disposal.test.ts | 1 + .../comprehensive-briefing-reroll.test.ts | 2 + .../comprehensive-budget-refusal.test.ts | 3 ++ .../comprehensive-generate-force.test.ts | 6 +++ .../comprehensive-json-retry.test.ts | 4 ++ .../comprehensive-outbound-repair.test.ts | 1 + .../comprehensive-plan-progress.test.ts | 1 + .../comprehensive-privacy-flip.test.ts | 6 +++ .../comprehensive-timeout-honored.test.ts | 2 + .../comprehensive-warm-timeout.test.ts | 1 + .../__tests__/status-provider-budget.test.ts | 16 +++++++ .../status-provider-slow-model.test.ts | 1 + src/lib/insights/briefing-provider.ts | 4 +- src/lib/insights/status-provider.ts | 13 +++--- .../ai-extraction-capability.test.ts | 1 + tests/integration/coach-chat.test.ts | 4 ++ .../parity/insights-generate.parity.test.ts | 1 + 43 files changed, 198 insertions(+), 61 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 7e0885638..16c5b2580 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 351, - "total": 113530, + "count": 350, + "total": 113326, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -31,7 +31,6 @@ "app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256, "app/api/medications/[id]/inventory/[itemId]/route.ts :: apiHandler(arg)": 204, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts :: apiHandler(arg)": 250, - "app/api/medications/extract/route.ts :: handleExtract": 201, "app/api/mental-health/assessments/route.ts :: postAssessment": 249, "app/api/mood-entries/[id]/route.ts :: apiHandler(arg) #2": 296, "app/api/mood-entries/route.ts :: postMoodEntry": 324, @@ -250,7 +249,7 @@ "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 881, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, - "lib/ai/coach/turn/model.ts :: runTurnModel": 540, + "lib/ai/coach/turn/model.ts :: runTurnModel": 538, "lib/ai/coach/turn/pipeline.ts :: runTurn": 274, "lib/ai/coach/turn/reply-guards.ts :: guardReply": 331, "lib/ai/codex-client.ts :: parseResponse": 302, @@ -298,7 +297,7 @@ "lib/insights/comprehensive-generate.ts :: generateComprehensiveInsight": 876, "lib/insights/features.ts :: extractFeatures": 796, "lib/insights/mood-aggregates.ts :: fetchMoodAggregates": 246, - "lib/insights/status-provider.ts :: runStatusCompletion": 225, + "lib/insights/status-provider.ts :: runStatusCompletion": 224, "lib/integrations/connection-health-load.ts :: loadConnectionFacts": 227, "lib/integrations/status.ts :: recordSyncFailure": 292, "lib/jobs/apple-health-import-worker.ts :: handleAppleHealthImport": 300, diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index 1cee38bac..823dd0fd6 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -210,6 +210,7 @@ beforeEach(() => { }, workingProvider: { providerType: "anthropic", instance: {} }, fallbackHops: [], + tokensSpent: 0, } as never); }); @@ -356,6 +357,7 @@ describe("POST — numeric grounding", () => { }, workingProvider: { providerType: "anthropic", instance: {} }, fallbackHops: [], + tokensSpent: 0, } as never); const res = await POST( req("doc-1", { message: "what is my ldl?" }) as never, @@ -393,6 +395,7 @@ describe("POST — numeric grounding", () => { }, workingProvider: { providerType: "anthropic", instance: {} }, fallbackHops: [], + tokensSpent: 0, } as never); const res = await POST( req("doc-1", { message: "how is my ldl?" }) as never, diff --git a/src/app/api/insights/chat/__tests__/dialog-harness.ts b/src/app/api/insights/chat/__tests__/dialog-harness.ts index dff956574..178ef3964 100644 --- a/src/app/api/insights/chat/__tests__/dialog-harness.ts +++ b/src/app/api/insights/chat/__tests__/dialog-harness.ts @@ -325,6 +325,7 @@ async function runRawCompletionWithFallback(args: { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/app/api/insights/chat/__tests__/golden-harness.ts b/src/app/api/insights/chat/__tests__/golden-harness.ts index 64324be38..e08e7c9aa 100644 --- a/src/app/api/insights/chat/__tests__/golden-harness.ts +++ b/src/app/api/insights/chat/__tests__/golden-harness.ts @@ -349,6 +349,7 @@ export function streamingResult(content: string) { model: "m-local", }, workingProvider: { providerType: "local" }, + tokensSpent: 0, }; }; } diff --git a/src/app/api/insights/chat/__tests__/route-guard.test.ts b/src/app/api/insights/chat/__tests__/route-guard.test.ts index 6499eb771..33603cc8e 100644 --- a/src/app/api/insights/chat/__tests__/route-guard.test.ts +++ b/src/app/api/insights/chat/__tests__/route-guard.test.ts @@ -264,6 +264,7 @@ async function noToolTurn(content: string): Promise { runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content, tokensUsed: 42, model: "m" }, workingProvider: { providerType: "anthropic" }, + tokensSpent: 0, }); const res = await post(chatReq({ message: "How is my BP?" })); await sse.done; diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index 401598337..613398663 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -220,6 +220,7 @@ describe("coach chat — stateless no-tools snapshot grounding", () => { runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" }, workingProvider: { providerType: "admin-openai" }, + tokensSpent: 0, }); }); @@ -276,6 +277,7 @@ describe("coach chat — snapshot stays grounded across transcript elision", () runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content: "Your BP looks stable.", tokensUsed: 42, model: "m" }, workingProvider: { providerType: "admin-openai" }, + tokensSpent: 0, }); }); diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 7f25ae425..947964daa 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -569,6 +569,7 @@ describe("coach chat — tool-mode routing (F1)", () => { runStreamingRawCompletionWithFallback.mockResolvedValue({ result: { content: "Your BP is steady.", tokensUsed: 42, model: "m" }, workingProvider: { providerType: "anthropic" }, + tokensSpent: 0, }); await postAndDrain({ message: "How is my BP?" }); diff --git a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts index 64fad5815..11e0041ac 100644 --- a/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts +++ b/src/app/api/insights/chat/__tests__/route-workout-scope.test.ts @@ -292,6 +292,7 @@ beforeEach(() => { model: "m", }, workingProvider: { providerType: "admin-openai" }, + tokensSpent: 0, }); isModuleEnabled.mockResolvedValue(true); }); diff --git a/src/app/api/medications/extract/__tests__/route.test.ts b/src/app/api/medications/extract/__tests__/route.test.ts index f13877232..81d06c85b 100644 --- a/src/app/api/medications/extract/__tests__/route.test.ts +++ b/src/app/api/medications/extract/__tests__/route.test.ts @@ -192,6 +192,7 @@ describe("POST /api/medications/extract — happy path", () => { }, workingProvider: { providerType: "openai", instance: {} as never }, fallbackHops: [], + tokensSpent: 0, }); const res = await POST( @@ -237,6 +238,7 @@ describe("POST /api/medications/extract — happy path", () => { }, workingProvider: { providerType: "openai", instance: {} as never }, fallbackHops: [], + tokensSpent: 0, }); const res = await POST( @@ -261,6 +263,7 @@ describe("POST /api/medications/extract — daily budget", () => { }, workingProvider: { providerType: "openai", instance: {} as never }, fallbackHops: [], + tokensSpent: 0, }; } @@ -409,6 +412,7 @@ describe("POST /api/medications/extract — validation + provider errors", () => }, workingProvider: { providerType: "openai", instance: {} as never }, fallbackHops: [], + tokensSpent: 0, }); const res = await POST(postReq({ text: "Mounjaro 5mg weekly" }) as never); diff --git a/src/app/api/medications/extract/route.ts b/src/app/api/medications/extract/route.ts index 4764d4d08..b0086519f 100644 --- a/src/app/api/medications/extract/route.ts +++ b/src/app/api/medications/extract/route.ts @@ -71,6 +71,7 @@ import { AllProvidersFailedError, runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { spentOnFailure } from "@/lib/ai/reasoning/answerless"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { singleUserTurn } from "@/lib/ai/types"; @@ -230,13 +231,9 @@ async function handleExtract(request: NextRequest): Promise { locale, }); - // Atomically reserve the day's budget BEFORE the provider call. The old - // `enforceBudget` read-then-write left a TOCTOU window (concurrent calls - // could each observe a sub-cap total and all proceed) AND passed no cap, so - // it always measured against `OPERATOR_COST_CAP` — rationing a self-hoster - // paying for their own key by the operator's ceiling. `resolveDailyCap` - // charges the operator ceiling only when the chain's primary is the - // operator's own credential; every other chain gets the user-plan ceiling. + // Atomically reserve the day's budget BEFORE the provider call, against the + // cost owner's ceiling: the operator's only when the chain's primary is the + // operator's own credential, the user-plan ceiling for every other chain. const dateKey = accountToday; const estimatedTokens = MAX_OUTPUT_TOKENS + @@ -268,11 +265,9 @@ async function handleExtract(request: NextRequest): Promise { params: singleUserTurn({ system: systemPrompt, user: userPrompt, - // Low temperature because we want a deterministic field - // extractor, not a creative rephrase. + // A deterministic field extractor, not a creative rephrase. temperature: 0.1, - // The reply body is a single small JSON object; 600 is well - // over the empirical max we have seen on a 2k-char input. + // One small JSON object; well over the largest seen on 2k chars. maxTokens: MAX_OUTPUT_TOKENS, // Parsed with `parseModelJson` (JSON.parse) below — opt the OpenAI / // Codex chains into their strict JSON-object mode (gated on this flag). @@ -280,12 +275,17 @@ async function handleExtract(request: NextRequest): Promise { }), }); } catch (err) { - // Nothing was generated — refund the whole reservation rather than bill an - // invented figure. Best-effort: a failed refund must not mask the 503. - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { - servedBy: null, - reservedOwner: reservation.owner, - }).catch(() => {}); + // Nothing was generated — refund the reservation rather than bill an + // invented figure, keeping only what the failed hops reported spending + // (#1279). Best-effort: a failed refund must not mask the 503. + await reconcileSpend( + userId, + reservation.reserved, + spentOnFailure(err), + dateKey, + 0, + { servedBy: null, reservedOwner: reservation.owner }, + ).catch(() => {}); if (err instanceof AllProvidersFailedError) { annotate({ action: { name: "medications.extract.provider-failed" }, @@ -296,12 +296,12 @@ async function handleExtract(request: NextRequest): Promise { throw err; } - // Reconcile against what the provider actually reported. This runs for the - // unusable-reply branches too: those tokens were burned upstream even though - // the body could not be used, so they stay on the ledger rather than funding - // a free retry loop. Falls back to the reservation when the provider reports - // no count, so an unreported generation is never billed as zero. - const actualTokens = completion.result.tokensUsed ?? reservation.reserved; + // What the provider reported, plus (#1279) what the hops that failed before + // it spent; for the unusable-reply branches too, since those tokens were + // burned upstream. No count falls back to the reservation, never to zero. + const actualTokens = + (completion.result.tokensUsed ?? reservation.reserved) + + completion.tokensSpent; await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, { servedBy: completion.workingProvider.providerType, reservedOwner: reservation.owner, diff --git a/src/lib/ai/__tests__/provider-runner-background-bench.test.ts b/src/lib/ai/__tests__/provider-runner-background-bench.test.ts index 708c8bbac..976b546b4 100644 --- a/src/lib/ai/__tests__/provider-runner-background-bench.test.ts +++ b/src/lib/ai/__tests__/provider-runner-background-bench.test.ts @@ -210,6 +210,36 @@ describe("#1279: background work and a benched provider", () => { expect(local.calls).toBe(PROVIDER_PAUSE_THRESHOLD); }); + it("reports what the hops before the answering one spent", async () => { + const ledger = createInMemoryProviderHealthLedger(); + const out = await runRawCompletionWithFallback({ + userId: "u-spent-then-answered", + surface: "job", + providers: [ + { + providerType: "openai-compatible", + instance: new AnswerlessProvider(), + }, + { + providerType: "anthropic", + instance: { + type: "anthropic", + generateCompletion: async () => ({ + content: "ok", + tokensUsed: 100, + model: "m", + providerType: "anthropic", + }), + } as AIProvider, + }, + ], + params: singleUserTurn({ system: "s", user: "u" }), + ledger, + }); + expect(out.result.tokensUsed).toBe(100); + expect(out.tokensSpent).toBe(2700); + }); + it("sums what the failed hops reported spending", async () => { const ledger = createInMemoryProviderHealthLedger(); const err = await runRawCompletionWithFallback({ diff --git a/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts b/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts index 29c02bae1..f1be3a3be 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-always-reasoning-gateway.test.ts @@ -209,6 +209,7 @@ async function turn(level: ReasoningLevel) { result: await client.generateCompletion(params), workingProvider: { providerType: "openai-compatible" }, fallbackHops: [], + tokensSpent: 0, }), ); return runCoachToolLoop({ diff --git a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts index 678e79f30..f7ae2181d 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-budget.test.ts @@ -61,6 +61,7 @@ function round( }, workingProvider: { providerType: "codex" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts index 0c09a5057..941e2889a 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-callbacks.test.ts @@ -37,6 +37,7 @@ function round(content: string, toolCalls?: Call[]) { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts index c7f1e2abf..a97c0f021 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-final-round-wire.test.ts @@ -98,6 +98,7 @@ async function capTurn( result: await client.generateCompletion({ ...params, ...extra }), workingProvider: { providerType }, fallbackHops: [], + tokensSpent: 0, }), ); return runCoachToolLoop({ diff --git a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts index 8cc8aa3ee..381e65d51 100644 --- a/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop-miss-payloads.test.ts @@ -65,6 +65,7 @@ describe("coach tool loop — which results reach the grounding guard", () => { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }) .mockResolvedValueOnce({ result: { @@ -76,6 +77,7 @@ describe("coach tool loop — which results reach the grounding guard", () => { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }); const loop = await runCoachToolLoop(baseArgs); @@ -110,6 +112,7 @@ describe("coach tool loop — which results reach the grounding guard", () => { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }) .mockResolvedValueOnce({ result: { @@ -121,6 +124,7 @@ describe("coach tool loop — which results reach the grounding guard", () => { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }); const loop = await runCoachToolLoop(baseArgs); diff --git a/src/lib/ai/coach/__tests__/tools-loop.test.ts b/src/lib/ai/coach/__tests__/tools-loop.test.ts index 36509a431..a71f44af7 100644 --- a/src/lib/ai/coach/__tests__/tools-loop.test.ts +++ b/src/lib/ai/coach/__tests__/tools-loop.test.ts @@ -42,6 +42,7 @@ function completion(opts: { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts index 02ad76e56..674a9577b 100644 --- a/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts +++ b/src/lib/ai/coach/results/__tests__/loop-result-tables.test.ts @@ -35,6 +35,7 @@ function round(content: string, toolCalls?: unknown[]) { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 5bfb5cc6a..1e5441adb 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -401,7 +401,7 @@ export async function runCoachToolLoop(args: { }); if (spend) { await spend.settleRound({ - tokens: result.tokensUsed ?? 0, + tokens: (result.tokensUsed ?? 0) + fallback.tokensSpent, cachedTokens: result.cachedInputTokens ?? 0, servedBy: fallback.workingProvider.providerType as ProviderChainType, final: isFinal, diff --git a/src/lib/ai/coach/turn/__tests__/ledger-reasoning.test.ts b/src/lib/ai/coach/turn/__tests__/ledger-reasoning.test.ts index 986ad92b5..a51a337d3 100644 --- a/src/lib/ai/coach/turn/__tests__/ledger-reasoning.test.ts +++ b/src/lib/ai/coach/turn/__tests__/ledger-reasoning.test.ts @@ -106,6 +106,14 @@ describe("reserveTurnBudget and the turn ledger", () => { ]); }); + it("keeps what a failed call reported spending when the turn closes (#1279)", async () => { + const l = await ledger(); + await l.close(2_700); + expect(b.reconcileSpend.mock.calls.map((c) => [c[1], c[2]])).toEqual([ + [9_000, 2_700], + ]); + }); + it("settles a forced final round against the final reserve", async () => { const l = await ledger(); await l.settleRound({ diff --git a/src/lib/ai/coach/turn/budget.ts b/src/lib/ai/coach/turn/budget.ts index 603a08019..1fe05c0e1 100644 --- a/src/lib/ai/coach/turn/budget.ts +++ b/src/lib/ai/coach/turn/budget.ts @@ -41,8 +41,10 @@ export interface TurnLedger extends RoundSpend { /** * Give back everything still reserved and not settled. Called once the * turn is over, whatever way it ended; a second call does nothing. + * #1279 — `spent`, what a failed provider call reported spending, stays + * charged: a model that reasons without answering is billed for it. */ - close(): Promise; + close(spent?: number): Promise; } export async function reserveTurnBudget(args: { @@ -193,13 +195,13 @@ export function createTurnLedger(args: { } await settle(portion, tokens, cachedTokens, servedBy); }, - async close() { + async close(spent = 0) { if (closed) return; closed = true; const unused = open.reduce((sum, n) => sum + n, 0) + finalHeld; open.length = 0; finalHeld = 0; - if (unused > 0) await settle(unused, 0, 0, null); + if (unused > 0 || spent > 0) await settle(unused, spent, 0, null); }, }; } diff --git a/src/lib/ai/coach/turn/model.ts b/src/lib/ai/coach/turn/model.ts index 6b989b55d..4f5ce6707 100644 --- a/src/lib/ai/coach/turn/model.ts +++ b/src/lib/ai/coach/turn/model.ts @@ -12,6 +12,7 @@ import { AllProvidersFailedError, runStreamingRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { spentOnFailure } from "@/lib/ai/reasoning/answerless"; import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { singleUserTurn, type CompletionResult } from "@/lib/ai/types"; import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator"; @@ -689,7 +690,8 @@ export async function runTurnModel(args: { }); const result = fallback.result; await args.ledger.settleRound({ - tokens: result.tokensUsed ?? 0, + // #1279 — with what the hops that failed before it spent. + tokens: (result.tokensUsed ?? 0) + fallback.tokensSpent, cachedTokens: result.cachedInputTokens ?? 0, servedBy: fallback.workingProvider.providerType, final: false, @@ -729,20 +731,17 @@ export async function runTurnModel(args: { }; } catch (err) { // The provider chain failed: every round that returned is already - // settled; give back what is still reserved before the error frame. - await args.ledger.close(); - // #781 — the client walked away mid-generation. The request's abort - // signal is threaded into every provider call, so the teardown surfaces - // here as an abort-shaped failure with `request.signal` already flipped. - // Close the turn honestly instead of letting the user message dangle - // unanswered: persist an EMPTY assistant marker tagged - // `providerType: "cancelled"` (the same channel "refusal" and "nudge" - // already use for non-provider rows) so the thread shows the turn as - // interrupted and offers a retry on reload. The content is empty by - // construction — nothing guarded was produced, and anything the client - // ever SAW was persisted before the first token frame left, so no - // unguarded partial text is ever written. The reservation was refunded - // above; a retry pays exactly what a fresh turn pays. + // settled; give back what is still reserved before the error frame, less + // what the failed hops reported spending (#1279). + await args.ledger.close(spentOnFailure(err)); + // #781 — the client walked away mid-generation (the abort signal is + // threaded into every provider call). Close the turn honestly instead of + // leaving the user message dangling: persist an EMPTY assistant marker + // tagged `providerType: "cancelled"` (the channel "refusal" and "nudge" + // use for non-provider rows), so the thread shows the turn as interrupted + // and offers a retry. Empty by construction: nothing guarded was + // produced, and anything the client SAW was persisted before the first + // token frame left. A retry pays exactly what a fresh turn pays. if (signal.aborted) { await appendMessage({ conversationId, diff --git a/src/lib/ai/provider-runner.ts b/src/lib/ai/provider-runner.ts index 11de8b6fc..fe6174638 100644 --- a/src/lib/ai/provider-runner.ts +++ b/src/lib/ai/provider-runner.ts @@ -674,6 +674,7 @@ async function runRawChain( result, workingProvider: candidate, fallbackHops: hops, + tokensSpent: hops.reduce((sum, h) => sum + (h.tokensSpent ?? 0), 0), }; } catch (error) { if (!isHardProviderFailure(error)) { @@ -766,6 +767,12 @@ export interface RunRawWithFallbackResult { result: CompletionResult; workingProvider: ProviderChainResolved; fallbackHops: FallbackHop[]; + /** + * #1279 — tokens the hops that failed before `workingProvider` answered + * reported spending, summed. Not in `result.tokensUsed`, which is the + * answering call's alone; a caller that reconciles budget charges both. + */ + tokensSpent: number; } /** diff --git a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts index facf860c6..4eb1f4845 100644 --- a/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts +++ b/src/lib/documents/__tests__/fenced-chat-pipeline.test.ts @@ -106,6 +106,7 @@ beforeEach(() => { result: { content: "ok", tokensUsed: 10, model: "m" }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, } as never); }); @@ -194,6 +195,7 @@ describe("streamFencedReply — numeric grounding over the UNION", () => { }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, } as never); const res = await streamFencedReply({ ...baseArgs, diff --git a/src/lib/documents/fenced-chat.ts b/src/lib/documents/fenced-chat.ts index 90a834650..2321af54f 100644 --- a/src/lib/documents/fenced-chat.ts +++ b/src/lib/documents/fenced-chat.ts @@ -42,6 +42,7 @@ import { AllProvidersFailedError, runStreamingRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; +import { spentOnFailure } from "@/lib/ai/reasoning/answerless"; import { singleUserTurn } from "@/lib/ai/types"; import { buildDateKey, @@ -285,9 +286,8 @@ export async function streamFencedReply( args; // ── Provider resolution: document order (local-first, codex last), SINGLE - // pick, NO cascade — so the exact egress equals the checked target. The pick - // re-checks `documentAi` for that provider, consent receipt included; a - // refusal is thrown (403) before any egress. ── + // pick, NO cascade, so the egress is the checked target. The pick re-checks + // `documentAi` for it, receipt included, and throws 403 before egress. ── const { pick, withheld } = await resolveDocumentTextProvider(userId); if (withheld) throw withheld; if (!pick) { @@ -364,8 +364,7 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ contractLocale === "de" ? "German" : "English" }.`; - // The authoritative grounding set: the LIVE attachments' texts only. Never - // history, never a snapshot (none exists) — recomputed from `docs` this turn. + // The grounding set: the LIVE attachments' texts only, recomputed this turn. const groundingSources = docs.map((d) => d.text); type Outcome = @@ -397,7 +396,9 @@ Reply now as the assistant, grounded ONLY in the documents above, in ${ }); result = fallback.result; } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { + // #1279 — what the failed call reported spending stays charged. + const spent = spentOnFailure(err); + await reconcileSpend(userId, reservation.reserved, spent, dateKey, 0, { servedBy: null, reservedOwner: reservation.owner, }).catch(() => {}); diff --git a/src/lib/insights/__tests__/briefing-provider-budget.test.ts b/src/lib/insights/__tests__/briefing-provider-budget.test.ts index 508fba298..bd9ff8143 100644 --- a/src/lib/insights/__tests__/briefing-provider-budget.test.ts +++ b/src/lib/insights/__tests__/briefing-provider-budget.test.ts @@ -110,6 +110,7 @@ function mockProviderReply( }, workingProvider: { providerType: "admin-openai" }, fallbackHops: [], + tokensSpent: 0, }); } @@ -133,6 +134,19 @@ describe("runBriefingCompletion — ledger accounting", () => { expect(ledgerTotal).toBe(1234); }); + it("charges what the hops that failed before the answer spent (#1279)", async () => { + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: "{}", model: "m", tokensUsed: 1234 }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + tokensSpent: 2700, + }); + + await runBriefingCompletion(completionArgs()); + + expect(ledgerTotal).toBe(1234 + 2700); + }); + it("charges the reservation when the provider reports no token count", async () => { mockProviderReply(null); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts index 17c106731..88bcdecd4 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-grounding-disposal.test.ts @@ -165,6 +165,7 @@ function completion(content: string) { result: { content, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index 00ce1b374..64bb13794 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -162,6 +162,7 @@ describe("daily-briefing re-roll on unchanged data", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); @@ -226,6 +227,7 @@ describe("daily-briefing re-roll on unchanged data", () => { result: { content: "not json at all", tokensUsed: 5, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); diff --git a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts index e2b135eb3..9f61a3efe 100644 --- a/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts +++ b/src/lib/insights/__tests__/comprehensive-budget-refusal.test.ts @@ -166,6 +166,7 @@ beforeEach(() => { result: { content: VALID, tokensUsed: 900, model: "m" }, workingProvider: { providerType: "admin-openai" }, fallbackHops: [], + tokensSpent: 0, }); }); @@ -269,11 +270,13 @@ describe("generateComprehensiveInsight — daily token ceiling", () => { result: { content: "not json", tokensUsed: 400, model: "m" }, workingProvider: { providerType: "admin-openai" }, fallbackHops: [], + tokensSpent: 0, }) .mockResolvedValueOnce({ result: { content: VALID, tokensUsed: 900, model: "m" }, workingProvider: { providerType: "admin-openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 3878245df..c1ccda632 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -301,6 +301,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { @@ -353,6 +354,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); // The kilogram fingerprint no longer matches: a unit switch regenerates. @@ -429,6 +431,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { @@ -467,6 +470,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { @@ -503,6 +507,7 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { @@ -553,6 +558,7 @@ describe("generateComprehensiveInsight — usage level", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); } diff --git a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts index 3a6f0e78f..3795d2d85 100644 --- a/src/lib/insights/__tests__/comprehensive-json-retry.test.ts +++ b/src/lib/insights/__tests__/comprehensive-json-retry.test.ts @@ -130,11 +130,13 @@ describe("comprehensive JSON-retry", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }) .mockResolvedValueOnce({ result: { content: VALID, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); @@ -161,6 +163,7 @@ describe("comprehensive JSON-retry", () => { result: { content: "still not json", tokensUsed: 5, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); @@ -174,6 +177,7 @@ describe("comprehensive JSON-retry", () => { result: { content: VALID, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); diff --git a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts index 97adb76f7..2bbcc3ea6 100644 --- a/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts +++ b/src/lib/insights/__tests__/comprehensive-outbound-repair.test.ts @@ -137,6 +137,7 @@ function reply(content: string) { result: { content, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }; } diff --git a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts index eaf83631b..062085827 100644 --- a/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts +++ b/src/lib/insights/__tests__/comprehensive-plan-progress.test.ts @@ -128,6 +128,7 @@ beforeEach(() => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); }); diff --git a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts index 87fbb55b1..76408f423 100644 --- a/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts +++ b/src/lib/insights/__tests__/comprehensive-privacy-flip.test.ts @@ -167,6 +167,7 @@ describe("generateComprehensiveInsight — consent withdrawn in flight", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); userUpdateMany.mockResolvedValue({ count: 1 }); const { AiUnavailableError } = @@ -209,6 +210,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); // Some User update landed: the guarded commit matches zero rows. userUpdateMany.mockResolvedValue({ count: 0 }); @@ -258,6 +260,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); // The real DB returns count: 1 here too, since the guarded predicate // never references `updatedAt` — the credential-refresh write cannot @@ -304,6 +307,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); // Simulates a genuine concurrent settings/profile change (exclude list, // gender, or display name edited) between the read above and the @@ -349,6 +353,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); userUpdateMany.mockResolvedValue({ count: 1 }); @@ -388,6 +393,7 @@ describe("generateComprehensiveInsight — scope/version commit guard", () => { }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); userUpdateMany.mockResolvedValue({ count: 0 }); diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts index 39307a17c..19f543c8a 100644 --- a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -135,6 +135,7 @@ describe("comprehensive timeout honouring", () => { result: { content: VALID, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); await generateComprehensiveInsight("u1", { locale: "de" }); @@ -150,6 +151,7 @@ describe("comprehensive timeout honouring", () => { result: { content: VALID, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "openai" }, fallbackHops: [], + tokensSpent: 0, }); await generateComprehensiveInsight("u1", { locale: "de" }); diff --git a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts index ef7cd05ad..e90362f4e 100644 --- a/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts +++ b/src/lib/insights/__tests__/comprehensive-warm-timeout.test.ts @@ -145,6 +145,7 @@ describe("comprehensive warm-path timeout + failure observability", () => { result: { content: VALID, tokensUsed: 10, model: "m" }, workingProvider: { providerType: "codex" }, fallbackHops: [], + tokensSpent: 0, }); const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); diff --git a/src/lib/insights/__tests__/status-provider-budget.test.ts b/src/lib/insights/__tests__/status-provider-budget.test.ts index ca21f4991..e0e3f23ac 100644 --- a/src/lib/insights/__tests__/status-provider-budget.test.ts +++ b/src/lib/insights/__tests__/status-provider-budget.test.ts @@ -125,6 +125,8 @@ function mockProviderReply(tokensUsed: number | null) { runRawCompletionWithFallback.mockResolvedValue({ result: { content: '{"summary":"ok"}', model: "m", tokensUsed }, workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + tokensSpent: 0, }); } @@ -269,6 +271,19 @@ describe("runStatusCompletion — ledger accounting", () => { expect(chat.allowed).toBe(true); }); + it("charges what the hops that failed before the answer spent (#1279)", async () => { + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: '{"summary":"ok"}', model: "m", tokensUsed: 100 }, + workingProvider: { providerType: "admin-openai" }, + fallbackHops: [], + tokensSpent: 2700, + }); + + await runStatusCompletion(completionArgs()); + + expect(ledgerTotal).toBe(100 + 2700); + }); + it("charges the reservation when the provider reports no token count", async () => { mockProviderReply(null); @@ -398,6 +413,7 @@ describe("runStatusCompletion — cost owner decides the ceiling", () => { runRawCompletionWithFallback.mockResolvedValue({ result: { content: '{"summary":"ok"}', model: "m", tokensUsed: 100 }, workingProvider: { providerType: "anthropic" }, + tokensSpent: 0, }); const result = await runStatusCompletion(completionArgs()); diff --git a/src/lib/insights/__tests__/status-provider-slow-model.test.ts b/src/lib/insights/__tests__/status-provider-slow-model.test.ts index e5bce7962..08db9e390 100644 --- a/src/lib/insights/__tests__/status-provider-slow-model.test.ts +++ b/src/lib/insights/__tests__/status-provider-slow-model.test.ts @@ -82,6 +82,7 @@ function answersAfter(afterMs: number) { tokensUsed: 80, }, workingProvider: { providerType: "local" }, + tokensSpent: 0, }), afterMs, ), diff --git a/src/lib/insights/briefing-provider.ts b/src/lib/insights/briefing-provider.ts index ad28340c5..ea4e493ae 100644 --- a/src/lib/insights/briefing-provider.ts +++ b/src/lib/insights/briefing-provider.ts @@ -227,7 +227,9 @@ export async function runBriefingCompletion( throw err; } - const actualTokens = outcome.result.tokensUsed ?? reservation.reserved; + // #1279 — plus what the hops that failed before it spent. + const actualTokens = + (outcome.result.tokensUsed ?? reservation.reserved) + outcome.tokensSpent; await reconcileSpend( args.userId, reservation.reserved, diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 4a67d8d94..4dec02709 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -344,13 +344,12 @@ export async function runStatusCompletion( return { kind: raced.timedOut ? "timeout" : "error" }; } - const { result, workingProvider } = raced.value; - // Reconcile against what the provider actually reported. This runs for the - // empty-content branch too: those tokens were burned upstream even though the - // reply was unusable, so they stay on the ledger rather than being refunded - // into a free retry loop. Falls back to the reservation when the provider - // reports no count, so an unreported generation is never billed as zero. - const actualTokens = result.tokensUsed ?? reservation.reserved; + const { result, workingProvider, tokensSpent } = raced.value; + // What the provider reported, plus (#1279) what the hops that failed before + // it spent. The empty-content branch is charged too: those tokens were + // burned upstream. No count falls back to the reservation, never to zero. + const actualTokens = + (result.tokensUsed ?? reservation.reserved) + tokensSpent; await reconcileSpend(userId, reservation.reserved, actualTokens, dateKey, 0, { servedBy: workingProvider.providerType, reservedOwner: reservation.owner, diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index 6d57b5c2d..263eb2c48 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -105,6 +105,7 @@ vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({ }, workingProvider: { providerType: "anthropic" }, fallbackHops: [], + tokensSpent: 0, })), })); diff --git a/tests/integration/coach-chat.test.ts b/tests/integration/coach-chat.test.ts index 30af73e8d..00c6cb523 100644 --- a/tests/integration/coach-chat.test.ts +++ b/tests/integration/coach-chat.test.ts @@ -181,6 +181,7 @@ describe("POST /api/insights/chat — integration", () => { }, workingProvider: { providerType: "openai", instance: {} }, fallbackHops: [], + tokensSpent: 0, }); const { POST } = await import("@/app/api/insights/chat/route"); @@ -331,6 +332,7 @@ describe("POST /api/insights/chat — integration", () => { }, workingProvider: { providerType: "openai", instance: {} }, fallbackHops: [], + tokensSpent: 0, }); const { POST } = await import("@/app/api/insights/chat/route"); @@ -399,6 +401,7 @@ describe("POST /api/insights/chat — integration", () => { }, workingProvider: { providerType: "openai", instance: {} }, fallbackHops: [], + tokensSpent: 0, }); const { POST } = await import("@/app/api/insights/chat/route"); @@ -509,6 +512,7 @@ describe("POST /api/insights/chat — integration", () => { }, workingProvider: { providerType: "openai", instance: {} }, fallbackHops: [], + tokensSpent: 0, }); const { POST } = await import("@/app/api/insights/chat/route"); diff --git a/tests/integration/routes/parity/insights-generate.parity.test.ts b/tests/integration/routes/parity/insights-generate.parity.test.ts index 81d01d215..0cdb9d831 100644 --- a/tests/integration/routes/parity/insights-generate.parity.test.ts +++ b/tests/integration/routes/parity/insights-generate.parity.test.ts @@ -171,6 +171,7 @@ vi.mock("@/lib/ai/provider-runner", async (importOriginal) => { }, workingProvider: args.providers[0], fallbackHops: [], + tokensSpent: 0, }; }, };