mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(ai): background generation cannot spend the interactive day
The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.
Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
This commit is contained in:
1 parent
2c46712e13
commit
d3196ee69e
38 files changed
+151
-17
No files matched your search
@@ -35,6 +35,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: () => 200_000,
|
||||
resolveDailyCapFor: () => 200_000,
|
||||
resolveCostOwner: () => "operator" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 100000),
|
||||
resolveDailyCapFor: vi.fn(() => 100000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/documents/extract", () => ({
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 1 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
|
||||
@@ -105,6 +105,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
|
||||
@@ -134,6 +134,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn(),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
|
||||
@@ -100,6 +100,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
})),
|
||||
reconcileSpend: vi.fn(),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
|
||||
|
||||
@@ -103,6 +103,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
|
||||
@@ -133,6 +133,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget,
|
||||
reconcileSpend,
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -177,6 +177,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: vi.fn(() => 2_000_000),
|
||||
resolveDailyCapFor: vi.fn(() => 2_000_000),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/refusal", () => ({
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
// returns a distinctive figure so an accidental default-cap regression is
|
||||
// visible in the assertion rather than silently passing.
|
||||
resolveDailyCap: vi.fn(() => 1_234_567),
|
||||
resolveDailyCapFor: vi.fn(() => 1_234_567),
|
||||
resolveCostOwner: vi.fn(() => "user" as const),
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
buildDateKey,
|
||||
OPERATOR_COST_CAP,
|
||||
USER_PLAN_CAP,
|
||||
JOB_SURFACE_SHARE,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "../budget";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -198,6 +200,40 @@ describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
|
||||
* `totalAfter: 513539` against the 200 k operator ceiling. Comparing the total
|
||||
* is the defect; the operator ceiling may only see operator-funded tokens.
|
||||
*/
|
||||
/**
|
||||
* v1.38.19 (Wave E) — background generation gets a bounded slice of the day.
|
||||
*
|
||||
* Production evidence (2026-09-11): 150–330 ledger rows a day on the
|
||||
* operator's account, 172 `insights.metric` generations by 06:42Z — the
|
||||
* automatic jobs had spent the day's ceiling before he opened the chat. A
|
||||
* background surface may reserve at most half of the interactive ceiling.
|
||||
*/
|
||||
describe("resolveDailyCapFor (Wave E — the job share)", () => {
|
||||
it("gives a background surface half the operator ceiling", () => {
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
|
||||
OPERATOR_COST_CAP * JOB_SURFACE_SHARE,
|
||||
);
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "admin-openai" }])).toBe(
|
||||
100_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the interactive surfaces the whole ceiling", () => {
|
||||
expect(
|
||||
resolveDailyCapFor("coach", [{ providerType: "admin-openai" }]),
|
||||
).toBe(OPERATOR_COST_CAP);
|
||||
expect(resolveDailyCapFor("coach", [{ providerType: "codex" }])).toBe(
|
||||
USER_PLAN_CAP,
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the user-plan ceiling the same way", () => {
|
||||
expect(resolveDailyCapFor("job", [{ providerType: "codex" }])).toBe(
|
||||
USER_PLAN_CAP * JOB_SURFACE_SHARE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reserveBudget cap by cost owner (Wave E)", () => {
|
||||
let prismaMock: {
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -46,6 +46,7 @@ const budgetMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => {}),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
@@ -68,6 +68,34 @@ export function resolveDailyCap(
|
||||
: USER_PLAN_CAP;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.38.19 (Wave E) — the share of a day's ceiling that BACKGROUND generation
|
||||
* may reserve, all job surfaces together.
|
||||
*
|
||||
* Production evidence (2026-09-11): the operator's account wrote 150–330
|
||||
* ledger rows a day, 172 of them `insights.metric` generations before 06:42Z.
|
||||
* The automatic work had eaten the day before he opened the chat. Half the
|
||||
* ceiling keeps the interactive surfaces a day of their own no matter how much
|
||||
* background generation the instance schedules.
|
||||
*/
|
||||
export const JOB_SURFACE_SHARE = 0.5;
|
||||
|
||||
/**
|
||||
* v1.38.19 (Wave E) — the daily ceiling for one SURFACE on a chain. `"coach"`
|
||||
* (every interactive surface: the chat, the extraction routes, the document
|
||||
* routes, the connection probe) gets the whole ceiling; `"job"` (the automatic
|
||||
* generators) gets `JOB_SURFACE_SHARE` of it. The cost owner is unchanged —
|
||||
* this only rations how much of the owner's ceiling a background surface may
|
||||
* claim.
|
||||
*/
|
||||
export function resolveDailyCapFor(
|
||||
surface: "coach" | "job",
|
||||
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
|
||||
): number {
|
||||
const cap = resolveDailyCap(chain);
|
||||
return surface === "job" ? Math.floor(cap * JOB_SURFACE_SHARE) : cap;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.19 (A7-2) — does this provider spend the OPERATOR's money?
|
||||
* `admin-openai` (the server's own API key) and `admin-codex` (the
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: vi.fn(() => "2026-07-16"),
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCapFor: vi.fn(() => 100_000),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
AI_BUDGETS: { ocrExtractText: { temperature: 0, maxTokens: 2000 } },
|
||||
@@ -99,6 +100,8 @@ beforeEach(() => {
|
||||
allowed: true,
|
||||
reserved: 10,
|
||||
totalAfter: 10,
|
||||
owner: "operator",
|
||||
operatorAfter: 10,
|
||||
} as Awaited<ReturnType<typeof reserveBudget>>);
|
||||
mockExtract.mockResolvedValue({
|
||||
reportDate: "2026-07-10",
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn().mockResolvedValue({ allowed: true, reserved: 600 }),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 100000),
|
||||
resolveDailyCapFor: vi.fn(() => 100000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/provider-runner", () => ({
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { resolveIndexProvider } from "@/lib/documents/index-document";
|
||||
@@ -276,13 +277,17 @@ export async function maybeAutoStageLabFacts(
|
||||
}
|
||||
|
||||
// A text-structuring pass, not a vision call — reserve the proportionate
|
||||
// ceiling under the same daily cap the auto-index resolved.
|
||||
// ceiling under the cost owner the auto-index resolved.
|
||||
// v1.38.19 (Wave E) — auto-staging runs off the upload, with no one waiting
|
||||
// on it: a background surface, so half the day's ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
provider.dailyCap,
|
||||
resolveDailyCapFor("job", [
|
||||
{ providerType: provider.pick.entry.providerType },
|
||||
]),
|
||||
provider.costOwner,
|
||||
);
|
||||
if (!reservation.allowed) return { staged: false, reason: "budget" };
|
||||
|
||||
@@ -128,6 +128,33 @@ describe("runStatusCompletion — ledger accounting", () => {
|
||||
expect(ledgerTotal).toBe(1234);
|
||||
});
|
||||
|
||||
it("refuses a background generation at the job share while the chat still runs", async () => {
|
||||
// Wave E — the automatic status/reference generators may spend at most
|
||||
// half the operator ceiling, so the interactive chat still has a day left
|
||||
// when the jobs have had theirs. One token past the job share:
|
||||
ledgerTotal = 100_001;
|
||||
ledgerOperator = 100_001;
|
||||
mockProviderReply(500);
|
||||
|
||||
const result = await runStatusCompletion(completionArgs());
|
||||
|
||||
expect(runRawCompletionWithFallback).not.toHaveBeenCalled();
|
||||
expect(result.kind).toBe("error");
|
||||
|
||||
// The same spend, reserved by the interactive coach ceiling, is admitted.
|
||||
const { reserveBudget, resolveDailyCap, resolveCostOwner } =
|
||||
await import("@/lib/ai/coach/budget");
|
||||
const chatChain = [{ providerType: "admin-openai" as const }];
|
||||
const chat = await reserveBudget(
|
||||
"u1",
|
||||
3_000,
|
||||
"2026-09-11",
|
||||
resolveDailyCap(chatChain),
|
||||
resolveCostOwner(chatChain),
|
||||
);
|
||||
expect(chat.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("charges the reservation when the provider reports no token count", async () => {
|
||||
mockProviderReply(null);
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
*
|
||||
* This is the same accounting the status tier uses, not a second mechanism:
|
||||
* `reserveBudget` (one atomic upsert-increment, no read-then-write window) +
|
||||
* `resolveDailyCap(chain)` for the cost owner + `reconcileSpend` against the
|
||||
* `resolveDailyCapFor("job", chain)` for the cost owner + `reconcileSpend` against the
|
||||
* provider's reported count. Every provider call on the briefing path routes
|
||||
* through here, so a retry is reserved and charged like any other call — a
|
||||
* user at the ceiling does not get a free correction pass.
|
||||
*
|
||||
* The cap follows the COST OWNER, not the surface. `resolveDailyCap` charges
|
||||
* The cap follows the COST OWNER. `resolveDailyCapFor` charges
|
||||
* the operator ceiling only when the chain's primary is the operator's own
|
||||
* credential (`admin-openai` / `admin-codex`); a self-hoster on their own key,
|
||||
* their own ChatGPT plan, or a local model is measured against the generous
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -122,7 +122,8 @@ export async function runBriefingCompletion(
|
||||
args.userId,
|
||||
estimatedTokens,
|
||||
dateKey,
|
||||
resolveDailyCap(args.chain),
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", args.chain),
|
||||
resolveCostOwner(args.chain),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
@@ -211,7 +211,7 @@ export async function runStatusCompletion(
|
||||
// prompt we are about to send — and reconcile against the provider's reported
|
||||
// count afterwards, refunding in full when nothing was generated.
|
||||
//
|
||||
// The cap follows the COST OWNER, not the surface: `resolveDailyCap` charges
|
||||
// The cap follows the COST OWNER: `resolveDailyCapFor` charges
|
||||
// the operator ceiling only when the chain's primary is the operator's own
|
||||
// credential (`admin-openai` / `admin-codex`). A self-hoster on their own key
|
||||
// or a local model is measured against the generous user-plan ceiling, so
|
||||
@@ -223,7 +223,8 @@ export async function runStatusCompletion(
|
||||
userId,
|
||||
estimatedTokens,
|
||||
dateKey,
|
||||
resolveDailyCap(chain),
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", chain),
|
||||
resolveCostOwner(chain),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
|
||||
@@ -76,6 +76,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
|
||||
@@ -35,6 +35,7 @@ const budgetMocks = vi.hoisted(() => ({
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => {}),
|
||||
resolveDailyCap: vi.fn(() => 200_000),
|
||||
resolveDailyCapFor: vi.fn(() => 200_000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/budget", () => budgetMocks);
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
|
||||
@@ -50,6 +50,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: vi.fn(),
|
||||
reconcileSpend: vi.fn().mockResolvedValue(undefined),
|
||||
resolveDailyCap: vi.fn(() => 1000),
|
||||
resolveDailyCapFor: vi.fn(() => 1000),
|
||||
resolveCostOwner: vi.fn(() => "operator" as const),
|
||||
}));
|
||||
vi.mock("@/lib/ai/ai-budgets", () => ({
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
reserveBudget: (...a: unknown[]) => reserveBudget(...a),
|
||||
reconcileSpend: (...a: unknown[]) => reconcileSpend(...a),
|
||||
resolveDailyCap: () => 200_000,
|
||||
resolveDailyCapFor: () => 200_000,
|
||||
resolveCostOwner: () => "operator" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn } from "@/lib/ai/types";
|
||||
@@ -191,7 +191,8 @@ export const composeNudgeWithAI: ComposeNudgeWithAI = async (params) => {
|
||||
|
||||
const budget = AI_BUDGETS.coachNudge;
|
||||
const maxTokens = budget.maxTokens ?? 160;
|
||||
const dailyCap = resolveDailyCap(chain);
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
const dailyCap = resolveDailyCapFor("job", chain);
|
||||
const dateKey = buildDateKey();
|
||||
|
||||
// Per-user budget gate (atomic reserve; refunds itself on refusal).
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -200,7 +200,10 @@ export async function runContentIndexBackfillForUser(
|
||||
// leaves every PDF out of "index all documents".
|
||||
const candidateMimes = [...IMAGE_MIMES, "application/pdf"];
|
||||
|
||||
const dailyCap = resolveDailyCap([{ providerType: pick.entry.providerType }]);
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
const dailyCap = resolveDailyCapFor("job", [
|
||||
{ providerType: pick.entry.providerType },
|
||||
]);
|
||||
let indexed = 0;
|
||||
let skipped = 0;
|
||||
let failed = stale.failed;
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import {
|
||||
loadOwnedDocument,
|
||||
@@ -191,7 +191,8 @@ export async function runDocumentSummaryJob(
|
||||
userId,
|
||||
AI_BUDGETS.documentSummary.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", [{ providerType: pick.entry.providerType }]),
|
||||
resolveCostOwner([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
// Budget exhausted → skip (no local fallback for a summary).
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
resolveCostOwner,
|
||||
resolveDailyCap,
|
||||
resolveDailyCapFor,
|
||||
type BudgetCostOwner,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
@@ -553,7 +553,8 @@ export async function runReactionLine(
|
||||
job.userId,
|
||||
revision,
|
||||
buildDateKey(),
|
||||
resolveDailyCap(chain),
|
||||
// v1.38.19 (Wave E) — a background surface: half the day's ceiling.
|
||||
resolveDailyCapFor("job", chain),
|
||||
resolveCostOwner(chain),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user