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
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 00:33:18 +02:00
1 parent 186fc30fcc
commit b0ea3b1654
43 files changed
+198 -61

No files matched your search

@@ -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,
+7 -6
View File
@@ -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(() => {});