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 { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||||
import { IntegrationKeyStore } from "./integration-key-store";
|
import { IntegrationKeyStore } from "./integration-key-store";
|
||||||
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
import { currentProviderUsageWindow } from "../../shared/provider-runtime-domain";
|
||||||
|
import { buildUsageStats } from "./usage-stats";
|
||||||
|
|
||||||
type Row = Record<string, unknown>;
|
type Row = Record<string, unknown>;
|
||||||
type ProviderUsageWindowRow = Pick<
|
type ProviderUsageWindowRow = Pick<
|
||||||
@@ -128,6 +129,7 @@ export class SnapshotService {
|
|||||||
from provider_requests order by created_at desc limit 100
|
from provider_requests order by created_at desc limit 100
|
||||||
`)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]),
|
`)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]),
|
||||||
providerRateLimits,
|
providerRateLimits,
|
||||||
|
usageStats: buildUsageStats(this.db, new Date()),
|
||||||
providerRateLimitObservations,
|
providerRateLimitObservations,
|
||||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
||||||
socialAccounts: list(this.db, "social_accounts", "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": "已被取代",
|
"superseded": "已被取代",
|
||||||
"stalled": "已停滞",
|
"stalled": "已停滞",
|
||||||
"Resume": "恢复",
|
"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 this month": "本月已花费",
|
||||||
"${spent} of ${allocated} allocated to agents": "已花 ${spent} / 分配给 Agent 的 ${allocated}",
|
"${spent} of ${allocated} allocated to agents": "已花 ${spent} / 分配给 Agent 的 ${allocated}",
|
||||||
"{count} calls not priced": "{count} 次调用未计价",
|
"{count} calls not priced": "{count} 次调用未计价",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
import { NewTaskDialog } from "./NewTaskDialog";
|
import { NewTaskDialog } from "./NewTaskDialog";
|
||||||
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
|
import { AgentWorkloadChart, CostTrendChart, RunActivityChart, SuccessRateChart } from "./home/DashboardCharts";
|
||||||
import type { PageProps } from "./types";
|
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";
|
import { t } from "../i18n/i18n";
|
||||||
|
|
||||||
const STRIP: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 16, marginBottom: 16 };
|
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 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 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 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 [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||||
|
|
||||||
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
|
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
|
||||||
@@ -69,9 +69,9 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
|
|||||||
|
|
||||||
<div style={METRICS}>
|
<div style={METRICS}>
|
||||||
<AgentWorkloadChart agents={agents} tasks={tasks} />
|
<AgentWorkloadChart agents={agents} tasks={tasks} />
|
||||||
<RunActivityChart taskRuns={taskRuns} />
|
<RunActivityChart days={usageStats.days} />
|
||||||
<SuccessRateChart taskRuns={taskRuns} />
|
<SuccessRateChart days={usageStats.days} />
|
||||||
<CostTrendChart providerRequests={providerRequests} />
|
<CostTrendChart days={usageStats.days} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={ACTIVITY}>
|
<div style={ACTIVITY}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AdvancedPanel } from "./settings/AdvancedPanel";
|
|||||||
import { IntegrationKeysPanel } from "./settings/IntegrationKeysPanel";
|
import { IntegrationKeysPanel } from "./settings/IntegrationKeysPanel";
|
||||||
import { t } from "../i18n/i18n";
|
import { t } from "../i18n/i18n";
|
||||||
import type { PageProps } from "./types";
|
import type { PageProps } from "./types";
|
||||||
import { activeOrgId, requireActiveEmployee, requireActiveOrganization } from "./types";
|
import { activeOrgId, requireActiveEmployee, requireActiveOrganization, requireUsageStats } from "./types";
|
||||||
|
|
||||||
type SettingsTab = "general" | "providers" | "integrations" | "advanced";
|
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 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 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 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 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 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]);
|
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}
|
workspaces={workspaces}
|
||||||
providerRequests={providerRequests}
|
providerRequests={providerRequests}
|
||||||
providerRateLimits={providerRateLimits}
|
providerRateLimits={providerRateLimits}
|
||||||
|
usage={usage}
|
||||||
hiringPolicies={hiringPolicies}
|
hiringPolicies={hiringPolicies}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
|
||||||
import { useState, type ReactNode } from "react";
|
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 { TASK_STATUS } from "../../../../shared/domain";
|
||||||
import { t } from "../../i18n/i18n";
|
import { t } from "../../i18n/i18n";
|
||||||
|
|
||||||
@@ -60,13 +61,13 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
export function RunActivityChart({ days }: { days: UsageDays }) {
|
||||||
const buckets = last7DayBuckets(taskRuns.map((r) => r.createdAt));
|
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 total = buckets.reduce((s, b) => s + b.count, 0);
|
||||||
const max = Math.max(1, ...buckets.map((b) => b.count));
|
const max = Math.max(1, ...buckets.map((b) => b.count));
|
||||||
const barW = (CHART_W - 6 * BAR_GAP) / 7;
|
const barW = (CHART_W - 6 * BAR_GAP) / 7;
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
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 (
|
return (
|
||||||
<ChartCard title={t("Run activity")} readout={readout}>
|
<ChartCard title={t("Run activity")} readout={readout}>
|
||||||
@@ -96,16 +97,16 @@ export function RunActivityChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) {
|
export function SuccessRateChart({ days }: { days: UsageDays }) {
|
||||||
const completed = taskRuns.filter((r) => r.state === "completed").length;
|
const completed = days.reduce((sum, day) => sum + day.completedRuns, 0);
|
||||||
const failed = taskRuns.filter((r) => r.state === "failed").length;
|
const failed = days.reduce((sum, day) => sum + day.failedRuns, 0);
|
||||||
const total = completed + failed;
|
const total = completed + failed;
|
||||||
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
const circumference = 2 * Math.PI * 42;
|
const circumference = 2 * Math.PI * 42;
|
||||||
const offset = circumference - (rate / 100) * circumference;
|
const offset = circumference - (rate / 100) * circumference;
|
||||||
|
|
||||||
return (
|
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" }}>
|
<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 cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
|
||||||
<circle
|
<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. */
|
/** 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 }> }) {
|
export function CostTrendChart({ days }: { days: UsageDays }) {
|
||||||
const buckets = last7DayBuckets(providerRequests.map((r) => r.createdAt));
|
const tokenBuckets = days.map((day) => ({ label: weekdayFormat.format(new Date(day.dayStart)), tokens: day.tokens }));
|
||||||
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 };
|
|
||||||
});
|
|
||||||
const totalTokens = tokenBuckets.reduce((s, b) => s + b.tokens, 0);
|
const totalTokens = tokenBuckets.reduce((s, b) => s + b.tokens, 0);
|
||||||
// Same seven calendar days as the chart.
|
const unreported = days.reduce((sum, day) => sum + day.callsWithoutUsage, 0);
|
||||||
const unreported = providerRequests.filter((r) => r.inputTokens === null && new Date(r.createdAt).getTime() >= dayStartMs(0)).length;
|
|
||||||
const max = Math.max(1, ...tokenBuckets.map((b) => b.tokens));
|
const max = Math.max(1, ...tokenBuckets.map((b) => b.tokens));
|
||||||
const coords = tokenBuckets.map((b, i) => ({
|
const coords = tokenBuckets.map((b, i) => ({
|
||||||
x: (i / 6) * (CHART_W - 10) + 5,
|
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 points = coords.map((c) => `${c.x},${c.y}`).join(" ");
|
||||||
const [hover, setHover] = useState<number | null>(null);
|
const [hover, setHover] = useState<number | null>(null);
|
||||||
const week = unreported > 0
|
const week = unreported > 0
|
||||||
? t("{n} tokens this week · {count} calls without usage", { n: compact(totalTokens), count: unreported })
|
? t("{n} tokens in the last 7 days · {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", { n: compact(totalTokens) });
|
||||||
const readout = hover !== null && tokenBuckets[hover]
|
const readout = hover !== null && tokenBuckets[hover]
|
||||||
? `${tokenBuckets[hover]!.label} · ${compact(tokenBuckets[hover]!.tokens)}`
|
? `${tokenBuckets[hover]!.label} · ${compact(tokenBuckets[hover]!.tokens)}`
|
||||||
: week;
|
: week;
|
||||||
@@ -173,21 +165,4 @@ function compact(count: number): string {
|
|||||||
|
|
||||||
const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||||
|
|
||||||
function last7DayBuckets(dates: string[]): Array<{ label: string; count: number }> {
|
type UsageDays = UsageStatsRecord["days"];
|
||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
|
||||||
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
|
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
|
||||||
import type {
|
import type {
|
||||||
AgentRecord,
|
AgentRecord,
|
||||||
@@ -21,6 +22,7 @@ export function AdvancedPanel({
|
|||||||
workspaces,
|
workspaces,
|
||||||
providerRequests,
|
providerRequests,
|
||||||
providerRateLimits,
|
providerRateLimits,
|
||||||
|
usage,
|
||||||
hiringPolicies,
|
hiringPolicies,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
@@ -31,6 +33,7 @@ export function AdvancedPanel({
|
|||||||
workspaces: WorkspaceRecord[];
|
workspaces: WorkspaceRecord[];
|
||||||
providerRequests: ProviderRequestRecord[];
|
providerRequests: ProviderRequestRecord[];
|
||||||
providerRateLimits: ProviderRateLimitStateRecord[];
|
providerRateLimits: ProviderRateLimitStateRecord[];
|
||||||
|
usage: UsageStatsRecord;
|
||||||
hiringPolicies: HiringPolicyRecord[];
|
hiringPolicies: HiringPolicyRecord[];
|
||||||
actions: SnapshotActions;
|
actions: SnapshotActions;
|
||||||
}) {
|
}) {
|
||||||
@@ -53,7 +56,7 @@ export function AdvancedPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ marginTop: 28 }}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Avatar, Pill } from "../../shared/ui";
|
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 { money } from "../types";
|
||||||
import { buildAgentSpend } from "./billing-helpers";
|
import { buildAgentSpend } from "./billing-helpers";
|
||||||
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
|
import type { ProviderRateLimitStateRecord } from "../../../../shared/provider-runtime-domain";
|
||||||
|
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
|
||||||
import { t } from "../../i18n/i18n";
|
import { t } from "../../i18n/i18n";
|
||||||
|
|
||||||
export function BillingPanel({
|
export function BillingPanel({
|
||||||
agents,
|
agents,
|
||||||
providers,
|
providers,
|
||||||
providerRequests,
|
usage,
|
||||||
providerRateLimits,
|
providerRateLimits,
|
||||||
hiringPolicies,
|
hiringPolicies,
|
||||||
}: {
|
}: {
|
||||||
agents: AgentRecord[];
|
agents: AgentRecord[];
|
||||||
providers: ProviderRecord[];
|
providers: ProviderRecord[];
|
||||||
providerRequests: ProviderRequestRecord[];
|
/** Last 30 days over every call, computed in the main process. */
|
||||||
|
usage: UsageStatsRecord;
|
||||||
providerRateLimits: ProviderRateLimitStateRecord[];
|
providerRateLimits: ProviderRateLimitStateRecord[];
|
||||||
hiringPolicies: HiringPolicyRecord[];
|
hiringPolicies: HiringPolicyRecord[];
|
||||||
}) {
|
}) {
|
||||||
@@ -23,15 +25,8 @@ export function BillingPanel({
|
|||||||
const unpricedCalls = providerRateLimits.reduce((s, usage) => s + usage.unpricedRequests + usage.requestsWithoutUsage, 0);
|
const unpricedCalls = providerRateLimits.reduce((s, usage) => s + usage.unpricedRequests + usage.requestsWithoutUsage, 0);
|
||||||
const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0);
|
const totalBudget = agents.reduce((sum, a) => sum + a.budgetCents, 0);
|
||||||
const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0);
|
const totalCap = hiringPolicies.reduce((sum, p) => sum + p.monthlyBudgetLimitCents, 0);
|
||||||
const monthRequests = providerRequests.filter((r) => withinDays(r.createdAt, 30));
|
const { inputTokens, cachedInputTokens: cachedTokens, outputTokens, reasoningTokens, callsWithoutUsage: unreported } = usage.month;
|
||||||
const sum = (pick: (request: ProviderRequestRecord) => number | null) => monthRequests.reduce((s, r) => s + (pick(r) ?? 0), 0);
|
const agentSpend = buildAgentSpend(agents, usage.agentSpend);
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -40,8 +35,8 @@ export function BillingPanel({
|
|||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, minmax(0, 1fr))", gap: 12, marginBottom: 20 }}>
|
<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("Monthly allocation")} value={money(totalBudget)} />
|
||||||
<StatTile label={t("Budget cap")} value={totalCap > 0 ? money(totalCap) : "—"} />
|
<StatTile label={t("Budget cap")} value={totalCap > 0 ? money(totalCap) : "—"} />
|
||||||
<StatTile label={t("Requests (30d)")} value={String(monthRequests.length)} />
|
<StatTile label={t("Requests (30d)")} value={String(usage.month.requests)} />
|
||||||
<StatTile label={t("Failures (30d)")} value={String(failedRequests.length)} tone={failedRequests.length > 0 ? "red" : undefined} />
|
<StatTile label={t("Failures (30d)")} value={String(usage.month.failures)} tone={usage.month.failures > 0 ? "red" : undefined} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, marginBottom: 20 }}>
|
<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>{t("Provider")}</div><div>{t("Requests")}</div><div>{t("Errors")}</div><div>{t("Agents")}</div>
|
||||||
</div>
|
</div>
|
||||||
{providers.map((provider, index) => {
|
{providers.map((provider, index) => {
|
||||||
const requests = monthRequests.filter((r) => r.providerId === provider.id);
|
const activity = usage.providerActivity.find((row) => row.providerId === provider.id);
|
||||||
const errors = requests.filter((r) => r.state === "failed").length;
|
const errors = activity?.failures ?? 0;
|
||||||
const boundAgents = agents.filter((a) => a.modelProviderId === provider.id).length;
|
const boundAgents = agents.filter((a) => a.modelProviderId === provider.id).length;
|
||||||
return (
|
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)" }}>
|
<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>
|
<div style={{ fontWeight: 540 }}>{provider.label}</div>
|
||||||
{provider.model && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.model}</div>}
|
{provider.model && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{provider.model}</div>}
|
||||||
</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>{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 className="num mono" style={{ color: "var(--text-2)" }}>{boundAgents}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +118,3 @@ function StatTile({ label, value, tone }: { label: string; value: string; tone?:
|
|||||||
</div>
|
</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 {
|
export interface AgentSpendRow {
|
||||||
agent: AgentRecord;
|
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. */
|
/** 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) => {
|
return agents.map((agent) => {
|
||||||
const agentRequests = requests.filter((r) => r.agentId === agent.id);
|
const entry = spend.find((row) => row.agentId === agent.id);
|
||||||
return {
|
return { agent, requestCount: entry?.requests ?? 0, tokens: entry?.tokens ?? 0, spentCents: entry?.costCents ?? 0 };
|
||||||
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),
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.spentCents - a.spentCents || b.requestCount - a.requestCount);
|
}).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 });
|
if (weeks < 5) return t("{n}w ago", { n: weeks });
|
||||||
return new Date(iso).toLocaleDateString();
|
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 { AuditReplayRunRecord } from "./audit-domain";
|
||||||
import type { IntegrationKeyStatus } from "./commands/integration-keys";
|
import type { IntegrationKeyStatus } from "./commands/integration-keys";
|
||||||
import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain";
|
import type { BoardInterventionRecord as BoardInterventionRecordModel } from "./board-domain";
|
||||||
@@ -102,6 +103,8 @@ export interface AppSnapshot {
|
|||||||
providerCatalog: ProviderCatalogEntry[];
|
providerCatalog: ProviderCatalogEntry[];
|
||||||
providerRequests: ProviderRequestRecord[];
|
providerRequests: ProviderRequestRecord[];
|
||||||
providerRateLimits: ProviderRateLimitStateRecord[];
|
providerRateLimits: ProviderRateLimitStateRecord[];
|
||||||
|
/** Period statistics per org over all records, for charts and billing. */
|
||||||
|
usageStats: UsageStatsRecord[];
|
||||||
providerRateLimitObservations: ProviderRateLimitObservationRecord[];
|
providerRateLimitObservations: ProviderRateLimitObservationRecord[];
|
||||||
// The snapshot is the renderer's working set and rides IPC on every
|
// The snapshot is the renderer's working set and rides IPC on every
|
||||||
// refresh. Skill internals (revisions, bindings, per-run runtime
|
// 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 { describe, expect, test } from "vitest";
|
||||||
import { buildAgentSpend } from "../../src/renderer/src/pages/settings/billing-helpers";
|
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 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", () => {
|
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")], [
|
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([
|
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