From eb0be43eacdb17c555586fbb1dc8c0c3ef066f12 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sun, 19 Jul 2026 01:19:59 +0800 Subject: [PATCH] feat(hub): usage fact breakdown API + admin usage/session UI + release v0.0.34 (#10) Expose UsageFact kind/capability rollups on org and project usage reports, and add admin pages that separate model tokens from external-capability meters. Co-authored-by: Hong Jiarong Co-committed-by: Hong Jiarong --- hub/admin-web/src/lib/api.ts | 80 ++++ hub/admin-web/src/lib/format.ts | 46 ++ hub/admin-web/src/routes/+layout.svelte | 3 +- .../src/routes/admin/org/[slug]/+page.svelte | 47 +- .../[slug]/projects/[projectId]/+page.svelte | 85 +++- .../[slug]/sessions/[sessionId]/+page.svelte | 235 ++++++++++ .../admin/org/[slug]/usage/+page.svelte | 238 ++++++++++ hub/package-lock.json | 4 +- hub/package.json | 2 +- hub/src/org/usage.ts | 409 ++++++++++++------ hub/test/integration/usage.test.ts | 32 +- 11 files changed, 1047 insertions(+), 134 deletions(-) create mode 100644 hub/admin-web/src/routes/admin/org/[slug]/sessions/[sessionId]/+page.svelte create mode 100644 hub/admin-web/src/routes/admin/org/[slug]/usage/+page.svelte diff --git a/hub/admin-web/src/lib/api.ts b/hub/admin-web/src/lib/api.ts index c98cc39..87d65c3 100644 --- a/hub/admin-web/src/lib/api.ts +++ b/hub/admin-web/src/lib/api.ts @@ -193,13 +193,81 @@ export interface ProjectUsageRow extends UsageTotals { folderId: string | null; } +/** Ledger slice from UsageFact (ADR-0026): separates model tokens vs external meters. */ +export interface UsageBreakdownRow { + kind: string; + provider: string; + model: string | null; + capabilityId: string | null; + unit: string | null; + factCount: number; + factsWithCost: number; + factsWithoutCost: number; + inputTokens: number; + outputTokens: number; + quantity: number | null; + costUsd: number | null; +} + export interface UsageReport { from: string | null; to: string | null; projects: ProjectUsageRow[]; totals: UsageTotals; + breakdown: UsageBreakdownRow[]; } +export interface ProjectUsageReport extends ProjectUsageRow { + from: string | null; + to: string | null; + breakdown: UsageBreakdownRow[]; +} + +export interface UsageFactRow { + id: string; + occurredAt: string; + kind: string; + provider: string; + model: string | null; + inputTokens: number | null; + outputTokens: number | null; + quantity: number | null; + unit: string | null; + costUsd: number | null; + costSource: string; + capabilityId: string | null; + correlationId: string | null; +} + +export interface SessionRunRow { + id: string; + status: string; + model: string; + provider: string; + inputTokens: number | null; + outputTokens: number | null; + costUsd: number | null; + costSource: string | null; + startedAt: string; + finishedAt: string | null; + error: string | null; + usageFacts: UsageFactRow[]; +} + +export interface SessionDetail { + id: string; + provider: string; + roleId: string; + model: string; + title: string | null; + createdAt: string; + updatedAt: string; + archivedAt: string | null; + project: { id: string; name: string }; + runs: SessionRunRow[]; +} + + export type CapacityDimension = | 'requestRate' | 'requestBodySize' @@ -353,6 +421,8 @@ export const api = { get(`${orgBase(slug)}/projects/${projectId}/sessions${limit !== undefined ? `?limit=${limit}` : ''}`) as Promise<{ sessions: SessionSummary[]; }>, + session: (slug: string, sessionId: string) => + get(`${orgBase(slug)}/sessions/${encodeURIComponent(sessionId)}`) as Promise, usage: (slug: string, params?: { from?: string; to?: string; folderId?: string }) => { const q = new URLSearchParams(); if (params?.from) q.set('from', params.from); @@ -361,6 +431,16 @@ export const api = { const qs = q.toString(); return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise; }, + projectUsage: (slug: string, projectId: string, params?: { from?: string; to?: string }) => { + const q = new URLSearchParams(); + if (params?.from) q.set('from', params.from); + if (params?.to) q.set('to', params.to); + const qs = q.toString(); + return get( + `${orgBase(slug)}/projects/${encodeURIComponent(projectId)}/usage${qs ? `?${qs}` : ''}`, + ) as Promise; + }, + providerConnections: (slug: string) => get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>, diff --git a/hub/admin-web/src/lib/format.ts b/hub/admin-web/src/lib/format.ts index 07391b5..03ffcc6 100644 --- a/hub/admin-web/src/lib/format.ts +++ b/hub/admin-web/src/lib/format.ts @@ -29,6 +29,52 @@ export function fmtNum(n: number): string { return n.toLocaleString(); } +const USAGE_KIND_LABELS: Record = { + model_completion: '模型完成', + external_capability: '外部能力', + tool_proxy: '工具代理', +}; + +const METER_UNIT_LABELS: Record = { + pages: '页', + audio_seconds: '音频秒', + invocations: '次调用', +}; + +export function usageKindLabel(kind: string): string { + return USAGE_KIND_LABELS[kind] ?? kind; +} + +export function meterUnitLabel(unit: string | null | undefined): string { + if (!unit) return '—'; + return METER_UNIT_LABELS[unit] ?? unit; +} + +export function fmtQuantity(quantity: number | null | undefined, unit: string | null | undefined): string { + if (quantity === null || quantity === undefined) return '—'; + const u = meterUnitLabel(unit); + return u === '—' ? fmtNum(quantity) : `${fmtNum(quantity)} ${u}`; +} + +export function fmtTokens(input: number | null | undefined, output: number | null | undefined): string { + const hasIn = input !== null && input !== undefined; + const hasOut = output !== null && output !== undefined; + if (!hasIn && !hasOut) return '—'; + return `${fmtNum(input ?? 0)} / ${fmtNum(output ?? 0)}`; +} + +export function runStatusLabel(status: string): string { + const key = status.toUpperCase(); + if (key === 'COMPLETED') return '完成'; + if (key === 'FAILED') return '失败'; + if (key === 'CANCELED') return '取消'; + if (key === 'TIMED_OUT') return '超时'; + if (key === 'RUNNING') return '运行中'; + if (key === 'QUEUED') return '排队'; + return status; +} + + export function orgRoleLabel(role: string): string { const key = role.toUpperCase() as OrgRole; return ORG_ROLE_LABELS[key] ?? role; diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index 6858858..c78b8bf 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -20,6 +20,7 @@ const navItems = [ { key: 'overview', label: '概览', icon: 'overview' as const }, + { key: 'usage', label: '用量', icon: 'overview' as const }, { key: 'members', label: '成员', icon: 'members' as const }, { key: 'teams', label: '团队', icon: 'teams' as const }, { key: 'projects', label: '项目', icon: 'projects' as const }, @@ -81,10 +82,10 @@ if (key === 'overview') return `/admin/org/${slug}`; return `/admin/org/${slug}/${key}`; } - function pageTitle(): string { const key = activeKey(); if (key === 'overview' || key === '') return '概览'; + if (key === 'sessions') return '会话详情'; return navItems.find((i) => i.key === key)?.label ?? '管理后台'; } diff --git a/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte index 2231397..30570e5 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte @@ -94,19 +94,56 @@

用量概览

-

全组织智能体运行汇总

+

totals 含全部 UsageFact;分账明细见用量页。

+ 完整用量报告
- +
+ {#if usage.breakdown.length > 0} +
+
+
+

消费来源(Top)

+

模型完成 vs 外部能力,按成本降序前 5

+
+ 查看全部分账 +
+
+ + + + + + + + + + + + {#each [...usage.breakdown].sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)).slice(0, 5) as row} + + + + + + + + {/each} + +
类型供应方模型 / 能力次数成本
{row.kind === 'external_capability' ? '外部能力' : row.kind === 'model_completion' ? '模型完成' : row.kind}{row.provider}{row.capabilityId ?? row.model ?? '—'}{fmtNum(row.factCount)}{fmtCost(row.costUsd)}
+
+
+ {/if} +

按项目用量

@@ -129,7 +166,11 @@ {#each usage.projects as p} - {p.projectName} + + + {p.projectName} + + {fmtNum(p.runCount)} {fmtNum(p.inputTokens)} / {fmtNum(p.outputTokens)} {fmtCost(p.costUsd)} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte index 8ab28ba..3cfb1aa 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte +++ b/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte @@ -7,8 +7,17 @@ type TeamRow, type SessionSummary, type ExplorerData, + type ProjectUsageReport, } from '$lib/api'; - import { fmtDate, permissionRoleLabel } from '$lib/format'; + import { + fmtCost, + fmtDate, + fmtNum, + fmtQuantity, + fmtTokens, + permissionRoleLabel, + usageKindLabel, + } from '$lib/format'; import { PERMISSION_ROLES, PERMISSION_ROLE_LABELS } from '$lib/constants'; import PageHeader from '$lib/components/PageHeader.svelte'; import LoadingState from '$lib/components/LoadingState.svelte'; @@ -26,6 +35,7 @@ let proj = $state(null); let access = $state([]); let sessions = $state([]); + let projectUsage = $state(null); let teams = $state([]); let explorer = $state(null); let loading = $state(true); @@ -49,14 +59,18 @@ // Team list is needed for grant UI whenever the actor has project MANAGE // (org admin or member). Sessions/explorer stay org-admin oversight only. const needTeams = p.actorIsOrgAdmin === true || p.actorCanManageProject === true; - const [s, t, e] = await Promise.all([ + const [s, t, e, u] = await Promise.all([ p.actorIsOrgAdmin ? api.sessions(slug, projectId) : Promise.resolve({ sessions: [] as SessionSummary[] }), needTeams ? api.teams(slug) : Promise.resolve({ teams: [] as TeamRow[] }), p.actorIsOrgAdmin ? api.explorer(slug) : Promise.resolve(null as ExplorerData | null), + p.actorIsOrgAdmin + ? api.projectUsage(slug, projectId) + : Promise.resolve(null as ProjectUsageReport | null), ]); sessions = s.sessions; teams = t.teams; explorer = e; + projectUsage = u; if (p.actorIsOrgAdmin) { moveFolder = p.folderId ?? ''; } @@ -269,9 +283,67 @@
{#if actorIsOrgAdmin} + {#if projectUsage} +
+
+
+

项目用量分账

+

+ {fmtNum(projectUsage.runCount)} 次运行 · 成本 {fmtCost(projectUsage.costUsd)} · tokens + {fmtTokens(projectUsage.inputTokens, projectUsage.outputTokens)} +

+
+ 组织报告 +
+ {#if projectUsage.breakdown.length === 0} +
尚无 UsageFact。
+ {:else} +
+ + + + + + + + + + + + + {#each projectUsage.breakdown as row} + + + + + + + + + {/each} + +
类型供应方模型 / 能力次数计量成本
+ + {usageKindLabel(row.kind)} + + {row.provider}{row.capabilityId ?? row.model ?? '—'}{fmtNum(row.factCount)} + {#if row.unit} + {fmtQuantity(row.quantity, row.unit)} + {:else} + {fmtTokens(row.inputTokens, row.outputTokens)} + {/if} + {fmtCost(row.costUsd)}
+
+ {/if} +
+ {/if} +

智能体会话

+

点进会话可查看每次 run 的 UsageFact 分账(模型 / 外部能力)。

{#if sessions.length === 0} @@ -283,6 +355,7 @@ 模型 运行次数 更新 + @@ -292,6 +365,14 @@ {s.model} {s.runCount} {fmtDate(s.updatedAt)} + + + 详情 + + {/each} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/sessions/[sessionId]/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/sessions/[sessionId]/+page.svelte new file mode 100644 index 0000000..e521d1e --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/sessions/[sessionId]/+page.svelte @@ -0,0 +1,235 @@ + + +{#if loading} + +{:else if error} + +{:else if detail} + + + + +
+
+
+
会话 ID
+
{detail.id}
+
+ +
+
创建 / 更新
+
{fmtDate(detail.createdAt)} · {fmtDate(detail.updatedAt)}
+
+ {#if detail.archivedAt} +
+
已归档
+
{fmtDate(detail.archivedAt)}
+
+ {/if} +
+
运行数
+
{fmtNum(detail.runs.length)}
+
+
+
+ +
+

运行与计费事实

+

每条 UsageFact 是一次可计费消费;外部能力与模型完成分开列出。

+
+ + {#if detail.runs.length === 0} +
+ +
+ {:else} +
+ {#each detail.runs as run (run.id)} + {@const open = expandedRunId === run.id} +
+ + + {#if open} +
+ {#if run.usageFacts.length === 0} +
+

此 run 没有 UsageFact(可能尚未结束或未记费)。

+
+ {:else} +
+ + + + + + + + + + + + + + + {#each run.usageFacts as fact} + + + + + + + + + + + {/each} + +
时间类型供应方模型 / 能力计量成本来源关联 ID
{fmtDate(fact.occurredAt)} + + {usageKindLabel(fact.kind)} + + {fact.provider}{factSource(fact)}{factMeter(fact)}{fmtCost(fact.costUsd)}{fact.costSource} + {fact.correlationId ?? '—'} +
+
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +{/if} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/usage/+page.svelte b/hub/admin-web/src/routes/admin/org/[slug]/usage/+page.svelte new file mode 100644 index 0000000..f90b4cf --- /dev/null +++ b/hub/admin-web/src/routes/admin/org/[slug]/usage/+page.svelte @@ -0,0 +1,238 @@ + + +{#if loading && !usage} + +{:else if error && !usage} + +{:else if usage} + + +
+
+
+ + +
+
+ + +
+ + + {#if usage.from || usage.to} +

+ 窗口: + {usage.from ? fmtDateOnly(usage.from) : '—'} + → + {usage.to ? fmtDateOnly(usage.to) : '—'} +

+ {/if} +
+ {#if error} +

{error}

+ {/if} +
+ +
+ + + + + + n + b.factCount, 0))} + hint={`${fmtNum(usage.breakdown.length)} 个分项`} + /> +
+ +
+
+

按来源分账

+

+ kind × provider × model/capability。外部能力显示页数/秒等计量,不与 tokens 混排。 +

+
+ {#if usage.breakdown.length === 0} + + {:else} +
+ + + + + + + + + + + + + + {#each usage.breakdown as row} + + + + + + + + + + {/each} + +
类型供应方模型 / 能力次数计量有成本 / 未知成本
+ + {usageKindLabel(row.kind)} + + {row.provider}{sourceLabel(row)}{fmtNum(row.factCount)} +
{meterCell(row)}
+
{meterHint(row)}
+
+ {fmtNum(row.factsWithCost)} / {fmtNum(row.factsWithoutCost)} + {fmtCost(row.costUsd)}
+
+ {/if} +
+ +
+
+

按项目

+

项目仍是权限边界;行内成本已含该项目全部 fact 类型。

+
+ {#if usage.projects.length === 0} + + {:else} +
+ + + + + + + + + + + + + {#each usage.projects as p} + + + + + + + + + {/each} + +
项目运行有成本 / 未知in / out tokens成本
{p.projectName}{fmtNum(p.runCount)} + {fmtNum(p.runsWithCost)} / {fmtNum(p.runsWithoutCost)} + {fmtTokens(p.inputTokens, p.outputTokens)}{fmtCost(p.costUsd)} + + 查看项目 + +
+
+ {/if} +
+{/if} diff --git a/hub/package-lock.json b/hub/package-lock.json index 748b2ae..360c489 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -1,12 +1,12 @@ { "name": "@paradigm/hub", - "version": "0.0.31", + "version": "0.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@paradigm/hub", - "version": "0.0.31", + "version": "0.0.34", "dependencies": { "@alicloud/credentials": "^2.4.5", "@alicloud/docmind-api20220711": "^1.4.15", diff --git a/hub/package.json b/hub/package.json index 308b60d..893ece7 100644 --- a/hub/package.json +++ b/hub/package.json @@ -1,6 +1,6 @@ { "name": "@paradigm/hub", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "engines": { diff --git a/hub/src/org/usage.ts b/hub/src/org/usage.ts index 41f2b5e..6885a9b 100644 --- a/hub/src/org/usage.ts +++ b/hub/src/org/usage.ts @@ -9,13 +9,14 @@ * external-capability consumption (PDF→MD, ASR, …) is attributed correctly, * not just the main model loop. A run with no cost-bearing fact is * `runsWithoutCost` — ADR-0022: missing cost ≠ zero. + * + * Breakdown buckets keep kind / provider / model / capability / unit, so the + * admin UI can separate model tokens from non-token external meters instead of + * collapsing everything into a single input/output token total. */ import type { Prisma, PrismaClient } from "@prisma/client"; -export interface ProjectUsageRow { - readonly projectId: string; - readonly projectName: string; - readonly folderId: string | null; +export interface UsageTotals { readonly runCount: number; readonly runsWithCost: number; readonly runsWithoutCost: number; @@ -24,21 +25,50 @@ export interface ProjectUsageRow { readonly costUsd: number | null; } +export interface ProjectUsageRow extends UsageTotals { + readonly projectId: string; + readonly projectName: string; + readonly folderId: string | null; +} + +/** One ledger slice: kind + provider + model + capability + unit. */ +export interface UsageBreakdownRow { + readonly kind: string; + readonly provider: string; + readonly model: string | null; + readonly capabilityId: string | null; + readonly unit: string | null; + readonly factCount: number; + readonly factsWithCost: number; + readonly factsWithoutCost: number; + readonly inputTokens: number; + readonly outputTokens: number; + /** Sum of quantity when unit is non-null; null when this bucket is token-only. */ + readonly quantity: number | null; + readonly costUsd: number | null; +} + export interface UsageReport { readonly from: string | null; readonly to: string | null; readonly projects: readonly ProjectUsageRow[]; - readonly totals: { - readonly runCount: number; - readonly runsWithCost: number; - readonly runsWithoutCost: number; - readonly inputTokens: number; - readonly outputTokens: number; - readonly costUsd: number | null; - }; + readonly totals: UsageTotals; + readonly breakdown: readonly UsageBreakdownRow[]; +} + +export interface ProjectUsageReport extends ProjectUsageRow { + readonly from: string | null; + readonly to: string | null; + readonly breakdown: readonly UsageBreakdownRow[]; } type FactRow = { + readonly kind: string; + readonly provider: string; + readonly model: string | null; + readonly capabilityId: string | null; + readonly unit: string | null; + readonly quantity: unknown; readonly inputTokens: number | null; readonly outputTokens: number | null; readonly costUsd: unknown; @@ -49,26 +79,91 @@ type RunWithFacts = { readonly usageFacts: readonly FactRow[]; }; -/** A run is "recorded with cost" if any of its facts carries a known costUsd. */ -function runHasRecordedCost(facts: readonly FactRow[]): boolean { - return facts.some((f) => f.costUsd !== null && f.costUsd !== undefined); -} +type MutableTotals = { + runCount: number; + runsWithCost: number; + runsWithoutCost: number; + inputTokens: number; + outputTokens: number; + costUsd: number | null; +}; + +type MutableBreakdown = { + kind: string; + provider: string; + model: string | null; + capabilityId: string | null; + unit: string | null; + factCount: number; + factsWithCost: number; + factsWithoutCost: number; + inputTokens: number; + outputTokens: number; + quantity: number | null; + costUsd: number | null; +}; + +type MutableProjectRow = MutableTotals & { + readonly projectId: string; + readonly projectName: string; + readonly folderId: string | null; +}; + +const FACT_SELECT = { + kind: true, + provider: true, + model: true, + capabilityId: true, + unit: true, + quantity: true, + inputTokens: true, + outputTokens: true, + costUsd: true, +} as const; -/** Decimal→number for Prisma Decimal; null/undefined → null. */ function factCostUsdToNumber(value: unknown): number | null { if (value === null || value === undefined) return null; const n = typeof value === "number" ? value : Number(value); return Number.isFinite(n) ? n : null; } -interface RunRollup { +function factQuantityToNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : null; +} + +function breakdownKey(f: FactRow): string { + return [ + f.kind, + f.provider, + f.model ?? "", + f.capabilityId ?? "", + f.unit ?? "", + ].join("\u0000"); +} + +function emptyMutableTotals(): MutableTotals { + return { + runCount: 0, + runsWithCost: 0, + runsWithoutCost: 0, + inputTokens: 0, + outputTokens: 0, + costUsd: null, + }; +} + +function addCost(existing: number | null, add: number): number { + return (existing ?? 0) + add; +} + +function rollupRunTokensAndCost(facts: readonly FactRow[]): { readonly hasCost: boolean; readonly inputTokens: number; readonly outputTokens: number; readonly costUsd: number | null; -} - -function rollupRun(facts: readonly FactRow[]): RunRollup { +} { let inputTokens = 0; let outputTokens = 0; let costUsd: number | null = null; @@ -79,12 +174,118 @@ function rollupRun(facts: readonly FactRow[]): RunRollup { const c = factCostUsdToNumber(f.costUsd); if (c !== null) { hasCost = true; - costUsd = (costUsd ?? 0) + c; + costUsd = addCost(costUsd, c); } } return { hasCost, inputTokens, outputTokens, costUsd }; } +function accumulateBreakdown( + buckets: Map, + facts: readonly FactRow[], +): void { + for (const f of facts) { + const key = breakdownKey(f); + let bucket = buckets.get(key); + if (bucket === undefined) { + bucket = { + kind: f.kind, + provider: f.provider, + model: f.model, + capabilityId: f.capabilityId, + unit: f.unit, + factCount: 0, + factsWithCost: 0, + factsWithoutCost: 0, + inputTokens: 0, + outputTokens: 0, + quantity: null, + costUsd: null, + }; + buckets.set(key, bucket); + } + bucket.factCount += 1; + bucket.inputTokens += f.inputTokens ?? 0; + bucket.outputTokens += f.outputTokens ?? 0; + const qty = factQuantityToNumber(f.quantity); + if (qty !== null) { + bucket.quantity = (bucket.quantity ?? 0) + qty; + } + const c = factCostUsdToNumber(f.costUsd); + if (c !== null) { + bucket.factsWithCost += 1; + bucket.costUsd = addCost(bucket.costUsd, c); + } else { + bucket.factsWithoutCost += 1; + } + } +} + +function freezeBreakdown(buckets: Map): UsageBreakdownRow[] { + return [...buckets.values()] + .map((b) => ({ + kind: b.kind, + provider: b.provider, + model: b.model, + capabilityId: b.capabilityId, + unit: b.unit, + factCount: b.factCount, + factsWithCost: b.factsWithCost, + factsWithoutCost: b.factsWithoutCost, + inputTokens: b.inputTokens, + outputTokens: b.outputTokens, + quantity: b.quantity, + costUsd: b.costUsd, + })) + .sort((a, b) => { + const kindCmp = a.kind.localeCompare(b.kind); + if (kindCmp !== 0) return kindCmp; + const provCmp = a.provider.localeCompare(b.provider); + if (provCmp !== 0) return provCmp; + const capA = a.capabilityId ?? ""; + const capB = b.capabilityId ?? ""; + const capCmp = capA.localeCompare(capB); + if (capCmp !== 0) return capCmp; + const modelA = a.model ?? ""; + const modelB = b.model ?? ""; + return modelA.localeCompare(modelB); + }); +} + +function freezeTotals(t: MutableTotals): UsageTotals { + return { + runCount: t.runCount, + runsWithCost: t.runsWithCost, + runsWithoutCost: t.runsWithoutCost, + inputTokens: t.inputTokens, + outputTokens: t.outputTokens, + costUsd: t.costUsd, + }; +} + +function applyRunToTotals(totals: MutableTotals, facts: readonly FactRow[]): void { + const roll = rollupRunTokensAndCost(facts); + totals.runCount += 1; + if (roll.hasCost) { + totals.runsWithCost += 1; + totals.costUsd = addCost(totals.costUsd, roll.costUsd ?? 0); + } else { + totals.runsWithoutCost += 1; + } + totals.inputTokens += roll.inputTokens; + totals.outputTokens += roll.outputTokens; +} + +function emptyReport(from?: Date, to?: Date): UsageReport { + return { + from: from?.toISOString() ?? null, + to: to?.toISOString() ?? null, + projects: [], + totals: freezeTotals(emptyMutableTotals()), + breakdown: [], + }; +} + export async function getOrgUsage( prisma: PrismaClient, input: { @@ -108,101 +309,65 @@ export async function getOrgUsage( } const projectIds = projects.map((p) => p.id); - const runWhere: Prisma.AgentRunWhereInput = { - projectId: { in: projectIds }, - ...(input.from !== undefined || input.to !== undefined - ? { - startedAt: { - ...(input.from !== undefined ? { gte: input.from } : {}), - ...(input.to !== undefined ? { lte: input.to } : {}), - }, - } - : {}), - }; - // Date range filters by run.startedAt, matching the pre-ADR-0026 semantics: // a run is in or out of the window based on when it started, and all of its // facts come along. Filtering facts by occurredAt independently would let a // run contribute partial cost to a window it doesn't belong to. const runs = await prisma.agentRun.findMany({ - where: runWhere, + where: { + projectId: { in: projectIds }, + ...(input.from !== undefined || input.to !== undefined + ? { + startedAt: { + ...(input.from !== undefined ? { gte: input.from } : {}), + ...(input.to !== undefined ? { lte: input.to } : {}), + }, + } + : {}), + }, select: { projectId: true, usageFacts: { - select: { inputTokens: true, outputTokens: true, costUsd: true }, + select: FACT_SELECT, orderBy: { occurredAt: "asc" }, }, }, }); - type MutableRow = { - readonly projectId: string; - readonly projectName: string; - readonly folderId: string | null; - runCount: number; - runsWithCost: number; - runsWithoutCost: number; - inputTokens: number; - outputTokens: number; - costUsd: number | null; - }; - - const byProject = new Map(); + const byProject = new Map(); for (const p of projects) { byProject.set(p.id, { projectId: p.id, projectName: p.name, folderId: p.folderId, - runCount: 0, - runsWithCost: 0, - runsWithoutCost: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: null, + ...emptyMutableTotals(), }); } + const breakdownBuckets = new Map(); for (const run of runs as readonly RunWithFacts[]) { const row = byProject.get(run.projectId); if (row === undefined) continue; - const roll = rollupRun(run.usageFacts); - row.runCount += 1; - if (roll.hasCost) { - row.runsWithCost += 1; - row.costUsd = (row.costUsd ?? 0) + (roll.costUsd ?? 0); - } else { - row.runsWithoutCost += 1; - } - row.inputTokens += roll.inputTokens; - row.outputTokens += roll.outputTokens; + applyRunToTotals(row, run.usageFacts); + accumulateBreakdown(breakdownBuckets, run.usageFacts); } const projectsOut: ProjectUsageRow[] = [...byProject.values()].map((r) => ({ projectId: r.projectId, projectName: r.projectName, folderId: r.folderId, - runCount: r.runCount, - runsWithCost: r.runsWithCost, - runsWithoutCost: r.runsWithoutCost, - inputTokens: r.inputTokens, - outputTokens: r.outputTokens, - costUsd: r.costUsd, + ...freezeTotals(r), })); - let runCount = 0; - let runsWithCost = 0; - let runsWithoutCost = 0; - let inputTokens = 0; - let outputTokens = 0; - let costUsd: number | null = null; + const totals = emptyMutableTotals(); for (const row of projectsOut) { - runCount += row.runCount; - runsWithCost += row.runsWithCost; - runsWithoutCost += row.runsWithoutCost; - inputTokens += row.inputTokens; - outputTokens += row.outputTokens; + totals.runCount += row.runCount; + totals.runsWithCost += row.runsWithCost; + totals.runsWithoutCost += row.runsWithoutCost; + totals.inputTokens += row.inputTokens; + totals.outputTokens += row.outputTokens; if (row.costUsd !== null) { - costUsd = (costUsd ?? 0) + row.costUsd; + totals.costUsd = addCost(totals.costUsd, row.costUsd); } } @@ -210,14 +375,8 @@ export async function getOrgUsage( from: input.from?.toISOString() ?? null, to: input.to?.toISOString() ?? null, projects: projectsOut, - totals: { - runCount, - runsWithCost, - runsWithoutCost, - inputTokens, - outputTokens, - costUsd, - }, + totals: freezeTotals(totals), + breakdown: freezeBreakdown(breakdownBuckets), }; } @@ -229,7 +388,7 @@ export async function getProjectUsage( readonly from?: Date | undefined; readonly to?: Date | undefined; }, -): Promise { +): Promise { const project = await prisma.project.findFirst({ where: { id: input.projectId, organizationId: input.organizationId }, select: { id: true, name: true, folderId: true }, @@ -237,39 +396,41 @@ export async function getProjectUsage( if (project === null) { throw new Error(`project not found: ${input.projectId}`); } - const report = await getOrgUsage(prisma, { - organizationId: input.organizationId, - from: input.from, - to: input.to, - }); - const row = report.projects.find((p) => p.projectId === project.id); - return ( - row ?? { - projectId: project.id, - projectName: project.name, - folderId: project.folderId, - runCount: 0, - runsWithCost: 0, - runsWithoutCost: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: null, - } - ); -} -function emptyReport(from?: Date, to?: Date): UsageReport { - return { - from: from?.toISOString() ?? null, - to: to?.toISOString() ?? null, - projects: [], - totals: { - runCount: 0, - runsWithCost: 0, - runsWithoutCost: 0, - inputTokens: 0, - outputTokens: 0, - costUsd: null, + const runs = await prisma.agentRun.findMany({ + where: { + projectId: project.id, + ...(input.from !== undefined || input.to !== undefined + ? { + startedAt: { + ...(input.from !== undefined ? { gte: input.from } : {}), + ...(input.to !== undefined ? { lte: input.to } : {}), + }, + } + : {}), }, + select: { + usageFacts: { + select: FACT_SELECT, + orderBy: { occurredAt: "asc" }, + }, + }, + }); + + const totals = emptyMutableTotals(); + const breakdownBuckets = new Map(); + for (const run of runs) { + applyRunToTotals(totals, run.usageFacts as readonly FactRow[]); + accumulateBreakdown(breakdownBuckets, run.usageFacts as readonly FactRow[]); + } + + return { + projectId: project.id, + projectName: project.name, + folderId: project.folderId, + ...freezeTotals(totals), + from: input.from?.toISOString() ?? null, + to: input.to?.toISOString() ?? null, + breakdown: freezeBreakdown(breakdownBuckets), }; } diff --git a/hub/test/integration/usage.test.ts b/hub/test/integration/usage.test.ts index 3e226de..8d95e02 100644 --- a/hub/test/integration/usage.test.ts +++ b/hub/test/integration/usage.test.ts @@ -71,7 +71,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => { occurredAt: new Date(startedAt.getTime() + i * 1000), kind: f.kind ?? "model_completion", provider: f.provider ?? "openrouter", - model: f.model ?? "mock-model", + model: f.model !== undefined ? f.model : "mock-model", inputTokens: f.inputTokens ?? null, outputTokens: f.outputTokens ?? null, costUsd: f.costUsd ?? null, @@ -158,6 +158,32 @@ describe("usage rollup from UsageFact (ADR-0026)", () => { expect(report.totals.inputTokens).toBe(100); expect(report.totals.outputTokens).toBe(50); expect(report.totals.costUsd).toBeCloseTo(0.035, 8); + // ADR-0026 breakdown: model tokens vs external non-token meter must not collapse. + expect(report.breakdown).toHaveLength(2); + const modelBucket = report.breakdown.find((b) => b.kind === "model_completion"); + const capBucket = report.breakdown.find((b) => b.kind === "external_capability"); + expect(modelBucket).toMatchObject({ + provider: "openrouter", + model: "mock-model", + capabilityId: null, + inputTokens: 100, + outputTokens: 50, + quantity: null, + costUsd: 0.02, + factCount: 1, + }); + expect(capBucket).toMatchObject({ + provider: "mineru", + model: null, + capabilityId: "pdf_to_md_bundle", + unit: "pages", + quantity: 12, + inputTokens: 0, + outputTokens: 0, + costUsd: 0.015, + factCount: 1, + }); + }); it("a run with no facts at all is runsWithoutCost and contributes nothing", async () => { @@ -180,6 +206,8 @@ describe("usage rollup from UsageFact (ADR-0026)", () => { expect(row.projectId).toBe("proj-x"); expect(row.runCount).toBe(1); expect(row.costUsd).toBeCloseTo(0.1, 8); + expect(row.breakdown).toHaveLength(1); + expect(row.breakdown[0]).toMatchObject({ kind: "model_completion", costUsd: 0.1, inputTokens: 1 }); }); it("returns an empty report for an org with no projects", async () => { @@ -187,5 +215,7 @@ describe("usage rollup from UsageFact (ADR-0026)", () => { expect(report.projects).toHaveLength(0); expect(report.totals.runCount).toBe(0); expect(report.totals.costUsd).toBeNull(); + expect(report.breakdown).toEqual([]); + }); });