mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
186fc30fcc
commit
b0ea3b1654
43 files changed
+198
-61
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user