diff --git a/messages/de.json b/messages/de.json index 9d49cd290..277b88ac3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2224,7 +2224,7 @@ "feedbackThanks": "Danke für das Signal.", "feedbackError": "Feedback konnte nicht gespeichert werden", "dailyLimitTitle": "Heute-Limit erreicht", - "dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.", + "dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.", "cluster": { diff --git a/messages/en.json b/messages/en.json index ba04271ee..bf05239fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2224,7 +2224,7 @@ "feedbackThanks": "Thanks for the signal.", "feedbackError": "Could not save feedback", "dailyLimitTitle": "Daily limit reached", - "dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.", + "dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.", "providerRateLimitTitle": "Provider rate-limited", "providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.", "cluster": { diff --git a/messages/es.json b/messages/es.json index 4596b221c..bb1b6992f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Gracias por la indicación.", "feedbackError": "No se pudo guardar el comentario", "dailyLimitTitle": "Límite diario alcanzado", - "dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.", + "dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).", "providerRateLimitTitle": "Proveedor con límite de tasa", "providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.", "settingsContextLabel": "Enviar contexto", diff --git a/messages/fr.json b/messages/fr.json index 82b1156d4..fb09bcfa2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Merci pour l’indication.", "feedbackError": "Impossible d'enregistrer le retour", "dailyLimitTitle": "Limite quotidienne atteinte", - "dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.", + "dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).", "providerRateLimitTitle": "Fournisseur limité en débit", "providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.", "settingsContextLabel": "Envoyer le contexte", diff --git a/messages/it.json b/messages/it.json index 4e611a6ee..9c2a5496c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Grazie per il segnale.", "feedbackError": "Impossibile salvare il feedback", "dailyLimitTitle": "Limite giornaliero raggiunto", - "dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.", + "dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).", "providerRateLimitTitle": "Provider con rate-limit", "providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.", "settingsContextLabel": "Invia contesto", diff --git a/messages/pl.json b/messages/pl.json index fda773d42..e4c29338e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2222,7 +2222,7 @@ "feedbackThanks": "Dzięki za sygnał.", "feedbackError": "Nie udało się zapisać opinii", "dailyLimitTitle": "Osiągnięto dzienny limit", - "dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.", + "dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).", "providerRateLimitTitle": "Dostawca z ograniczeniem", "providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.", "settingsContextLabel": "Wysyłaj kontekst", diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts index 8b6c82fd3..6c92359fb 100644 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -94,6 +94,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(), enforceBudget: vi.fn(), recordSpend: vi.fn(), + resolveDailyCap: vi.fn(() => 200_000), })); vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() })); vi.mock("@/lib/ai/coach/system-prompt", () => ({ diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index ef6332dde..eeaea14f3 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -100,6 +100,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-21"), reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })), reconcileSpend: vi.fn(async () => undefined), + resolveDailyCap: vi.fn(() => 2_000_000), })); vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn(() => ({ refuse: false })), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index 3f4a1e17d..c38816b39 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -111,6 +111,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-21"), reserveBudget, reconcileSpend, + resolveDailyCap: vi.fn(() => 2_000_000), })); const { detectRefusal } = vi.hoisted(() => ({ @@ -245,8 +246,14 @@ describe("coach chat — tool-mode routing (F1)", () => { { providerType: "anthropic", instance: {} }, ]); await post(chatReq({ message: "How is my BP?" })); - // maxTokens (1500) × MAX_ROUNDS (2). - expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21"); + // maxTokens (1500) × MAX_ROUNDS (2). The 4th arg is the provider-aware + // daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain. + expect(reserveBudget).toHaveBeenCalledWith( + "u1", + 3000, + "2026-06-21", + 2_000_000, + ); }); it("persists the tool trace onto provenance", async () => { @@ -321,8 +328,14 @@ describe("coach chat — tool-mode routing (F1)", () => { expect(runCoachToolLoop).not.toHaveBeenCalled(); expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1); - // Single-round budget reservation in the fallback path. - expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21"); + // Single-round budget reservation in the fallback path; the 4th arg is the + // provider-aware daily cap (F1). + expect(reserveBudget).toHaveBeenCalledWith( + "u1", + 1500, + "2026-06-21", + 2_000_000, + ); // The legacy path ships the snapshot figures in the user turn. const params = ( (runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 9fca7a8d8..13d02b2f4 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -64,6 +64,7 @@ import { buildDateKey, reserveBudget, reconcileSpend, + resolveDailyCap, } from "@/lib/ai/coach/budget"; import { detectRefusal } from "@/lib/ai/coach/refusal"; import { @@ -503,6 +504,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // so reserve the per-call ceiling × the round count up front and reconcile // the SUMMED actual tokens afterwards. The atomic reserve/reconcile // primitives are unchanged; only the reserved amount scales. + // v1.21.0 (F1) — the daily ceiling is the OPERATOR's cost cap only when the + // chain egresses via the operator's own key (`admin-openai` primary). A + // ChatGPT-OAuth/Codex or BYOK chain runs on the user's OWN plan/key and costs + // the operator nothing, so it gets the generous user-plan ceiling — gating it + // on the operator-cost cap would lock the user out of a plan they pay for. + const dailyCap = resolveDailyCap(chain); const reqDateKey = buildDateKey(); const reservation = await reserveBudget( userId, @@ -510,6 +517,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; ? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS : AI_BUDGETS.coach.maxTokens, reqDateKey, + dailyCap, ); if (!reservation.allowed) { annotate({ @@ -526,6 +534,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // prose number-verifier. Empty on the no-tools path. let toolResultPayloads: unknown[] = []; let totalTokensSpent: number; + // v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached + // input the user did not re-pay for must not be billed to the daily meter). + let cachedTokensSpent = 0; try { if (toolMode) { // v1.20.0 (F1) — base context: the full system prompt + a tool-mode @@ -565,6 +576,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch toolTrace = loop.toolTrace; toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data); totalTokensSpent = loop.totalTokens; + cachedTokensSpent = loop.cachedTokens; } else { const fallback = await runRawCompletionWithFallback({ userId, @@ -587,6 +599,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch result = fallback.result; workingProviderType = fallback.workingProvider.providerType; totalTokensSpent = result.tokensUsed ?? 0; + cachedTokensSpent = result.cachedInputTokens ?? 0; } } catch (err) { // The provider chain failed outright — no tokens were billed, so refund @@ -643,6 +656,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch reservation.reserved, totalTokensSpent, reqDateKey, + cachedTokensSpent, ).catch(() => { // Ledger reconcile is best-effort; a failure leaves the conservative // reservation in place (never an undercount) and never breaks the turn. diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 055a56a1f..f4617a05c 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -41,6 +41,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({ buildDateKey: vi.fn(() => "2026-06-26"), reserveBudget: vi.fn(), reconcileSpend: vi.fn().mockResolvedValue(undefined), + resolveDailyCap: vi.fn(() => 200_000), })); vi.mock("@/lib/labs/ocr-extract", async () => { const actual = await vi.importActual( @@ -106,6 +107,8 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => { "user-1", AI_BUDGETS.ocrExtractText.maxTokens, "2026-06-26", + // F1 — the provider-aware daily cap (mocked) is threaded as the 4th arg. + 200_000, ); }); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index e35b710d6..ebddbf99c 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -35,6 +35,7 @@ import { buildDateKey, reconcileSpend, reserveBudget, + resolveDailyCap, } from "@/lib/ai/coach/budget"; import { prisma } from "@/lib/db"; import { @@ -142,11 +143,15 @@ async function handleTextExtract( // a vision call, so it reserves the proportionate text ceiling rather than // the vision budget. Over-charging the vision rate for a text call would // exhaust the day budget against spend that never happened. + // v1.21.0 (F1) — the operator-cost cap applies only when the picked provider + // egresses on the operator's own key; a BYOK / Codex / local pick runs on the + // user's own plan and gets the generous user-plan ceiling. const dateKey = buildDateKey(); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtractText.maxTokens, dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ @@ -234,11 +239,15 @@ async function handleVisionExtract( } // 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe). + // v1.21.0 (F1) — the operator-cost cap applies only when the picked vision + // provider egresses on the operator's own key; a BYOK / Codex pick runs on + // the user's own plan and gets the generous user-plan ceiling. const dateKey = buildDateKey(); const reservation = await reserveBudget( userId, AI_BUDGETS.ocrExtract.maxTokens, dateKey, + resolveDailyCap([{ providerType: pick.entry.providerType }]), ); if (!reservation.allowed) { annotate({ diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index dd9356ed3..03b093499 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -618,7 +618,9 @@ describe("", () => { })} />, ); - expect(html).toContain("Daily limit reached; resets at 00:00 UTC."); + expect(html).toContain( + "Daily limit reached. The budget refreshes at midnight UTC.", + ); // v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights // provider"). Both old and new strings should be absent here when // the budget-exceeded error takes precedence. diff --git a/src/lib/ai/coach/__tests__/budget.test.ts b/src/lib/ai/coach/__tests__/budget.test.ts index aca47c40b..267cb1dcf 100644 --- a/src/lib/ai/coach/__tests__/budget.test.ts +++ b/src/lib/ai/coach/__tests__/budget.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { HttpError } from "@/lib/api-handler"; -import { buildDateKey, MAX_TOKENS_PER_USER_PER_DAY } from "../budget"; +import { + buildDateKey, + MAX_TOKENS_PER_USER_PER_DAY, + OPERATOR_COST_CAP, + USER_PLAN_CAP, + resolveDailyCap, +} from "../budget"; vi.mock("@/lib/db", () => ({ prisma: { @@ -9,6 +15,8 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), upsert: vi.fn(), }, + $executeRaw: vi.fn(), + $queryRaw: vi.fn(), }, })); @@ -105,3 +113,131 @@ describe("budget", () => { expect(args.create.totalTokens).toBe(12); }); }); + +describe("resolveDailyCap (F1 — provider-aware cap)", () => { + it("applies the operator-cost cap to an operator-key (admin-openai) primary", () => { + expect(resolveDailyCap([{ providerType: "admin-openai" }])).toBe( + OPERATOR_COST_CAP, + ); + }); + + it("applies the generous user-plan cap to a ChatGPT-OAuth (codex) primary", () => { + expect(resolveDailyCap([{ providerType: "codex" }])).toBe(USER_PLAN_CAP); + }); + + it("applies the user-plan cap to BYOK openai / anthropic / local primaries", () => { + expect(resolveDailyCap([{ providerType: "openai" }])).toBe(USER_PLAN_CAP); + expect(resolveDailyCap([{ providerType: "anthropic" }])).toBe( + USER_PLAN_CAP, + ); + expect(resolveDailyCap([{ providerType: "local" }])).toBe(USER_PLAN_CAP); + }); + + it("classifies on the PRIMARY entry — a user-egress chain with an admin-openai fallback stays user-plan", () => { + expect( + resolveDailyCap([ + { providerType: "codex" }, + { providerType: "admin-openai" }, + ]), + ).toBe(USER_PLAN_CAP); + }); + + it("defaults an empty chain to the conservative operator cap", () => { + expect(resolveDailyCap([])).toBe(OPERATOR_COST_CAP); + }); +}); + +describe("reserveBudget cap (F1 — user-plan path not locked out)", () => { + let prismaMock: { + $queryRaw: ReturnType; + $executeRaw: ReturnType; + }; + + beforeEach(async () => { + const dbModule = await import("@/lib/db"); + prismaMock = dbModule.prisma as unknown as typeof prismaMock; + prismaMock.$queryRaw.mockReset(); + prismaMock.$executeRaw.mockReset(); + }); + + it("a user-plan chain does NOT trip after a spend that exceeds the operator cap", async () => { + // Prior spend well past the 200k operator cap, but under the user-plan cap. + const priorSpend = OPERATOR_COST_CAP + 50_000; + prismaMock.$queryRaw.mockResolvedValue([ + { total_tokens: priorSpend + 1_200 }, + ]); + const { reserveBudget } = await import("../budget"); + const res = await reserveBudget( + "u", + 1_200, + "2026-05-10", + resolveDailyCap([{ providerType: "codex" }]), + ); + expect(res.allowed).toBe(true); + // The reservation upsert ran; no refund executeRaw fired. + expect(prismaMock.$executeRaw).not.toHaveBeenCalled(); + }); + + it("the operator-key path STILL trips once prior spend reaches the operator cap", async () => { + const priorSpend = OPERATOR_COST_CAP; + prismaMock.$queryRaw.mockResolvedValue([ + { total_tokens: priorSpend + 1_200 }, + ]); + prismaMock.$executeRaw.mockResolvedValue(0); + const { reserveBudget } = await import("../budget"); + const res = await reserveBudget( + "u", + 1_200, + "2026-05-10", + resolveDailyCap([{ providerType: "admin-openai" }]), + ); + expect(res.allowed).toBe(false); + // Refund of the reservation fired on refusal. + expect(prismaMock.$executeRaw).toHaveBeenCalled(); + }); +}); + +describe("reconcileSpend cached-token subtraction (F3)", () => { + let prismaMock: { $executeRaw: ReturnType }; + + beforeEach(async () => { + const dbModule = await import("@/lib/db"); + prismaMock = dbModule.prisma as unknown as typeof prismaMock; + prismaMock.$executeRaw.mockReset(); + prismaMock.$executeRaw.mockResolvedValue(0); + }); + + it("bills total_tokens minus cached input as the signed delta", async () => { + const { reconcileSpend } = await import("../budget"); + // reserved 1200, gross 20000, cached 13000 → net actual 7000 → delta 5800. + await reconcileSpend("u", 1_200, 20_000, "2026-05-10", 13_000); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + // The tagged-template interpolations carry the delta; assert it is 5800. + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + expect(interpolations).toContain(5_800); + }); + + it("clamps a cached count larger than gross to a zero charge (delta = -reserved)", async () => { + const { reconcileSpend } = await import("../budget"); + await reconcileSpend("u", 1_200, 5_000, "2026-05-10", 9_999); + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + // net actual clamped to 0 → delta = 0 - 1200 = -1200. + expect(interpolations).toContain(-1_200); + }); + + it("defaults cachedTokens to 0 (back-compat) — bills gross", async () => { + const { reconcileSpend } = await import("../budget"); + await reconcileSpend("u", 1_000, 4_000, "2026-05-10"); + const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1); + expect(interpolations).toContain(3_000); + }); +}); + +describe("MAX_TOKENS_PER_USER_PER_DAY (F2 — reasoning-aware operator cap)", () => { + it("is sized for reasoning turns, not a single non-reasoning reply", () => { + // Was 25_000 (≈ one gpt-5.x reasoning turn). Raised so the operator-key + // path survives a normal day of reasoning turns. + expect(MAX_TOKENS_PER_USER_PER_DAY).toBeGreaterThanOrEqual(150_000); + expect(OPERATOR_COST_CAP).toBe(MAX_TOKENS_PER_USER_PER_DAY); + }); +}); diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 02df866e7..f52f52340 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -16,8 +16,63 @@ */ import { prisma } from "@/lib/db"; import { HttpError } from "@/lib/api-handler"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; -export const MAX_TOKENS_PER_USER_PER_DAY = 25_000; +/** + * Operator-cost daily ceiling — the cap that protects the OPERATOR's LLM bill. + * + * It applies ONLY to the operator-managed-key path (`admin-openai`): the + * server's own OpenAI key, where every token the user spends lands on the + * operator's invoice. + * + * v1.21.0 (F1/F2) — raised from the historical 25_000. That figure was sized + * for a single non-reasoning ~600-token chat reply (~20 turns/day). With the + * v1.20 tool loop charging the Responses-API gross `total_tokens` of a `gpt-5.x` + * reasoning turn (re-sent system prompt + inventory + 7 tool defs per round + + * hidden reasoning, summed across rounds ≈ 20k–40k), 25k locked the user out + * after one turn. 200k keeps gross accounting (no per-round output-token + * surgery) while leaving room for a normal day of reasoning turns on the + * operator's key. The cap stays a real ceiling on the operator's exposure. + */ +export const MAX_TOKENS_PER_USER_PER_DAY = 200_000; + +/** + * v1.21.0 (F1) — historical name retained for any external reference; the + * operator-cost cap is the only cap the ledger enforces. + */ +export const OPERATOR_COST_CAP = MAX_TOKENS_PER_USER_PER_DAY; + +/** + * v1.21.0 (F1) — the daily ceiling for a chain whose egress runs on the + * USER's own plan / key (ChatGPT-OAuth/Codex, BYOK OpenAI/Anthropic, or a + * self-hosted local model). The operator pays nothing for these, so the + * operator-cost cap is a category error here — gating them on it locks a user + * out of a plan they pay for. We keep only a generous abuse ceiling so a + * runaway client loop can't write unbounded rows; a normal user never reaches + * it. + */ +export const USER_PLAN_CAP = 2_000_000; + +/** + * v1.21.0 (F1) — classify a resolved provider chain's cost owner and return + * the daily cap that applies. + * + * The chain is a fallback list; the FIRST entry is the provider that will be + * tried first and is the expected cost owner. Only when that primary provider + * is `admin-openai` (the user has no personal provider and falls back to the + * operator's key) does the operator pay — so the operator-cost cap applies. + * Every other primary (`codex` / `openai` / `anthropic` / `local`) is the + * user's own egress, so the generous user-plan cap applies. An empty chain + * (no provider resolved) defaults to the operator cap — the conservative side. + */ +export function resolveDailyCap( + chain: ReadonlyArray<{ providerType: ProviderChainType }>, +): number { + const primary = chain[0]?.providerType; + return primary === "admin-openai" || primary === undefined + ? OPERATOR_COST_CAP + : USER_PLAN_CAP; +} /** * Build the UTC day-key for a given clock. Defaults to "now". @@ -136,17 +191,31 @@ export async function reserveBudget( * goes negative). Called after every provider call — including empty / * sentinel / refusal replies, whose upstream tokens were still burned * (SENIOR-DEV MEDIUM: spend undercount). + * + * v1.21.0 (F3) — `cachedTokens` (the Responses-API `cached_tokens` count) is + * subtracted from the charged amount. The gross `total_tokens` a reasoning + * provider reports still includes the full input even when prompt-caching + * served most of it cheaply / free; charging the user's daily meter for input + * they did not re-pay for is an over-charge. We bill `actual - cached`. */ export async function reconcileSpend( userId: string, reserved: number, actualTokens: number, dateKey: string = buildDateKey(), + cachedTokens = 0, ): Promise { - const actual = + const grossActual = Number.isFinite(actualTokens) && actualTokens > 0 ? Math.floor(actualTokens) : 0; + const cached = + Number.isFinite(cachedTokens) && cachedTokens > 0 + ? Math.floor(cachedTokens) + : 0; + // Bill net of cached input; clamp so a cached count larger than the gross + // (shouldn't happen, but the wire is untrusted) can't drive a negative charge. + const actual = Math.max(0, grossActual - cached); const delta = actual - reserved; if (delta === 0) return; // Clamp at zero so a smaller-than-reserved actual can't drive the row diff --git a/src/lib/ai/coach/tools/loop.ts b/src/lib/ai/coach/tools/loop.ts index 5e8208e4a..852813e67 100644 --- a/src/lib/ai/coach/tools/loop.ts +++ b/src/lib/ai/coach/tools/loop.ts @@ -45,6 +45,12 @@ export interface CoachToolLoopResult { workingProviderType: string; /** Summed tokens across every round (for budget reconcile). */ totalTokens: number; + /** + * v1.21.0 (F3) — summed cached-input tokens across every round. Subtracted + * from the charged amount at reconcile so prompt-cached input the user did + * not re-pay for is not billed to their daily meter. + */ + cachedTokens: number; /** Number of model round-trips made. */ rounds: number; /** Which tools ran + whether each found data (persisted onto provenance). */ @@ -86,6 +92,7 @@ export async function runCoachToolLoop(args: { const messages: AiMessage[] = [...args.messages]; let totalTokens = 0; + let cachedTokens = 0; let rounds = 0; let workingProviderType = ""; const toolTrace: CoachToolTrace[] = []; @@ -116,6 +123,7 @@ export async function runCoachToolLoop(args: { const result = fallback.result; workingProviderType = fallback.workingProvider.providerType; totalTokens += result.tokensUsed ?? 0; + cachedTokens += result.cachedInputTokens ?? 0; const calls = result.toolCalls ?? []; const wantsTools = @@ -136,6 +144,7 @@ export async function runCoachToolLoop(args: { result, workingProviderType, totalTokens, + cachedTokens, rounds, toolTrace, toolResults,