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, }; }, };