diff --git a/src/app/api/usage/records/route.ts b/src/app/api/usage/records/route.ts new file mode 100644 index 0000000..25aaaa1 --- /dev/null +++ b/src/app/api/usage/records/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ActorError, requireActor } from "@/lib/server/actor"; +import { clearUsageRecords } from "@/lib/server/usageStore"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export function DELETE(request: NextRequest) { + try { + requireActor(request); + } catch (error) { + if (error instanceof ActorError) { + return NextResponse.json({ error: error.message }, { status: error.status }); + } + throw error; + } + return NextResponse.json(clearUsageRecords(), { + headers: { "Cache-Control": "no-store" }, + }); +} diff --git a/src/app/api/usage/routes.test.ts b/src/app/api/usage/routes.test.ts index 28797fc..5a01ac1 100644 --- a/src/app/api/usage/routes.test.ts +++ b/src/app/api/usage/routes.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterAll, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; vi.mock("server-only", () => ({})); @@ -14,6 +15,7 @@ process.env.EVOSCIENTIST_WORKSPACE_ID = "ws1_fixture"; const capabilities = await import("./capabilities/route"); const events = await import("./events/route"); const heartbeat = await import("./sources/heartbeat/route"); +const records = await import("./records/route"); const summary = await import("./summary/route"); const store = await import("@/lib/server/usageStore"); const fixture = JSON.parse( @@ -121,4 +123,42 @@ describe("usage route contract", () => { expect(response.status).toBe(422); expect(await response.json()).toEqual({ error: "time_range_too_large" }); }); + + it("clears all usage records and accepts re-delivered events", async () => { + const window = + "from=2026-07-01T00%3A00%3A00.000Z&to=2026-07-31T00%3A00%3A00.000Z"; + const before = await ( + await summary.GET( + request(`http://localhost/api/usage/summary?${window}`) + ).json() + ); + expect(BigInt(before.confirmed_call_count)).toBeGreaterThan(BigInt(0)); + + const cleared = records.DELETE( + new NextRequest("http://localhost/api/usage/records", { + method: "DELETE", + }) + ); + expect(cleared.status).toBe(200); + expect(await cleared.json()).toMatchObject({ + calls_deleted: expect.any(String), + }); + + const after = await ( + await summary.GET( + request(`http://localhost/api/usage/summary?${window}`) + ).json() + ); + expect(after.confirmed_call_count).toBe("0"); + expect(after.unknown_call_count).toBe("0"); + + const reingest = await events.POST( + request("http://localhost/api/usage/events", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(fixture), + }) + ); + expect(await reingest.json()).toMatchObject({ status: "accepted" }); + }); }); diff --git a/src/app/components/UsageDetailsDialog.tsx b/src/app/components/UsageDetailsDialog.tsx index 161275a..31d6c32 100644 --- a/src/app/components/UsageDetailsDialog.tsx +++ b/src/app/components/UsageDetailsDialog.tsx @@ -5,6 +5,7 @@ import { ChevronDown, LoaderCircle, RefreshCw, + Trash2, TriangleAlert, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -22,6 +23,7 @@ import { type UsageSourceStatus, type UsageView, usageCallTotal, + clearAllUsageRecords, useUsageDetails, } from "@/app/hooks/useUsage"; import { cn } from "@/lib/utils"; @@ -171,10 +173,26 @@ export function UsageDetailsDialog({ statusError: unknown; }) { const [view, setView] = useState("thread"); + const [clearing, setClearing] = useState(false); useEffect(() => { if (open) setView(threadId ? "thread" : "workspace"); }, [open, threadId]); + const clearRecords = async () => { + if ( + !window.confirm( + "Delete all recorded token usage for every chat? This cannot be undone." + ) + ) + return; + setClearing(true); + try { + await clearAllUsageRecords(); + } finally { + setClearing(false); + } + }; + const unavailable = Boolean(statusError) || !status || @@ -289,24 +307,42 @@ export function UsageDetailsDialog({ ); })} - + + + )} diff --git a/src/app/hooks/useUsage.ts b/src/app/hooks/useUsage.ts index 782cd92..eb8c793 100644 --- a/src/app/hooks/useUsage.ts +++ b/src/app/hooks/useUsage.ts @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; -import useSWR from "swr"; +import useSWR, { mutate } from "swr"; import useSWRInfinite from "swr/infinite"; export interface UsageSummary { @@ -171,8 +171,21 @@ export function useUsageDetails({ }; } -export function usageCallTotal(call: UsageCall): string | null { - if ( +/** + * Delete every recorded model call on the server, then revalidate all usage + * SWR keys so the dialog and the composer pill drop to zero. + */ +export async function clearAllUsageRecords(): Promise { + const response = await fetch("/api/usage/records", { method: "DELETE" }); + if (!response.ok) throw new Error(`Usage API returned ${response.status}`); + await mutate( + (key) => typeof key === "string" && key.startsWith("/api/usage/"), + undefined, + { revalidate: true } + ); +} + +export function usageCallTotal(call: UsageCall): string | null { if ( call.usage_status !== "confirmed" || call.input_tokens === null || call.output_tokens === null diff --git a/src/lib/server/usageStore.ts b/src/lib/server/usageStore.ts index 308e94d..fe5821f 100644 --- a/src/lib/server/usageStore.ts +++ b/src/lib/server/usageStore.ts @@ -272,6 +272,25 @@ export async function backupUsageDatabase(destination: string): Promise { await usageDb().backup(destination); } +/** + * Wipe every recorded model call plus its ingest bookkeeping, so a cleared + * database can accept re-delivered spooled events instead of dropping them + * as idempotency duplicates. Heartbeat source health and provider + * reconciliation aggregates are operational state, not usage records, and + * are kept. + */ +export function clearUsageRecords( + database: Database.Database = usageDb() +): { calls_deleted: string } { + return database.transaction(() => { + const calls = database.prepare("DELETE FROM model_usage").run(); + database.prepare("DELETE FROM usage_event_conflicts").run(); + database.prepare("DELETE FROM usage_event_inbox").run(); + database.prepare("DELETE FROM usage_event_idempotency").run(); + return { calls_deleted: calls.changes.toString() }; + })(); +} + function eventParams(event: UsageEventV1, now: string): SqlValue[] { return [ event.deployment_id,