From 8b4df5e11fd5ef053d34cdfb06014afbcab6a832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 12:41:44 -0400 Subject: [PATCH] Q75: charts and billing use full-period statistics computed per org Co-Authored-By: Claude Opus 5.5 --- src/main/services/snapshot-service.ts | 2 + src/main/services/usage-stats.ts | 75 +++++++++++++++++++ src/renderer/src/i18n/zh.ts | 4 + src/renderer/src/pages/HomePage.tsx | 10 +-- src/renderer/src/pages/SettingsPage.tsx | 4 +- .../src/pages/home/DashboardCharts.tsx | 55 ++++---------- .../src/pages/settings/AdvancedPanel.tsx | 5 +- .../src/pages/settings/BillingPanel.tsx | 33 +++----- .../src/pages/settings/billing-helpers.ts | 14 ++-- src/renderer/src/pages/types.ts | 7 ++ src/shared/snapshot-domain.ts | 3 + src/shared/usage-stats.ts | 23 ++++++ tests/unit/billing-helpers.test.ts | 11 ++- tests/unit/usage-stats.test.ts | 68 +++++++++++++++++ 14 files changed, 231 insertions(+), 83 deletions(-) create mode 100644 src/main/services/usage-stats.ts create mode 100644 src/shared/usage-stats.ts create mode 100644 tests/unit/usage-stats.test.ts diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index 74ec0f6e..d95aecec 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -3,6 +3,7 @@ import type { AppSnapshot } from "../../shared/domain"; import { PROVIDER_CATALOG } from "../../shared/provider-catalog"; import { IntegrationKeyStore } from "./integration-key-store"; import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain"; +import { buildUsageStats } from "./usage-stats"; type Row = Record; type ProviderUsageWindowRow = Pick< @@ -128,6 +129,7 @@ export class SnapshotService { from provider_requests order by created_at desc limit 100 `)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]), providerRateLimits, + usageStats: buildUsageStats(this.db, new Date()), providerRateLimitObservations, skillDefinitions: list(this.db, "skill_definitions", "created_at asc"), socialAccounts: list(this.db, "social_accounts", "created_at asc"), diff --git a/src/main/services/usage-stats.ts b/src/main/services/usage-stats.ts new file mode 100644 index 00000000..aede7d9e --- /dev/null +++ b/src/main/services/usage-stats.ts @@ -0,0 +1,75 @@ +import type { DatabaseSync } from "node:sqlite"; +import type { UsageStatsRecord } from "../../shared/usage-stats"; + +const WEEK_DAYS = 7; +const MONTH_DAYS = 30; + +/** Period statistics per org, computed over every record in the period, not over the snapshot's recent lists. */ +export function buildUsageStats(db: DatabaseSync, now: Date): UsageStatsRecord[] { + const orgs = db.prepare("select id from organizations").all() as Array<{ id: string }>; + const dayStarts = Array.from({ length: WEEK_DAYS }, (_, index) => localDayStart(now, WEEK_DAYS - 1 - index)); + const weekStart = dayStarts[0].toISOString(); + // "Last 30 days" is a rolling window ending now. + const monthStart = new Date(now.getTime() - MONTH_DAYS * 86_400_000).toISOString(); + return orgs.map(({ id: orgId }) => { + const runs = db.prepare("select created_at, state from task_runs where org_id = ? and created_at >= ?").all(orgId, weekStart) as Array<{ created_at: string; state: string }>; + const calls = db.prepare(` + select created_at, state, input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, cost_cents, agent_id, provider_id + from provider_requests where org_id = ? and created_at >= ? + `).all(orgId, monthStart) as Array<{ + created_at: string; state: string; input_tokens: number | null; output_tokens: number | null; + cached_input_tokens: number | null; reasoning_tokens: number | null; cost_cents: number | null; agent_id: string | null; provider_id: string; + }>; + const days = dayStarts.map((start, index) => { + const end = index + 1 < dayStarts.length ? dayStarts[index + 1] : localDayStart(now, -1); + const inDay = (iso: string) => { const at = Date.parse(iso); return at >= start.getTime() && at < end.getTime(); }; + const dayRuns = runs.filter((run) => inDay(run.created_at)); + const dayCalls = calls.filter((call) => inDay(call.created_at)); + return { + dayStart: start.toISOString(), + runs: dayRuns.length, + completedRuns: dayRuns.filter((run) => run.state === "completed").length, + failedRuns: dayRuns.filter((run) => run.state === "failed").length, + tokens: dayCalls.reduce((sum, call) => sum + (call.input_tokens ?? 0) + (call.output_tokens ?? 0), 0), + callsWithoutUsage: dayCalls.filter((call) => call.input_tokens === null).length, + }; + }); + const agentSpend = new Map(); + for (const call of calls) { + if (!call.agent_id) continue; + const entry = agentSpend.get(call.agent_id) ?? { requests: 0, tokens: 0, costCents: 0 }; + entry.requests += 1; + entry.tokens += (call.input_tokens ?? 0) + (call.output_tokens ?? 0); + entry.costCents += call.cost_cents ?? 0; + agentSpend.set(call.agent_id, entry); + } + const providerActivity = new Map(); + for (const call of calls) { + const entry = providerActivity.get(call.provider_id) ?? { requests: 0, failures: 0 }; + entry.requests += 1; + if (call.state === "failed") entry.failures += 1; + providerActivity.set(call.provider_id, entry); + } + const sum = (pick: (call: typeof calls[number]) => number | null) => calls.reduce((total, call) => total + (pick(call) ?? 0), 0); + return { + orgId, + days, + month: { + requests: calls.length, + failures: calls.filter((call) => call.state === "failed").length, + inputTokens: sum((call) => call.input_tokens), + cachedInputTokens: sum((call) => call.cached_input_tokens), + outputTokens: sum((call) => call.output_tokens), + reasoningTokens: sum((call) => call.reasoning_tokens), + callsWithoutUsage: calls.filter((call) => call.input_tokens === null).length, + }, + agentSpend: [...agentSpend].map(([agentId, entry]) => ({ agentId, ...entry })), + providerActivity: [...providerActivity].map(([providerId, entry]) => ({ providerId, ...entry })), + }; + }); +} + +/** Local midnight of the calendar day `daysAgo` days before today; calendar arithmetic keeps DST days 23 or 25 hours long. */ +function localDayStart(now: Date, daysAgo: number): Date { + return new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysAgo); +} diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index f1233d5f..4bc4d546 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -32,6 +32,10 @@ export const zh: Record = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "{n} in the last 7 days": "近 7 天 {n}", + "Success rate (7 days)": "成功率(近 7 天)", + "{n} tokens in the last 7 days": "近 7 天 {n} token", + "{n} tokens in the last 7 days · {count} calls without usage": "近 7 天 {n} token · {count} 次调用未报告用量", "Spent this month": "本月已花费", "${spent} of ${allocated} allocated to agents": "已花 ${spent} / 分配给 Agent 的 ${allocated}", "{count} calls not priced": "{count} 次调用未计价", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index d4a9f054..f77faf34 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -14,7 +14,7 @@ import type { import { NewTaskDialog } from "./NewTaskDialog"; import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts"; import type { PageProps } from "./types"; -import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization } from "./types"; +import { activeOrgId, formatRelative, formatTime, money, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types"; import { t } from "../i18n/i18n"; const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 }; @@ -37,7 +37,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) { const taskRuns = useMemo(() => snapshot.taskRuns.filter((run) => run.orgId === orgId), [snapshot.taskRuns, orgId]); const chatMessages = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]); const chatSpaces = useMemo(() => snapshot.chatSpaces.filter((s) => s.orgId === orgId), [snapshot.chatSpaces, orgId]); - const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]); + const usageStats = requireUsageStats(snapshot.usageStats, orgId); const [newTaskOpen, setNewTaskOpen] = useState(false); const pendingApprovals = approvals.filter((approval) => approval.state === "pending"); @@ -69,9 +69,9 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
- - - + + +
diff --git a/src/renderer/src/pages/SettingsPage.tsx b/src/renderer/src/pages/SettingsPage.tsx index 0e46ff31..d9bc89de 100644 --- a/src/renderer/src/pages/SettingsPage.tsx +++ b/src/renderer/src/pages/SettingsPage.tsx @@ -5,7 +5,7 @@ import { AdvancedPanel } from "./settings/AdvancedPanel"; import { IntegrationKeysPanel } from "./settings/IntegrationKeysPanel"; import { t } from "../i18n/i18n"; import type { PageProps } from "./types"; -import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types"; +import { activeOrgId, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types"; type SettingsTab = "general" | "providers" | "integrations" | "advanced"; @@ -30,6 +30,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); const providers = useMemo(() => snapshot.providers.filter((item) => item.orgId === orgId), [snapshot.providers, orgId]); const providerAuthProfiles = useMemo(() => snapshot.providerAuthProfiles.filter((item) => item.orgId === orgId), [snapshot.providerAuthProfiles, orgId]); + const usage = requireUsageStats(snapshot.usageStats, orgId); const providerRateLimits = useMemo(() => snapshot.providerRateLimits.filter((item) => item.orgId === orgId), [snapshot.providerRateLimits, orgId]); const workspaces = useMemo(() => snapshot.workspaces.filter((item) => item.orgId === orgId), [snapshot.workspaces, orgId]); const providerRequests = useMemo(() => snapshot.providerRequests.filter((item) => item.orgId === orgId), [snapshot.providerRequests, orgId]); @@ -68,6 +69,7 @@ export function SettingsPage({ snapshot, actions }: PageProps) { workspaces={workspaces} providerRequests={providerRequests} providerRateLimits={providerRateLimits} + usage={usage} hiringPolicies={hiringPolicies} actions={actions} /> diff --git a/src/renderer/src/pages/home/DashboardCharts.tsx b/src/renderer/src/pages/home/DashboardCharts.tsx index 18b767d3..a84cb1f9 100644 --- a/src/renderer/src/pages/home/DashboardCharts.tsx +++ b/src/renderer/src/pages/home/DashboardCharts.tsx @@ -1,5 +1,6 @@ +import type { UsageStatsRecord } from "../../../../shared/usage-stats"; import { useState, type ReactNode } from "react"; -import type { AgentRecord, TaskRecord, TaskRunRecord } from "../../../../shared/domain"; +import type { AgentRecord, TaskRecord } from "../../../../shared/domain"; import { TASK_STATUS } from "../../../../shared/domain"; import { t } from "../../i18n/i18n"; @@ -60,13 +61,13 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t ); } -export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) { - const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt)); +export function RunActivityChart({ days }: { days: UsageDays }) { + const buckets = days.map((day) => ({ label: weekdayFormat.format(new Date(day.dayStart)), count: day.runs })); const total = buckets.reduce((s, b) => s + b.count, 0); const max = Math.max(1, ...buckets.map((b) => b.count)); const barW = (CHART_W - 6 * BAR_GAP) / 7; const [hover, setHover] = useState(null); - const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} this week", { n: total }); + const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} in the last 7 days", { n: total }); return ( @@ -96,16 +97,16 @@ export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) { ); } -export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) { - const completed = taskRuns.filter((r) => r.state === "completed").length; - const failed = taskRuns.filter((r) => r.state === "failed").length; +export function SuccessRateChart({ days }: { days: UsageDays }) { + const completed = days.reduce((sum, day) => sum + day.completedRuns, 0); + const failed = days.reduce((sum, day) => sum + day.failedRuns, 0); const total = completed + failed; const rate = total > 0 ? Math.round((completed / total) * 100) : 0; const circumference = 2 * Math.PI * 42; const offset = circumference - (rate / 100) * circumference; return ( - + }) { - const buckets = last7DayBuckets(providerRequests.map((r) => r.createdAt)); - const tokenBuckets = buckets.map((b, i) => { - const dayStart = dayStartMs(i); - const dayEnd = dayStart + 86400000; - const tokens = providerRequests - .filter((r) => { const t = new Date(r.createdAt).getTime(); return t >= dayStart && t < dayEnd; }) - .reduce((s, r) => s + (r.inputTokens ?? 0) + (r.outputTokens ?? 0), 0); - return { ...b, tokens }; - }); +export function CostTrendChart({ days }: { days: UsageDays }) { + const tokenBuckets = days.map((day) => ({ label: weekdayFormat.format(new Date(day.dayStart)), tokens: day.tokens })); const totalTokens = tokenBuckets.reduce((s, b) => s + b.tokens, 0); - // Same seven calendar days as the chart. - const unreported = providerRequests.filter((r) => r.inputTokens === null && new Date(r.createdAt).getTime() >= dayStartMs(0)).length; + const unreported = days.reduce((sum, day) => sum + day.callsWithoutUsage, 0); const max = Math.max(1, ...tokenBuckets.map((b) => b.tokens)); const coords = tokenBuckets.map((b, i) => ({ x: (i / 6) * (CHART_W - 10) + 5, @@ -143,8 +135,8 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ const points = coords.map((c) => `${c.x},${c.y}`).join(" "); const [hover, setHover] = useState(null); const week = unreported > 0 - ? t("{n} tokens this week · {count} calls without usage", { n: compact(totalTokens), count: unreported }) - : t("{n} tokens this week", { n: compact(totalTokens) }); + ? t("{n} tokens in the last 7 days · {count} calls without usage", { n: compact(totalTokens), count: unreported }) + : t("{n} tokens in the last 7 days", { n: compact(totalTokens) }); const readout = hover !== null && tokenBuckets[hover] ? `${tokenBuckets[hover]!.label} · ${compact(tokenBuckets[hover]!.tokens)}` : week; @@ -173,21 +165,4 @@ function compact(count: number): string { const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" }); -function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> { - const now = Date.now(); - return Array.from({ length: 7 }, (_, i) => { - const offset = 6 - i; - const start = new Date(now - offset * 86400000); - start.setHours(0, 0, 0, 0); - const end = new Date(start.getTime() + 86400000); - const count = dates.filter((d) => { const t = new Date(d).getTime(); return t >= start.getTime() && t < end.getTime(); }).length; - return { label: weekdayFormat.format(start), count }; - }); -} - -function dayStartMs(bucketIndex: number): number { - const offset = 6 - bucketIndex; - const start = new Date(Date.now() - offset * 86400000); - start.setHours(0, 0, 0, 0); - return start.getTime(); -} +type UsageDays = UsageStatsRecord["days"]; diff --git a/src/renderer/src/pages/settings/AdvancedPanel.tsx b/src/renderer/src/pages/settings/AdvancedPanel.tsx index 21579360..0bd2fb7e 100644 --- a/src/renderer/src/pages/settings/AdvancedPanel.tsx +++ b/src/renderer/src/pages/settings/AdvancedPanel.tsx @@ -1,3 +1,4 @@ +import type { UsageStatsRecord } from "../../../../shared/usage-stats"; import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain"; import type { AgentRecord, @@ -21,6 +22,7 @@ export function AdvancedPanel({ workspaces, providerRequests, providerRateLimits, + usage, hiringPolicies, actions, }: { @@ -31,6 +33,7 @@ export function AdvancedPanel({ workspaces: WorkspaceRecord[]; providerRequests: ProviderRequestRecord[]; providerRateLimits: ProviderRateLimitStateRecord[]; + usage: UsageStatsRecord; hiringPolicies: HiringPolicyRecord[]; actions: SnapshotActions; }) { @@ -53,7 +56,7 @@ export function AdvancedPanel({ )}
- +
); diff --git a/src/renderer/src/pages/settings/BillingPanel.tsx b/src/renderer/src/pages/settings/BillingPanel.tsx index 24188551..64b72849 100644 --- a/src/renderer/src/pages/settings/BillingPanel.tsx +++ b/src/renderer/src/pages/settings/BillingPanel.tsx @@ -1,20 +1,22 @@ import { Avatar, Pill } from "../../shared/ui"; -import type { AgentRecord, HiringPolicyRecord, ProviderRecord, ProviderRequestRecord } from "../../../../shared/domain"; +import type { AgentRecord, HiringPolicyRecord, ProviderRecord } from "../../../../shared/domain"; import { money } from "../types"; import { buildAgentSpend } from "./billing-helpers"; import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain"; +import type { UsageStatsRecord } from "../../../../shared/usage-stats"; import { t } from "../../i18n/i18n"; export function BillingPanel({ agents, providers, - providerRequests, + usage, providerRateLimits, hiringPolicies, }: { agents: AgentRecord[]; providers: ProviderRecord[]; - providerRequests: ProviderRequestRecord[]; + /** Last 30 days over every call, computed in the main process. */ + usage: UsageStatsRecord; providerRateLimits: ProviderRateLimitStateRecord[]; hiringPolicies: HiringPolicyRecord[]; }) { @@ -23,15 +25,8 @@ export function BillingPanel({ const unpricedCalls = providerRateLimits.reduce((s, usage) => s + usage.unpricedRequests + usage.requestsWithoutUsage, 0); const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0); const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0); - const monthRequests = providerRequests.filter((r) => withinDays(r.createdAt, 30)); - const sum = (pick: (request: ProviderRequestRecord) => number | null) => monthRequests.reduce((s, r) => s + (pick(r) ?? 0), 0); - const inputTokens = sum((r) => r.inputTokens); - const cachedTokens = sum((r) => r.cachedInputTokens); - const outputTokens = sum((r) => r.outputTokens); - const reasoningTokens = sum((r) => r.reasoningTokens); - const unreported = monthRequests.filter((r) => r.inputTokens === null).length; - const failedRequests = monthRequests.filter((r) => r.state === "failed"); - const agentSpend = buildAgentSpend(agents, monthRequests); + const { inputTokens, cachedInputTokens: cachedTokens, outputTokens, reasoningTokens, callsWithoutUsage: unreported } = usage.month; + const agentSpend = buildAgentSpend(agents, usage.agentSpend); return (
@@ -40,8 +35,8 @@ export function BillingPanel({
0 ? money(totalCap) : "—"} /> - - 0 ? "red" : undefined} /> + + 0 ? "red" : undefined} />
@@ -95,8 +90,8 @@ export function BillingPanel({
{t("Provider")}
{t("Requests")}
{t("Errors")}
{t("Agents")}
{providers.map((provider, index) => { - const requests = monthRequests.filter((r) => r.providerId === provider.id); - const errors = requests.filter((r) => r.state === "failed").length; + const activity = usage.providerActivity.find((row) => row.providerId === provider.id); + const errors = activity?.failures ?? 0; const boundAgents = agents.filter((a) => a.modelProviderId === provider.id).length; return (
@@ -104,7 +99,7 @@ export function BillingPanel({
{provider.label}
{provider.model &&
{provider.model}
}
-
{requests.length}
+
{activity?.requests ?? 0}
{errors > 0 ? {errors} : 0}
{boundAgents}
@@ -123,7 +118,3 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?: ); } - -function withinDays(iso: string, days: number): boolean { - return Date.now() - new Date(iso).getTime() < days * 24 * 60 * 60 * 1000; -} diff --git a/src/renderer/src/pages/settings/billing-helpers.ts b/src/renderer/src/pages/settings/billing-helpers.ts index 401d95f4..211af505 100644 --- a/src/renderer/src/pages/settings/billing-helpers.ts +++ b/src/renderer/src/pages/settings/billing-helpers.ts @@ -1,4 +1,5 @@ -import type { AgentRecord, ProviderRequestRecord } from "../../../../shared/domain"; +import type { AgentRecord } from "../../../../shared/domain"; +import type { UsageStatsRecord } from "../../../../shared/usage-stats"; export interface AgentSpendRow { agent: AgentRecord; @@ -8,14 +9,9 @@ export interface AgentSpendRow { } /** Each call counts once, for the agent that made it, whatever provider the agent uses now. */ -export function buildAgentSpend(agents: AgentRecord[], requests: ProviderRequestRecord[]): AgentSpendRow[] { +export function buildAgentSpend(agents: AgentRecord[], spend: UsageStatsRecord["agentSpend"]): AgentSpendRow[] { return agents.map((agent) => { - const agentRequests = requests.filter((r) => r.agentId === agent.id); - return { - agent, - requestCount: agentRequests.length, - tokens: agentRequests.reduce((s, r) => s + (r.inputTokens ?? 0) + (r.outputTokens ?? 0), 0), - spentCents: agentRequests.reduce((s, r) => s + (r.costCents ?? 0), 0), - }; + const entry = spend.find((row) => row.agentId === agent.id); + return { agent, requestCount: entry?.requests ?? 0, tokens: entry?.tokens ?? 0, spentCents: entry?.costCents ?? 0 }; }).sort((a, b) => b.spentCents - a.spentCents || b.requestCount - a.requestCount); } diff --git a/src/renderer/src/pages/types.ts b/src/renderer/src/pages/types.ts index cc1dcbb1..717a5c19 100644 --- a/src/renderer/src/pages/types.ts +++ b/src/renderer/src/pages/types.ts @@ -79,3 +79,10 @@ export function formatRelative(iso: string, reference: Date = new Date()): strin if (weeks < 5) return t("{n}w ago", { n: weeks }); return new Date(iso).toLocaleDateString(); } + +/** The active org's period statistics; the snapshot carries one entry per org. */ +export function requireUsageStats(stats: AppSnapshot["usageStats"], orgId: string): AppSnapshot["usageStats"][number] { + const found = stats.find((item) => item.orgId === orgId); + if (!found) throw new Error(`No usage statistics for organization ${orgId}.`); + return found; +} diff --git a/src/shared/snapshot-domain.ts b/src/shared/snapshot-domain.ts index b5621dd6..4f140dca 100644 --- a/src/shared/snapshot-domain.ts +++ b/src/shared/snapshot-domain.ts @@ -1,3 +1,4 @@ +import type { UsageStatsRecord } from "./usage-stats"; import type { AuditReplayRunRecord } from "./audit-domain"; import type { IntegrationKeyStatus } from "./commands/integration-keys"; import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain"; @@ -102,6 +103,8 @@ export interface AppSnapshot { providerCatalog: ProviderCatalogEntry[]; providerRequests: ProviderRequestRecord[]; providerRateLimits: ProviderRateLimitStateRecord[]; + /** Period statistics per org over all records, for charts and billing. */ + usageStats: UsageStatsRecord[]; providerRateLimitObservations: ProviderRateLimitObservationRecord[]; // The snapshot is the renderer's working set and rides IPC on every // refresh. Skill internals (revisions, bindings, per-run runtime diff --git a/src/shared/usage-stats.ts b/src/shared/usage-stats.ts new file mode 100644 index 00000000..8871681d --- /dev/null +++ b/src/shared/usage-stats.ts @@ -0,0 +1,23 @@ +/** One org's period statistics: the last 7 local days, and the last 30 days of provider calls. */ +export interface UsageStatsRecord { + orgId: string; + days: Array<{ + dayStart: string; + runs: number; + completedRuns: number; + failedRuns: number; + tokens: number; + callsWithoutUsage: number; + }>; + month: { + requests: number; + failures: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningTokens: number; + callsWithoutUsage: number; + }; + agentSpend: Array<{ agentId: string; requests: number; tokens: number; costCents: number }>; + providerActivity: Array<{ providerId: string; requests: number; failures: number }>; +} diff --git a/tests/unit/billing-helpers.test.ts b/tests/unit/billing-helpers.test.ts index be420053..1ac087e5 100644 --- a/tests/unit/billing-helpers.test.ts +++ b/tests/unit/billing-helpers.test.ts @@ -1,18 +1,17 @@ import { describe, expect, test } from "vitest"; import { buildAgentSpend } from "../../src/renderer/src/pages/settings/billing-helpers"; -import type { AgentRecord, ProviderRequestRecord } from "../../src/shared/domain"; +import type { AgentRecord } from "../../src/shared/domain"; const agent = (id: string, modelProviderId: string) => ({ id, name: id, modelProviderId, budgetCents: 1000 }) as AgentRecord; -const call = (agentId: string | null, providerId: string, costCents: number | null) => - ({ agentId, providerId, inputTokens: 100, outputTokens: 50, costCents }) as ProviderRequestRecord; describe("billing attribution", () => { - test("each call counts once for the agent that made it, not for everyone on the same provider", () => { + test("rows come from per-agent totals, highest spend first, agents without calls at zero", () => { const rows = buildAgentSpend([agent("ada", "prv_a"), agent("bo", "prv_a"), agent("cy", "prv_b")], [ - call("ada", "prv_a", 10), call("ada", "prv_a", 5), call("bo", "prv_a", 1), call("cy", "prv_a", 2), call(null, "prv_a", 99), + { agentId: "ada", requests: 2, tokens: 300, costCents: 15 }, + { agentId: "cy", requests: 1, tokens: 150, costCents: 2 }, ]); expect(rows.map((row) => [row.agent.id, row.requestCount, row.tokens, row.spentCents])).toEqual([ - ["ada", 2, 300, 15], ["cy", 1, 150, 2], ["bo", 1, 150, 1], + ["ada", 2, 300, 15], ["cy", 1, 150, 2], ["bo", 0, 0, 0], ]); }); }); diff --git a/tests/unit/usage-stats.test.ts b/tests/unit/usage-stats.test.ts new file mode 100644 index 00000000..fbf68085 --- /dev/null +++ b/tests/unit/usage-stats.test.ts @@ -0,0 +1,68 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { buildUsageStats } from "../../src/main/services/usage-stats"; + +describe("usage statistics", () => { + test("periods cover every record of each org, beyond the snapshot's recent-list caps", () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-usage-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const orgs: string[] = []; + connection.transaction(() => { + for (const name of ["Acme", "Beta"]) orgs.push(organization.createOrganization({ organizationName: name, employeeName: "Dana", mission: "m", goal: "g" })); + }); + const now = new Date(); + const insert = connection.db.prepare(` + insert into provider_requests(id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, state, input_char_count, output_char_count, + execution_mode, policy_decision, stream_event_count, created_at, input_tokens, output_tokens, cost_cents) + values (?, ?, ?, 'openai', null, 'm', 'sha', ?, 1, 1, 'foreground', 'allowed', 0, ?, ?, ?, ?) + `); + connection.transaction(() => { + for (const [index, orgId] of orgs.entries()) { + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + for (let call = 0; call < 150; call += 1) { + const daysAgo = call % 20; + const at = new Date(now.getTime() - daysAgo * 86_400_000).toISOString(); + insert.run(`preq_${index}_${call}`, orgId, providerId, call % 10 === 0 ? "failed" : "succeeded", at, call % 3 === 0 ? null : 100, call % 3 === 0 ? null : 10, 1); + } + } + }); + const stats = buildUsageStats(connection.db, now); + expect(stats.map((item) => item.orgId).sort()).toEqual([...orgs].sort()); + const acme = stats.find((item) => item.orgId === orgs[0])!; + expect(acme.month).toMatchObject({ requests: 150, failures: 15, callsWithoutUsage: 50, inputTokens: 100 * 100 }); + expect(acme.days).toHaveLength(7); + const weekCalls = Array.from({ length: 150 }, (_, call) => call % 20).filter((daysAgo) => daysAgo < 7).length; + expect(acme.days.reduce((sum, day) => sum + day.callsWithoutUsage, 0) + acme.days.reduce((sum, day) => sum + day.tokens, 0) / 110).toBe(weekCalls); + expect(acme.providerActivity).toEqual([{ providerId: expect.any(String), requests: 150, failures: 15 }]); + connection.close(); + }); + + test("day buckets follow calendar days across a daylight-saving change, and 30 days is a rolling window", () => { + const previous = process.env.TZ; + process.env.TZ = "America/New_York"; + const connection = new DatabaseConnection(join(tmpdir(), `ac-usage-dst-${randomUUID()}.db`)); + let orgId = ""; + connection.transaction(() => { + orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" }); + }); + const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id; + const insert = connection.db.prepare(` + insert into provider_requests(id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, state, input_char_count, output_char_count, + execution_mode, policy_decision, stream_event_count, created_at, input_tokens, output_tokens) + values (?, ?, ?, 'openai', null, 'm', 'sha', 'succeeded', 1, 1, 'foreground', 'allowed', 0, ?, 1, 0) + `); + // 2026-03-09 00:30 New York is right after the spring-forward night; 29 days 23 hours before "now" is still inside 30 days. + const now = new Date("2026-03-12T12:00:00-04:00"); + insert.run("preq_dst", orgId, providerId, new Date("2026-03-09T00:30:00-04:00").toISOString()); + insert.run("preq_edge", orgId, providerId, new Date(now.getTime() - (30 * 24 - 1) * 3_600_000).toISOString()); + const stats = buildUsageStats(connection.db, now).find((item) => item.orgId === orgId)!; + expect(stats.days.reduce((sum, day) => sum + day.tokens, 0)).toBe(1); + expect(stats.month.requests).toBe(2); + connection.close(); + process.env.TZ = previous; + }); +});