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:
1 parent
daea9797d4
commit
8b4df5e11f
14 files changed
+231
-83
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} 次调用未计价",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user