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

@@ -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,
@@ -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,
@@ -325,6 +325,7 @@ async function runRawCompletionWithFallback(args: {
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -349,6 +349,7 @@ export function streamingResult(content: string) {
model: "m-local",
},
workingProvider: { providerType: "local" },
tokensSpent: 0,
};
};
}
@@ -264,6 +264,7 @@ async function noToolTurn(content: string): Promise<string> {
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;
@@ -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,
});
});
@@ -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?" });
@@ -292,6 +292,7 @@ beforeEach(() => {
model: "m",
},
workingProvider: { providerType: "admin-openai" },
tokensSpent: 0,
});
isModuleEnabled.mockResolvedValue(true);
});
@@ -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);
+23 -23
View File
@@ -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<Response> {
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<Response> {
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<Response> {
}),
});
} 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<Response> {
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,
@@ -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({
@@ -209,6 +209,7 @@ async function turn(level: ReasoningLevel) {
result: await client.generateCompletion(params),
workingProvider: { providerType: "openai-compatible" },
fallbackHops: [],
tokensSpent: 0,
}),
);
return runCoachToolLoop({
@@ -61,6 +61,7 @@ function round(
},
workingProvider: { providerType: "codex" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -37,6 +37,7 @@ function round(content: string, toolCalls?: Call[]) {
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -98,6 +98,7 @@ async function capTurn(
result: await client.generateCompletion({ ...params, ...extra }),
workingProvider: { providerType },
fallbackHops: [],
tokensSpent: 0,
}),
);
return runCoachToolLoop({
@@ -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);
@@ -42,6 +42,7 @@ function completion(opts: {
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -35,6 +35,7 @@ function round(content: string, toolCalls?: unknown[]) {
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
};
}
+1 -1
View File
@@ -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,
@@ -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({
+5 -3
View File
@@ -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<void>;
close(spent?: number): Promise<void>;
}
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);
},
};
}
+14 -15
View File
@@ -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,
+7
View File
@@ -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;
}
/**
@@ -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(() => {});
@@ -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);
@@ -165,6 +165,7 @@ function completion(content: string) {
result: { content, tokensUsed: 10, model: "m" },
workingProvider: { providerType: "openai" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -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" });
@@ -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" });
@@ -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,
});
}
@@ -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" });
@@ -137,6 +137,7 @@ function reply(content: string) {
result: { content, tokensUsed: 10, model: "m" },
workingProvider: { providerType: "openai" },
fallbackHops: [],
tokensSpent: 0,
};
}
@@ -128,6 +128,7 @@ beforeEach(() => {
},
workingProvider: { providerType: "openai" },
fallbackHops: [],
tokensSpent: 0,
});
});
@@ -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 });
@@ -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" });
@@ -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" });
@@ -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());
@@ -82,6 +82,7 @@ function answersAfter(afterMs: number) {
tokensUsed: 80,
},
workingProvider: { providerType: "local" },
tokensSpent: 0,
}),
afterMs,
),
+3 -1
View File
@@ -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,
+6 -7
View File
@@ -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,
@@ -105,6 +105,7 @@ vi.mock("@/lib/ai/provider-runner", async (importOriginal) => ({
},
workingProvider: { providerType: "anthropic" },
fallbackHops: [],
tokensSpent: 0,
})),
}));
+4
View File
@@ -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");
@@ -171,6 +171,7 @@ vi.mock("@/lib/ai/provider-runner", async (importOriginal) => {
},
workingProvider: args.providers[0],
fallbackHops: [],
tokensSpent: 0,
};
},
};