mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Cover the briefing tier's ledger accounting
Pin the four guarantees at the new chokepoint and at both call sites: spend lands on the ledger, over-cap is refused before any egress, a BYOK chain is measured against the user-plan ceiling rather than the operator's, and a failed call refunds in full. Also pin the ordering the consent gate depends on — a consent-blocked user is never charged.
This commit is contained in:
1 parent
8a1d7502d0
commit
79922631ce
3 files changed
+492
No files matched your search
@@ -173,6 +173,11 @@ beforeEach(() => {
|
||||
// need a fresh cache so the previous test's success doesn't reorder
|
||||
// the chain on the next request.
|
||||
clearLastWorkingProviderCache();
|
||||
// `clearAllMocks` does not undo a `mockImplementation`, so the ledger stub is
|
||||
// re-seeded per test — otherwise the over-cap case below leaks into the rest.
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async () => [
|
||||
{ total_tokens: 0 },
|
||||
]) as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
@@ -228,6 +233,33 @@ function makeProviderThatThrows(
|
||||
} as unknown as Awaited<ReturnType<typeof resolveProvider>>);
|
||||
}
|
||||
|
||||
describe("POST /api/insights/generate — daily token ceiling", () => {
|
||||
it("refuses over-cap with 429 before any provider egress", async () => {
|
||||
// The reservation reports a prior total already at the ceiling. The hourly
|
||||
// rate limit bounds how OFTEN this route runs; the ledger bounds what it
|
||||
// costs. Before this wiring the route had only the former, so ten
|
||||
// full-price briefings an hour was the real ceiling.
|
||||
// Targeted at the ledger's upsert only — other raw reads on this path
|
||||
// (the plateau / derived-signal probes) must keep their own shape.
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async (
|
||||
strings: TemplateStringsArray,
|
||||
) =>
|
||||
String(strings[0]).includes("coach_usage")
|
||||
? [{ total_tokens: 10_000_000 }]
|
||||
: [{ total_tokens: 0 }]) as never);
|
||||
makeWorkingProvider();
|
||||
|
||||
const res = await POST(jsonRequest({ force: true }) as never);
|
||||
expect(res.status).toBe(429);
|
||||
const body = (await res.json()) as ApiErrorEnvelope;
|
||||
expect(body.error).toContain("Daily AI token budget");
|
||||
// Not a provider failure: no cache row written, so the last good briefing
|
||||
// stays intact and the card does not blank.
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(enqueueStatusRefillForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/insights/generate — provider error mapping", () => {
|
||||
it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
|
||||
const res = await POST(
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Cost accounting for the comprehensive-briefing provider chokepoint.
|
||||
*
|
||||
* `runBriefingCompletion` is the single provider entry for the briefing tier —
|
||||
* the shared generator's first pass, its JSON-shape retry, its
|
||||
* grounding-correction retry, the daily paragraph re-roll, and both calls in
|
||||
* the on-demand `POST /api/insights/generate` route. That tier previously ran
|
||||
* with NO budget accounting: nothing reserved, nothing recorded, no ceiling.
|
||||
* The briefing is the most expensive generation in the product and retries up
|
||||
* to twice, so an unmetered run could cost three full generations — and on an
|
||||
* operator-key account that was unmetered operator spend.
|
||||
*
|
||||
* These tests drive the REAL budget module (`reserveBudget` / `reconcileSpend`
|
||||
* / `resolveDailyCap`) against a stateful in-memory stand-in for the
|
||||
* `coach_usage` row, so they exercise the actual atomic-upsert path rather
|
||||
* than merely asserting that a mock was called. Mirrors the status tier's
|
||||
* `status-provider-budget.test.ts` deliberately: one accounting mechanism,
|
||||
* one set of guarantees, asserted the same way at both chokepoints.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/** In-memory stand-in for the day's `coach_usage.total_tokens`. */
|
||||
let ledgerTotal = 0;
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
// `reserveBudget`'s atomic upsert-increment. Tagged-template call shape:
|
||||
// (strings, userId, dateKey, reserved, reserved).
|
||||
$queryRaw: vi.fn(
|
||||
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
|
||||
ledgerTotal += Number(v[2] ?? 0);
|
||||
return [{ total_tokens: ledgerTotal }];
|
||||
},
|
||||
),
|
||||
// `reconcileSpend` (+delta) and `refundReservation` (-reserved).
|
||||
$executeRaw: vi.fn(
|
||||
async (strings: TemplateStringsArray, ...v: unknown[]) => {
|
||||
const sql = strings.join("?");
|
||||
const amount = Number(v[0] ?? 0);
|
||||
if (sql.includes("total_tokens + ")) ledgerTotal += amount;
|
||||
else if (sql.includes("total_tokens - ")) ledgerTotal -= amount;
|
||||
ledgerTotal = Math.max(0, ledgerTotal);
|
||||
return 1;
|
||||
},
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
const { runRawCompletionWithFallback } = vi.hoisted(() => ({
|
||||
runRawCompletionWithFallback: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback,
|
||||
}));
|
||||
|
||||
import {
|
||||
runBriefingCompletion,
|
||||
BriefingBudgetExceededError,
|
||||
} from "../briefing-provider";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
|
||||
|
||||
const OPERATOR_CHAIN = [
|
||||
{ providerType: "admin-openai" as const, instance: {} as never },
|
||||
];
|
||||
const BYOK_CHAIN = [
|
||||
{ providerType: "anthropic" as const, instance: {} as never },
|
||||
];
|
||||
|
||||
function completionArgs(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
userId: "u1",
|
||||
chain: OPERATOR_CHAIN,
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user",
|
||||
temperature: 0.3,
|
||||
maxTokens: 2500,
|
||||
timeoutMs: 60_000,
|
||||
stage: "generate" as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** A successful provider reply reporting `tokensUsed`. */
|
||||
function mockProviderReply(
|
||||
tokensUsed: number | null,
|
||||
cachedInputTokens?: number,
|
||||
) {
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: {
|
||||
content: '{"dailyBriefing":{"paragraph":"ok"}}',
|
||||
model: "m",
|
||||
tokensUsed,
|
||||
cachedInputTokens,
|
||||
},
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ledgerTotal = 0;
|
||||
});
|
||||
|
||||
describe("runBriefingCompletion — ledger accounting", () => {
|
||||
it("lands a successful generation on the day's ledger", async () => {
|
||||
mockProviderReply(1234);
|
||||
|
||||
const outcome = await runBriefingCompletion(completionArgs());
|
||||
|
||||
expect(outcome.result.tokensUsed).toBe(1234);
|
||||
// Reserved an estimate, then reconciled down to what the provider actually
|
||||
// reported. Either way the spend is now ON the ledger — before this wiring
|
||||
// existed the total stayed at zero forever, no matter how many briefings
|
||||
// the account generated.
|
||||
expect(ledgerTotal).toBe(1234);
|
||||
});
|
||||
|
||||
it("charges the reservation when the provider reports no token count", async () => {
|
||||
mockProviderReply(null);
|
||||
|
||||
await runBriefingCompletion(completionArgs());
|
||||
|
||||
// An unreported generation must not bill as free.
|
||||
expect(ledgerTotal).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("bills net of provider-cached input tokens", async () => {
|
||||
mockProviderReply(1000, 400);
|
||||
|
||||
await runBriefingCompletion(completionArgs());
|
||||
|
||||
// The gross count still includes input the prompt cache served cheaply;
|
||||
// charging the user's meter for input they did not re-pay for is an
|
||||
// over-charge.
|
||||
expect(ledgerTotal).toBe(600);
|
||||
});
|
||||
|
||||
it("refuses a generation once the day's cap is already spent", async () => {
|
||||
ledgerTotal = OPERATOR_COST_CAP;
|
||||
mockProviderReply(500);
|
||||
|
||||
await expect(
|
||||
runBriefingCompletion(completionArgs()),
|
||||
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
|
||||
|
||||
// Refused BEFORE any provider egress — the point of the whole exercise.
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: { name: "insights.briefing.budget_exceeded" },
|
||||
}),
|
||||
);
|
||||
// The refused reservation was refunded, not left on the row.
|
||||
expect(ledgerTotal).toBe(OPERATOR_COST_CAP);
|
||||
});
|
||||
|
||||
it("refunds the reservation in full when the provider errors", async () => {
|
||||
runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down"));
|
||||
|
||||
await expect(runBriefingCompletion(completionArgs())).rejects.toThrow(
|
||||
"upstream down",
|
||||
);
|
||||
|
||||
// A failed generation must not leave an estimate parked on the ledger,
|
||||
// which would ration the retry the user is entitled to.
|
||||
expect(ledgerTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("re-throws the provider error unchanged so callers keep their mapping", async () => {
|
||||
class AllProvidersFailedError extends Error {}
|
||||
runRawCompletionWithFallback.mockRejectedValue(
|
||||
new AllProvidersFailedError("chain exhausted"),
|
||||
);
|
||||
|
||||
// The route maps `AllProvidersFailedError` to specific user-facing
|
||||
// statuses; swallowing or re-wrapping it here would silently degrade every
|
||||
// one of those branches to the generic error.
|
||||
await expect(
|
||||
runBriefingCompletion(completionArgs()),
|
||||
).rejects.toBeInstanceOf(AllProvidersFailedError);
|
||||
});
|
||||
|
||||
it("meters each retry stage separately", async () => {
|
||||
mockProviderReply(1000);
|
||||
|
||||
await runBriefingCompletion(completionArgs({ stage: "generate" }));
|
||||
await runBriefingCompletion(completionArgs({ stage: "json-retry" }));
|
||||
await runBriefingCompletion(completionArgs({ stage: "grounding-retry" }));
|
||||
|
||||
// A correction pass is real spend. If retries rode the first pass's
|
||||
// reservation, a user at the ceiling would get free retries.
|
||||
expect(ledgerTotal).toBe(3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBriefingCompletion — cost owner decides the ceiling", () => {
|
||||
it("measures an operator-key chain against the operator ceiling", async () => {
|
||||
ledgerTotal = OPERATOR_COST_CAP;
|
||||
mockProviderReply(100);
|
||||
|
||||
await expect(
|
||||
runBriefingCompletion(completionArgs()),
|
||||
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("measures a BYOK chain against the user-plan ceiling, not the operator's", async () => {
|
||||
// Same spend that locked the operator-key user out above. A self-hoster on
|
||||
// their own key pays their own bill, so the operator's ceiling is a
|
||||
// category error for them — they must still be served.
|
||||
ledgerTotal = OPERATOR_COST_CAP;
|
||||
mockProviderReply(100);
|
||||
|
||||
const outcome = await runBriefingCompletion(
|
||||
completionArgs({ chain: BYOK_CHAIN }),
|
||||
);
|
||||
|
||||
expect(outcome.result.tokensUsed).toBe(100);
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still bounds a BYOK chain at the user-plan ceiling", async () => {
|
||||
ledgerTotal = USER_PLAN_CAP;
|
||||
mockProviderReply(100);
|
||||
|
||||
await expect(
|
||||
runBriefingCompletion(completionArgs({ chain: BYOK_CHAIN })),
|
||||
).rejects.toBeInstanceOf(BriefingBudgetExceededError);
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The briefing generator's behaviour when the day's token ceiling refuses it.
|
||||
*
|
||||
* `briefing-provider-budget.test.ts` pins the accounting itself. This pins the
|
||||
* WIRING: that `generateComprehensiveInsight` actually routes through the
|
||||
* metered chokepoint, and that an over-cap run is reported honestly.
|
||||
*
|
||||
* Load-bearing distinction: an over-cap run is `skipped`, NOT `failed`. No
|
||||
* provider was contacted, so there is no upstream fault to retry against. The
|
||||
* morning-refresh worker enqueues its 45-minute provider-failure retry on a
|
||||
* `failed` outcome and holds the day provisional — doing that for a ceiling
|
||||
* that will not move until the ledger rolls over would be a retry loop against
|
||||
* an accounting outcome.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
let ledgerTotal = 0;
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const userUpdate = vi.fn();
|
||||
const resolveProviderChain = vi.fn();
|
||||
const resolveProvider = vi.fn();
|
||||
const runRawCompletionWithFallback = vi.fn();
|
||||
const extractFeatures = vi.fn();
|
||||
const recordBriefingFailure = vi.fn();
|
||||
const hasActiveConsentForSurface = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(
|
||||
async (_strings: TemplateStringsArray, ...v: unknown[]) => {
|
||||
ledgerTotal += Number(v[2] ?? 0);
|
||||
return [{ total_tokens: ledgerTotal }];
|
||||
},
|
||||
),
|
||||
$executeRaw: vi.fn(
|
||||
async (strings: TemplateStringsArray, ...v: unknown[]) => {
|
||||
const sql = strings.join("?");
|
||||
const amount = Number(v[0] ?? 0);
|
||||
if (sql.includes("total_tokens + ")) ledgerTotal += amount;
|
||||
else if (sql.includes("total_tokens - ")) ledgerTotal -= amount;
|
||||
ledgerTotal = Math.max(0, ledgerTotal);
|
||||
return 1;
|
||||
},
|
||||
),
|
||||
user: {
|
||||
findUnique: (...a: unknown[]) => findUnique(...a),
|
||||
update: (...a: unknown[]) => userUpdate(...a),
|
||||
},
|
||||
auditLog: { deleteMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider", () => ({
|
||||
resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a),
|
||||
resolveProvider: (...a: unknown[]) => resolveProvider(...a),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
AllProvidersFailedError: class extends Error {},
|
||||
runRawCompletionWithFallback: (...a: unknown[]) =>
|
||||
runRawCompletionWithFallback(...a),
|
||||
}));
|
||||
vi.mock("@/lib/insights/features", () => ({
|
||||
FeaturesPayloadTooLargeError: class extends Error {
|
||||
sizeBytes = 0;
|
||||
},
|
||||
extractFeatures: (...a: unknown[]) => extractFeatures(...a),
|
||||
BRIEFING_FEATURE_WINDOW_DAYS: 400,
|
||||
}));
|
||||
vi.mock("@/lib/insights/briefing-failure-marker", () => ({
|
||||
recordBriefingFailure: (...a: unknown[]) => recordBriefingFailure(...a),
|
||||
readBriefingFailure: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/insights/illness-cycle-briefing", () => ({
|
||||
buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null),
|
||||
buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""),
|
||||
}));
|
||||
vi.mock("@/lib/insights/glp1-plateau", () => ({
|
||||
detectGlp1Plateau: vi.fn(async () => null),
|
||||
buildGlp1PlateauPrompt: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/about-me", () => ({
|
||||
getSelfContextTextForUser: vi.fn(async () => null),
|
||||
buildAboutMeInsightBlock: vi.fn(() => ""),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserInsights: vi.fn(),
|
||||
}));
|
||||
// Modelled truthfully rather than stubbed open: only an operator-credential
|
||||
// chain needs a receipt, which is what lets the consent-ordering test below
|
||||
// assert the real gate rather than a bypass.
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
chainRequiresServerManagedConsent: (chain: { providerType: string }[] = []) =>
|
||||
chain.some((c) => c.providerType.startsWith("admin-")),
|
||||
hasActiveConsentForSurface: (...a: unknown[]) =>
|
||||
hasActiveConsentForSurface(...a),
|
||||
}));
|
||||
|
||||
import { generateComprehensiveInsight } from "../comprehensive-generate";
|
||||
import { OPERATOR_COST_CAP, USER_PLAN_CAP } from "@/lib/ai/coach/budget";
|
||||
|
||||
const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ledgerTotal = 0;
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "admin-openai", instance: {} },
|
||||
]);
|
||||
resolveProvider.mockResolvedValue({ type: "none" });
|
||||
extractFeatures.mockResolvedValue({
|
||||
weight: { count: 12, latest: 81.4, mean30: 82.1 },
|
||||
});
|
||||
userUpdate.mockResolvedValue({});
|
||||
findUnique.mockResolvedValue({
|
||||
insightsPrivacyMode: "aggregated",
|
||||
insightsCachedAt: null,
|
||||
insightsCachedText: null,
|
||||
insightsExcludeMetrics: [],
|
||||
insightsSnapshotHash: null,
|
||||
insightsBriefingRerollDate: null,
|
||||
});
|
||||
hasActiveConsentForSurface.mockResolvedValue(true);
|
||||
runRawCompletionWithFallback.mockResolvedValue({
|
||||
result: { content: VALID, tokensUsed: 900, model: "m" },
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateComprehensiveInsight — daily token ceiling", () => {
|
||||
it("lands the briefing's spend on the ledger", async () => {
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome.status).toBe("generated");
|
||||
// The whole point: this path used to spend invisibly.
|
||||
expect(ledgerTotal).toBe(900);
|
||||
});
|
||||
|
||||
it("refuses over-cap as `skipped: budget` without contacting a provider", async () => {
|
||||
ledgerTotal = OPERATOR_COST_CAP;
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "budget" });
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
// Not a provider failure: no marker, so the read path never claims the
|
||||
// refresh broke, and the morning-refresh worker does not enqueue its
|
||||
// provider-failure retry against a ceiling.
|
||||
expect(recordBriefingFailure).not.toHaveBeenCalled();
|
||||
// No cache row written, so the last good briefing stays intact.
|
||||
expect(userUpdate).not.toHaveBeenCalled();
|
||||
expect(ledgerTotal).toBe(OPERATOR_COST_CAP);
|
||||
});
|
||||
|
||||
it("serves a BYOK account at the same spend that refuses an operator-key one", async () => {
|
||||
// A self-hoster on their own key pays their own bill; the operator's
|
||||
// ceiling is a category error for them.
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
ledgerTotal = OPERATOR_COST_CAP;
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome.status).toBe("generated");
|
||||
expect(runRawCompletionWithFallback).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still bounds a BYOK account at the user-plan ceiling", async () => {
|
||||
resolveProviderChain.mockResolvedValue([
|
||||
{ providerType: "anthropic", instance: {} },
|
||||
]);
|
||||
ledgerTotal = USER_PLAN_CAP;
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "budget" });
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refunds in full when the provider fails, leaving no estimate parked", async () => {
|
||||
runRawCompletionWithFallback.mockRejectedValue(new Error("upstream down"));
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome.status).toBe("failed");
|
||||
expect(ledgerTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("never charges a consent-blocked user", async () => {
|
||||
// Ordering guarantee: the reservation lands AFTER the consent gate.
|
||||
// Reserving first would bill an operator-key user for a snapshot that is
|
||||
// never sent anywhere, and could lock them out of a tier they are not
|
||||
// even permitted to use.
|
||||
hasActiveConsentForSurface.mockResolvedValue(false);
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome).toEqual({ status: "skipped", reason: "no-consent" });
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(ledgerTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("charges the JSON-shape retry as its own generation", async () => {
|
||||
runRawCompletionWithFallback
|
||||
.mockResolvedValueOnce({
|
||||
result: { content: "not json", tokensUsed: 400, model: "m" },
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
fallbackHops: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: { content: VALID, tokensUsed: 900, model: "m" },
|
||||
workingProvider: { providerType: "admin-openai" },
|
||||
fallbackHops: [],
|
||||
});
|
||||
|
||||
const outcome = await generateComprehensiveInsight("u1", { locale: "de" });
|
||||
|
||||
expect(outcome.status).toBe("generated");
|
||||
// Both passes billed. A retry that rode the first reservation would let a
|
||||
// user at the ceiling generate for free.
|
||||
expect(ledgerTotal).toBe(1300);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user