Q75: charts and billing use full-period statistics computed per org

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 12:41:44 -04:00
1 parent daea9797d4
commit 8b4df5e11f
14 files changed
+231 -83

No files matched your search

+2
View File
@@ -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<string, unknown>;
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"),
+75
View File
@@ -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<string, { requests: number; tokens: number; costCents: number }>();
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<string, { requests: number; failures: number }>();
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);
}
+4
View File
@@ -32,6 +32,10 @@ export const zh: Record<string, string> = {
"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} 次调用未计价",
+5 -5
View File
@@ -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) {
<div style={METRICS}>
<AgentWorkloadChart agents={agents} tasks={tasks} />
<RunActivityChart taskRuns={taskRuns} />
<SuccessRateChart taskRuns={taskRuns} />
<CostTrendChart providerRequests={providerRequests} />
<RunActivityChart days={usageStats.days} />
<SuccessRateChart days={usageStats.days} />
<CostTrendChart days={usageStats.days} />
</div>
<div style={ACTIVITY}>
+3 -1
View File
@@ -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}
/>
+15 -40
View File
@@ -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<number | null>(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 (
<ChartCard title={t("Run activity")} readout={readout}>
@@ -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 (
<ChartCard title={t("Success rate")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
<ChartCard title={t("Success rate (7 days)")} readout={t("{ok} ok · {failed} failed", { ok: completed, failed })}>
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
<circle
@@ -121,19 +122,10 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
}
/** Daily model tokens as the providers reported them; calls without reported usage are counted, not guessed. */
export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ createdAt: string; inputTokens: number | null; outputTokens: number | null }> }) {
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<number | null>(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"];
@@ -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({
)}
<div style={{ marginTop: 28 }}>
<BillingPanel agents={agents} providers={providers} providerRequests={providerRequests} providerRateLimits={providerRateLimits} hiringPolicies={hiringPolicies} />
<BillingPanel agents={agents} providers={providers} usage={usage} providerRateLimits={providerRateLimits} hiringPolicies={hiringPolicies} />
</div>
</div>
);
@@ -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 (
<div>
@@ -40,8 +35,8 @@ export function BillingPanel({
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
<StatTile label={t("Monthly allocation")} value={money(totalBudget)} />
<StatTile label={t("Budget cap")} value={totalCap > 0 ? money(totalCap) : "—"} />
<StatTile label={t("Requests (30d)")} value={String(monthRequests.length)} />
<StatTile label={t("Failures (30d)")} value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
<StatTile label={t("Requests (30d)")} value={String(usage.month.requests)} />
<StatTile label={t("Failures (30d)")} value={String(usage.month.failures)} tone={usage.month.failures > 0 ? "red" : undefined} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
@@ -95,8 +90,8 @@ export function BillingPanel({
<div>{t("Provider")}</div><div>{t("Requests")}</div><div>{t("Errors")}</div><div>{t("Agents")}</div>
</div>
{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 (
<div key={provider.id} className="table-row" style={{ gridTemplateColumns: "minmax(0, 1.6fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr)", borderBottom: index === providers.length - 1 ? "none" : "1px solid var(--border-soft)" }}>
@@ -104,7 +99,7 @@ export function BillingPanel({
<div style={{ fontWeight: 540 }}>{provider.label}</div>
{provider.model && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.model}</div>}
</div>
<div className="num mono">{requests.length}</div>
<div className="num mono">{activity?.requests ?? 0}</div>
<div>{errors > 0 ? <Pill tone="amber">{errors}</Pill> : <span className="num mono" style={{ color: "var(--text-3)" }}>0</span>}</div>
<div className="num mono" style={{ color: "var(--text-2)" }}>{boundAgents}</div>
</div>
@@ -123,7 +118,3 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?:
</div>
);
}
function withinDays(iso: string, days: number): boolean {
return Date.now() - new Date(iso).getTime() < days * 24 * 60 * 60 * 1000;
}
@@ -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);
}
+7
View File
@@ -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;
}
+3
View File
@@ -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
+23
View File
@@ -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 }>;
}
+5 -6
View File
@@ -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],
]);
});
});
+68
View File
@@ -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;
});
});