diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index fb1bad5e..ab09e6e8 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -122,7 +122,8 @@ export class SnapshotService { select id, org_id, provider_id, provider_key, workspace_id, model, prompt_sha256, response_text, state, error_message, input_char_count, output_char_count, execution_mode, policy_decision, external_response_id, stream_event_count, - cancelled_at, created_at, completed_at + cancelled_at, created_at, completed_at, agent_id, task_run_id, + input_tokens, output_tokens, cached_input_tokens, reasoning_tokens, cost_cents from provider_requests order by created_at desc limit 100 `)).map((row) => camel(row) as unknown as AppSnapshot["providerRequests"][number]), providerRateLimits, diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 9ccb52f5..2f7dcc68 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -32,6 +32,16 @@ export const zh: Record = { "superseded": "已被取代", "stalled": "已停滞", "Resume": "恢复", + "Tokens": "Token", + "not reported": "未报告", + "{input} in · {output} out": "输入 {input} · 输出 {output}", + "Cost": "费用", + "unpriced": "未定价", + "{n} tokens this week": "本周 {n} token", + "{n} tokens this week · {count} calls without usage": "本周 {n} token · {count} 次调用未报告用量", + "of which cached": "其中缓存", + "of which reasoning": "其中推理", + "Calls without usage": "未报告用量的调用", "Prices (US$ per million tokens)": "价格(美元 / 百万 token)", "Cached input": "缓存输入", "Unpriced: spend is not counted": "未定价:不计入花费", diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index c0f404a8..9075b684 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -106,7 +106,7 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions }: tasks: TaskRecord[]; agents: AgentRecord[]; providers: ProviderRecord[]; - providerRequests: Array<{ id: string; orgId: string; providerId: string; model: string; state: string; responseText: string | null; inputCharCount: number; outputCharCount: number; createdAt: string }>; + providerRequests: Array<{ id: string; orgId: string; providerId: string; model: string; state: string; responseText: string | null; inputCharCount: number; outputCharCount: number; createdAt: string; inputTokens: number | null; outputTokens: number | null; costCents: number | null }>; }) { const task = tasks.find((t) => t.id === run.taskId); const agent = agents.find((a) => a.id === run.agentId); @@ -169,6 +169,8 @@ function RunDetail({ run, tasks, agents, providers, providerRequests, actions }: {t("State")}
{request.state}
{t("Input chars")}
{request.inputCharCount.toLocaleString()}
{t("Output chars")}
{request.outputCharCount.toLocaleString()}
+ {t("Tokens")}
{request.inputTokens === null ? t("not reported") : t("{input} in · {output} out", { input: request.inputTokens.toLocaleString(), output: (request.outputTokens ?? 0).toLocaleString() })}
+ {t("Cost")}
{request.costCents === null ? t("unpriced") : `$${(request.costCents / 100).toFixed(4)}`}
{request.responseText && (
diff --git a/src/renderer/src/pages/home/DashboardCharts.tsx b/src/renderer/src/pages/home/DashboardCharts.tsx index e83759c5..18b767d3 100644 --- a/src/renderer/src/pages/home/DashboardCharts.tsx +++ b/src/renderer/src/pages/home/DashboardCharts.tsx @@ -120,28 +120,34 @@ export function SuccessRateChart({ taskRuns }: { taskRuns: TaskRunRecord[] }) { ); } -export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ createdAt: string; inputCharCount: number; outputCharCount: number }> }) { +/** 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 charBuckets = buckets.map((b, i) => { + const tokenBuckets = buckets.map((b, i) => { const dayStart = dayStartMs(i); const dayEnd = dayStart + 86400000; - const chars = providerRequests + const tokens = providerRequests .filter((r) => { const t = new Date(r.createdAt).getTime(); return t >= dayStart && t < dayEnd; }) - .reduce((s, r) => s + r.inputCharCount + r.outputCharCount, 0); - return { ...b, chars }; + .reduce((s, r) => s + (r.inputTokens ?? 0) + (r.outputTokens ?? 0), 0); + return { ...b, tokens }; }); - const totalChars = charBuckets.reduce((s, b) => s + b.chars, 0); - const max = Math.max(1, ...charBuckets.map((b) => b.chars)); - const coords = charBuckets.map((b, i) => ({ + 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 max = Math.max(1, ...tokenBuckets.map((b) => b.tokens)); + const coords = tokenBuckets.map((b, i) => ({ x: (i / 6) * (CHART_W - 10) + 5, - y: CHART_H - 20 - (b.chars / max) * (CHART_H - 30), + y: CHART_H - 20 - (b.tokens / max) * (CHART_H - 30), b, })); const points = coords.map((c) => `${c.x},${c.y}`).join(" "); const [hover, setHover] = useState(null); - const readout = hover !== null && charBuckets[hover] - ? `${charBuckets[hover]!.label} · ${kchars(charBuckets[hover]!.chars)}` - : t("{n} this week", { n: kchars(totalChars) }); + 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) }); + const readout = hover !== null && tokenBuckets[hover] + ? `${tokenBuckets[hover]!.label} · ${compact(tokenBuckets[hover]!.tokens)}` + : week; return ( @@ -159,9 +165,10 @@ export function CostTrendChart({ providerRequests }: { providerRequests: Array<{ ); } -function kchars(chars: number): string { - if (chars >= 1000) return `${(chars / 1000).toFixed(chars >= 10000 ? 0 : 1)}k`; - return String(chars); +function compact(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(count >= 10000 ? 0 : 1)}k`; + return String(count); } const weekdayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short" }); diff --git a/src/renderer/src/pages/settings/BillingPanel.tsx b/src/renderer/src/pages/settings/BillingPanel.tsx index eb2aa713..d01a3eb6 100644 --- a/src/renderer/src/pages/settings/BillingPanel.tsx +++ b/src/renderer/src/pages/settings/BillingPanel.tsx @@ -17,8 +17,12 @@ export function BillingPanel({ 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 totalInputChars = monthRequests.reduce((s, r) => s + r.inputCharCount, 0); - const totalOutputChars = monthRequests.reduce((s, r) => s + r.outputCharCount, 0); + 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, providers, monthRequests); @@ -37,9 +41,11 @@ export function BillingPanel({
{t("Token usage (30d)")}
- {t("Input chars")}{totalInputChars.toLocaleString()} - {t("Output chars")}{totalOutputChars.toLocaleString()} - {t("Total")}{(totalInputChars + totalOutputChars).toLocaleString()} + {t("Input")}{inputTokens.toLocaleString()} + {t("of which cached")}{cachedTokens.toLocaleString()} + {t("Output")}{outputTokens.toLocaleString()} + {t("of which reasoning")}{reasoningTokens.toLocaleString()} + {unreported > 0 && <>{t("Calls without usage")}{unreported}}
diff --git a/src/shared/provider-request-domain.ts b/src/shared/provider-request-domain.ts index ce576f48..ed9e09de 100644 --- a/src/shared/provider-request-domain.ts +++ b/src/shared/provider-request-domain.ts @@ -35,6 +35,15 @@ export interface ProviderRequestRecord { cancelledAt: string | null; createdAt: string; completedAt: string | null; + agentId: string | null; + taskRunId: string | null; + /** Provider-reported token usage; null when the provider reported none. */ + inputTokens: number | null; + outputTokens: number | null; + cachedInputTokens: number | null; + reasoningTokens: number | null; + /** Null when the provider has no prices or reported no usage. */ + costCents: number | null; } export interface ProviderStreamRendererEvent {