Переглянути джерело

feat: 用量统计与概览页

- 新增 usage_daily 表:Run 结束时按(本地日期、来源、引擎、模型)累加运行次数、失败/中断、token、估算费用与时长;
  删除会话不影响历史用量;迁移用已有 Run 补齐
- 后台 /admin/v1/usage、/admin/v1/usage/errors;平台 /v1/usage 只返回自己的用量
- 概览页:今日与区间汇总、每日柱状图(运行次数 / Token / 时长,7/30/90 天)、按来源与按模型、最近的失败
- 文档:数据模型、API、路线图

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 тиждень тому
батько
коміт
d7a708f5c2

+ 86 - 0
apps/admin/src/components/UsageChart.vue

@@ -0,0 +1,86 @@
+<script setup lang="ts">
+// 每日用量柱状图:单一序列(标题说明指标,不需要图例),悬停显示当天数值
+import { computed, ref } from 'vue'
+
+const props = defineProps<{ points: { date: string; value: number }[]; format: (v: number) => string }>()
+
+const W = 720
+const H = 180
+const PAD = { top: 12, right: 8, bottom: 22, left: 44 }
+const hover = ref<number | null>(null)
+
+/** 取整的刻度上限:1 / 2 / 5 × 10^n */
+const yMax = computed(() => {
+  const max = Math.max(0, ...props.points.map(p => p.value))
+  if (max === 0) return 1
+  const exp = 10 ** Math.floor(Math.log10(max))
+  return ([1, 2, 5, 10].find(m => m * exp >= max) ?? 10) * exp
+})
+// 中间刻度格式化后与其他刻度相同时(如 0.5 次显示为 1)不画
+const ticks = computed(() => [0, 0.5, 1].map(f => f * yMax.value).filter((t, i, all) => i !== 1 || new Set(all.map(props.format)).size === 3))
+const empty = computed(() => props.points.every(p => p.value === 0))
+const plotW = W - PAD.left - PAD.right
+const plotH = H - PAD.top - PAD.bottom
+const slot = computed(() => plotW / Math.max(1, props.points.length))
+// 细柱:占槽宽的 60%,最宽 18px;相邻柱之间至少 2px
+const barW = computed(() => Math.max(2, Math.min(18, slot.value * 0.6, slot.value - 2)))
+const y = (v: number) => PAD.top + plotH - (v / yMax.value) * plotH
+// x 轴标签:最多约 8 个,首尾必显示
+const labelEvery = computed(() => Math.max(1, Math.ceil(props.points.length / 8)))
+const shortDate = (d: string) => d.slice(5).replace('-', '/')
+
+/** 顶部 4px 圆角、底部贴住基线的柱形 */
+function barPath(i: number, v: number) {
+  const x = PAD.left + i * slot.value + (slot.value - barW.value) / 2
+  const top = y(v)
+  const h = PAD.top + plotH - top
+  if (h <= 0) return ''
+  const r = Math.min(4, barW.value / 2, h)
+  return `M${x},${PAD.top + plotH}V${top + r}Q${x},${top} ${x + r},${top}H${x + barW.value - r}Q${x + barW.value},${top} ${x + barW.value},${top + r}V${PAD.top + plotH}Z`
+}
+
+const tip = computed(() => {
+  if (hover.value === null) return null
+  const p = props.points[hover.value]!
+  const cx = PAD.left + (hover.value + 0.5) * slot.value
+  return { p, left: `${(cx / W) * 100}%` }
+})
+</script>
+
+<template>
+  <div class="relative">
+    <svg :viewBox="`0 0 ${W} ${H}`" class="w-full h-auto block" role="img" :aria-label="`每日用量,共 ${points.length} 天`" @mouseleave="hover = null">
+      <g class="text-fg-muted">
+        <template v-for="t in ticks" :key="t">
+          <line :x1="PAD.left" :x2="W - PAD.right" :y1="y(t)" :y2="y(t)" stroke="currentColor" stroke-opacity="0.18" stroke-width="1" />
+          <text :x="PAD.left - 6" :y="y(t) + 4" text-anchor="end" fill="currentColor" font-size="10">{{ format(t) }}</text>
+        </template>
+        <template v-for="(p, i) in points" :key="p.date">
+          <text
+            v-if="i % labelEvery === 0 || i === points.length - 1"
+            :x="PAD.left + (i + 0.5) * slot" :y="H - 6" text-anchor="middle" fill="currentColor" font-size="10"
+          >{{ shortDate(p.date) }}</text>
+        </template>
+      </g>
+      <g>
+        <path
+          v-for="(p, i) in points" :key="p.date" :d="barPath(i, p.value)"
+          fill="var(--app-link-foreground)" :fill-opacity="hover === null || hover === i ? 1 : 0.45"
+        />
+      </g>
+      <!-- 悬停区域:整个槽位,比柱子更容易命中 -->
+      <rect
+        v-for="(p, i) in points" :key="`hit-${p.date}`"
+        :x="PAD.left + i * slot" :y="PAD.top" :width="slot" :height="plotH" fill="transparent"
+        @mouseenter="hover = i"
+      />
+    </svg>
+    <div v-if="empty" class="absolute inset-0 flex items-center justify-center hint pointer-events-none">这段时间没有用量</div>
+    <div
+      v-else-if="tip" class="absolute top-0 -translate-x-1/2 pointer-events-none card bg-input-alt px-2 py-1 text-[11.5px] whitespace-nowrap shadow"
+      :style="{ left: tip.left }"
+    >
+      <span class="text-fg-muted">{{ tip.p.date }}</span> · <b>{{ format(tip.p.value) }}</b>
+    </div>
+  </div>
+</template>

+ 3 - 1
apps/admin/src/lib/api.ts

@@ -2,7 +2,7 @@
 import { AgentClient } from '@agent-server/client'
 import type {
   ApiKeyDto, ApiKeyProfiles, AppSettings, CodexPluginCatalogDto, CodexPluginDetailDto, CodexPluginInstallResultDto, CodexSkillCatalogDto, CommunitySearchResultDto, CommunitySkillDto, CommunitySkillPreviewDto, DeepPartial, FileAccessPolicy, KnowledgeRootDto, PathCheck, PluginAction, PluginStateDto, ProfileDto, ProfileInput,
-  ProviderStatusDto, ResolvedAccessDto, SkillInfo,
+  ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -29,6 +29,8 @@ export const admin = {
     cancel: () => req<void>('DELETE', '/providers/codex/login'),
     logout: () => req<{ ok: true }>('POST', '/providers/codex/logout'),
   },
+  usage: (days = 30) => req<UsageSummaryDto>('GET', `/usage?days=${days}`),
+  usageErrors: (limit = 10) => req<RecentErrorDto[]>('GET', `/usage/errors?limit=${limit}`),
   overview: () => req<{ providers: ProviderStatusDto[]; runs: { running: number; queued: number; active: number }; version: string }>('GET', '/overview'),
   settings: () => req<{ settings: AppSettings; defaults: AppSettings; builtinGlobalDeny: string[] }>('GET', '/settings'),
   updateSettings: (patch: DeepPartial<AppSettings>) => req<{ settings: AppSettings }>('PATCH', '/settings', patch),

+ 97 - 4
apps/admin/src/views/OverviewView.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
-import type { ProviderStatusDto } from '@agent-server/shared'
+import { computed, onMounted, ref, watch } from 'vue'
+import type { ProviderStatusDto, RecentErrorDto, UsageSummaryDto, UsageTotals } from '@agent-server/shared'
 import ClaudeLoginDialog from '../components/ClaudeLoginDialog.vue'
 import CodexLoginDialog from '../components/CodexLoginDialog.vue'
 import PageHeader from '../components/PageHeader.vue'
+import UsageChart from '../components/UsageChart.vue'
 import { admin, errorMessage } from '../lib/api'
-import { toast } from '../lib/ui'
+import { formatTime, toast } from '../lib/ui'
 
 const providers = ref<ProviderStatusDto[]>([])
 const runs = ref({ running: 0, queued: 0, active: 0 })
@@ -14,6 +15,29 @@ const showLogin = ref(false)
 const showCodexLogin = ref(false)
 const loggingOut = ref(false)
 
+// 用量:区间与指标在图表上方一行切换
+const usage = ref<UsageSummaryDto | null>(null)
+const errors = ref<RecentErrorDto[]>([])
+const days = ref(30)
+const metric = ref<'runs' | 'tokens' | 'duration'>('runs')
+const compact = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(Math.round(n)))
+const minutes = (ms: number) => (ms >= 3_600_000 ? `${(ms / 3_600_000).toFixed(1)} 小时` : `${Math.round(ms / 60_000)} 分钟`)
+const tokens = (t: UsageTotals) => t.inputTokens + t.outputTokens
+const METRICS = {
+  runs: { label: '运行次数', value: (t: UsageTotals) => t.runs, format: (v: number) => compact(v) },
+  tokens: { label: 'Token(输入 + 输出)', value: tokens, format: compact },
+  duration: { label: '运行时长', value: (t: UsageTotals) => t.durationMs, format: (v: number) => (v === 0 ? '0' : minutes(v)) },
+} as const
+const points = computed(() => (usage.value?.daily ?? []).map(d => ({ date: d.date, value: METRICS[metric.value].value(d) })))
+
+async function loadUsage() {
+  ;[usage.value, errors.value] = await Promise.all([
+    admin.usage(days.value).catch(err => (toast(errorMessage(err), 'error'), null)),
+    admin.usageErrors(8).catch(() => []),
+  ])
+}
+watch(days, () => void loadUsage())
+
 async function logout(provider: 'claude' | 'codex' = 'claude') {
   const name = provider === 'claude' ? 'Claude' : 'Codex'
   if (!confirm(`退出 ${name} 登录?退出后 AgentServer 将无法运行 ${name},直到重新登录。`)) return
@@ -42,6 +66,7 @@ async function load() {
     const o = await admin.overview()
     providers.value = o.providers
     runs.value = o.runs
+    await loadUsage()
   } catch (err) {
     toast(errorMessage(err), 'error')
   } finally {
@@ -53,7 +78,7 @@ onMounted(load)
 
 <template>
   <div class="h-full flex flex-col">
-    <PageHeader title="概览" subtitle="引擎状态与运行情况">
+    <PageHeader title="概览" subtitle="运行情况、用量与引擎状态">
       <button type="button" class="btn-secondary" :disabled="loading" @click="load"><span class="codicon codicon-refresh" />刷新</button>
     </PageHeader>
     <div class="flex-1 overflow-y-auto p-6 flex flex-col gap-6 max-w-240">
@@ -63,6 +88,74 @@ onMounted(load)
           <div class="text-[24px] font-600 mt-1">{{ runs[k] }}</div>
         </div>
       </section>
+      <!-- 用量 -->
+      <section v-if="usage" class="flex flex-col gap-3">
+        <div class="flex items-center gap-3">
+          <h2 class="section-title m-0 flex-1">用量</h2>
+          <select v-model="metric" class="field w-44" aria-label="指标">
+            <option v-for="(m, k) in METRICS" :key="k" :value="k">{{ m.label }}</option>
+          </select>
+          <select v-model.number="days" class="field w-28" aria-label="时间范围">
+            <option :value="7">最近 7 天</option>
+            <option :value="30">最近 30 天</option>
+            <option :value="90">最近 90 天</option>
+          </select>
+        </div>
+        <div class="grid grid-cols-4 gap-3">
+          <div class="card p-3">
+            <div class="hint">今天运行</div>
+            <div class="text-[20px] font-600">{{ usage.today.runs }}<span v-if="usage.today.failed" class="text-[12px] text-error font-400 ml-2">失败 {{ usage.today.failed }}</span></div>
+          </div>
+          <div class="card p-3">
+            <div class="hint">今天 Token</div>
+            <div class="text-[20px] font-600">{{ compact(tokens(usage.today)) }}</div>
+            <div class="hint">输入 {{ compact(usage.today.inputTokens) }} · 输出 {{ compact(usage.today.outputTokens) }}</div>
+          </div>
+          <div class="card p-3">
+            <div class="hint">{{ days }} 天运行</div>
+            <div class="text-[20px] font-600">{{ usage.total.runs }}<span v-if="usage.total.failed" class="text-[12px] text-error font-400 ml-2">失败 {{ usage.total.failed }}</span></div>
+            <div class="hint">时长 {{ minutes(usage.total.durationMs) }}</div>
+          </div>
+          <div class="card p-3">
+            <div class="hint">{{ days }} 天 Token</div>
+            <div class="text-[20px] font-600">{{ compact(tokens(usage.total)) }}</div>
+            <div class="hint" title="订阅登录下为引擎给出的估算值,不是实际扣费">估算费用 ${{ usage.total.costUsd.toFixed(2) }}</div>
+          </div>
+        </div>
+        <div class="card p-4 flex flex-col gap-2">
+          <div class="text-[12.5px] font-600">每日{{ METRICS[metric].label }}<span class="hint font-400"> · {{ usage.from }} 至 {{ usage.to }}</span></div>
+          <UsageChart :points="points" :format="METRICS[metric].format" />
+        </div>
+        <div class="grid grid-cols-2 gap-3">
+          <div class="card p-4 flex flex-col gap-1.5">
+            <div class="text-[12.5px] font-600 mb-1">按来源</div>
+            <div v-for="s in usage.bySource" :key="s.source" class="grid grid-cols-[1fr_64px_72px] gap-2 text-[12.5px]">
+              <span class="truncate" :title="s.source">{{ s.name }}</span>
+              <span class="text-right">{{ s.runs }} 次</span>
+              <span class="text-right text-fg-muted">{{ compact(tokens(s)) }}</span>
+            </div>
+            <div v-if="!usage.bySource.length" class="hint">暂无数据</div>
+          </div>
+          <div class="card p-4 flex flex-col gap-1.5">
+            <div class="text-[12.5px] font-600 mb-1">按模型</div>
+            <div v-for="m in usage.byModel" :key="`${m.provider}/${m.model}`" class="grid grid-cols-[1fr_64px_72px] gap-2 text-[12.5px]">
+              <span class="truncate"><span class="text-fg-muted">{{ m.provider }} /</span> {{ m.model }}</span>
+              <span class="text-right">{{ m.runs }} 次</span>
+              <span class="text-right text-fg-muted">{{ compact(tokens(m)) }}</span>
+            </div>
+            <div v-if="!usage.byModel.length" class="hint">暂无数据</div>
+          </div>
+        </div>
+        <div v-if="errors.length" class="card p-4 flex flex-col gap-1.5">
+          <div class="text-[12.5px] font-600 mb-1 flex items-center gap-1.5"><span class="codicon codicon-error text-error" />最近的失败</div>
+          <RouterLink v-for="e in errors" :key="e.runId" :to="`/sessions/${e.sessionId}`" class="grid grid-cols-[120px_160px_1fr] gap-2 text-[12.5px] no-underline text-fg hover:bg-hover rounded-sm px-1">
+            <span class="text-fg-muted">{{ formatTime(e.endedAt) }}</span>
+            <span class="truncate">{{ e.sessionTitle || e.sessionId }}</span>
+            <span class="truncate text-error" :title="e.error">{{ e.error }}</span>
+          </RouterLink>
+        </div>
+      </section>
+
       <section class="flex flex-col gap-3">
         <h2 class="section-title m-0">引擎</h2>
         <div v-for="p in providers" :key="p.id" class="card p-4 flex gap-4 items-start">

+ 27 - 0
apps/server/drizzle/0002_usage_daily.sql

@@ -0,0 +1,27 @@
+CREATE TABLE `usage_daily` (
+	`date` text NOT NULL,
+	`source` text NOT NULL,
+	`provider` text NOT NULL,
+	`model` text NOT NULL,
+	`runs` integer DEFAULT 0 NOT NULL,
+	`failed` integer DEFAULT 0 NOT NULL,
+	`interrupted` integer DEFAULT 0 NOT NULL,
+	`input_tokens` integer DEFAULT 0 NOT NULL,
+	`output_tokens` integer DEFAULT 0 NOT NULL,
+	`cache_read_tokens` integer DEFAULT 0 NOT NULL,
+	`cache_write_tokens` integer DEFAULT 0 NOT NULL,
+	`cost_usd` real DEFAULT 0 NOT NULL,
+	`duration_ms` integer DEFAULT 0 NOT NULL,
+	PRIMARY KEY(`date`, `source`, `provider`, `model`)
+);
+--> statement-breakpoint
+-- 数据迁移:用现有的 Run 记录补齐历史用量(按服务器本地日期)
+INSERT INTO `usage_daily` (`date`, `source`, `provider`, `model`, `runs`, `failed`, `interrupted`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `cost_usd`, `duration_ms`)
+SELECT date(coalesce(r.`started_at`, r.`created_at`), 'localtime'), s.`source`, s.`provider`, s.`model`,
+  count(*), sum(r.`status` = 'failed'), sum(r.`status` = 'interrupted'),
+  coalesce(sum(r.`input_tokens`), 0), coalesce(sum(r.`output_tokens`), 0), coalesce(sum(r.`cache_read_tokens`), 0), coalesce(sum(r.`cache_write_tokens`), 0),
+  coalesce(sum(r.`cost_usd`), 0),
+  coalesce(sum(CASE WHEN r.`started_at` IS NOT NULL AND r.`ended_at` IS NOT NULL THEN cast((julianday(r.`ended_at`) - julianday(r.`started_at`)) * 86400000 AS integer) END), 0)
+FROM `runs` r JOIN `sessions` s ON s.`id` = r.`session_id`
+WHERE r.`status` IN ('completed', 'failed', 'interrupted')
+GROUP BY 1, 2, 3, 4;

+ 1026 - 0
apps/server/drizzle/meta/0002_snapshot.json

@@ -0,0 +1,1026 @@
+{
+  "version": "6",
+  "dialect": "sqlite",
+  "id": "9f4fe72a-3a23-43e5-94ac-0969150e5f3d",
+  "prevId": "3e8e056e-165d-4d73-8242-7b5beb6fbac2",
+  "tables": {
+    "admin_sessions": {
+      "name": "admin_sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "api_keys": {
+      "name": "api_keys",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_hash": {
+          "name": "key_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_prefix": {
+          "name": "key_prefix",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "allowed_profile_ids": {
+          "name": "allowed_profile_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "default_profile_id": {
+          "name": "default_profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "last_used_at": {
+          "name": "last_used_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "api_keys_key_hash_unique": {
+          "name": "api_keys_key_hash_unique",
+          "columns": [
+            "key_hash"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "audit_logs": {
+      "name": "audit_logs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "integer",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": true
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "actor": {
+          "name": "actor",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "summary": {
+          "name": "summary",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "detail": {
+          "name": "detail",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "audit_ts_idx": {
+          "name": "audit_ts_idx",
+          "columns": [
+            "ts"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "events": {
+      "name": "events",
+      "columns": {
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "seq": {
+          "name": "seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "type": {
+          "name": "type",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "payload": {
+          "name": "payload",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "ts": {
+          "name": "ts",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "events_session_id_seq_pk": {
+          "columns": [
+            "session_id",
+            "seq"
+          ],
+          "name": "events_session_id_seq_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "files": {
+      "name": "files",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "rel_path": {
+          "name": "rel_path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "origin": {
+          "name": "origin",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mime": {
+          "name": "mime",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "size": {
+          "name": "size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "mtime_ms": {
+          "name": "mtime_ms",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "files_session_path_idx": {
+          "name": "files_session_path_idx",
+          "columns": [
+            "session_id",
+            "rel_path"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "interactions": {
+      "name": "interactions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "run_id": {
+          "name": "run_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "kind": {
+          "name": "kind",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "request": {
+          "name": "request",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "response": {
+          "name": "response",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "resolved_at": {
+          "name": "resolved_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "interactions_session_idx": {
+          "name": "interactions_session_idx",
+          "columns": [
+            "session_id",
+            "status"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "knowledge_roots": {
+      "name": "knowledge_roots",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "access": {
+          "name": "access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'read'"
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "profiles": {
+      "name": "profiles",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "system_prompt": {
+          "name": "system_prompt",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'ask'"
+        },
+        "allowed_tools": {
+          "name": "allowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "disallowed_tools": {
+          "name": "disallowed_tools",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "skills": {
+          "name": "skills",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'\"all\"'"
+        },
+        "file_access": {
+          "name": "file_access",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "knowledge_root_ids": {
+          "name": "knowledge_root_ids",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "'[]'"
+        },
+        "is_default": {
+          "name": "is_default",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "builtin": {
+          "name": "builtin",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "version": {
+          "name": "version",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 1
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "runs": {
+      "name": "runs",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "status": {
+          "name": "status",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "started_at": {
+          "name": "started_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "ended_at": {
+          "name": "ended_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "error": {
+          "name": "error",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "runs_session_idx": {
+          "name": "runs_session_idx",
+          "columns": [
+            "session_id"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "sessions": {
+      "name": "sessions",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "profile_id": {
+          "name": "profile_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "permission_mode": {
+          "name": "permission_mode",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "system_prompt_append": {
+          "name": "system_prompt_append",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "provider_session_id": {
+          "name": "provider_session_id",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "workspace_dir": {
+          "name": "workspace_dir",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "title": {
+          "name": "title",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "''"
+        },
+        "last_seq": {
+          "name": "last_seq",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "archived": {
+          "name": "archived",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {
+        "sessions_source_idx": {
+          "name": "sessions_source_idx",
+          "columns": [
+            "source",
+            "updated_at"
+          ],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "settings": {
+      "name": "settings",
+      "columns": {
+        "key": {
+          "name": "key",
+          "type": "text",
+          "primaryKey": true,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "value": {
+          "name": "value",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    },
+    "usage_daily": {
+      "name": "usage_daily",
+      "columns": {
+        "date": {
+          "name": "date",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "source": {
+          "name": "source",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "runs": {
+          "name": "runs",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "failed": {
+          "name": "failed",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "interrupted": {
+          "name": "interrupted",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cache_write_tokens": {
+          "name": "cache_write_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "real",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": 0
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_daily_date_source_provider_model_pk": {
+          "columns": [
+            "date",
+            "source",
+            "provider",
+            "model"
+          ],
+          "name": "usage_daily_date_source_provider_model_pk"
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraints": {}
+    }
+  },
+  "views": {},
+  "enums": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "indexes": {}
+  }
+}

+ 7 - 0
apps/server/drizzle/meta/_journal.json

@@ -15,6 +15,13 @@
       "when": 1792740352358,
       "tag": "0001_api_key_profiles",
       "breakpoints": true
+    },
+    {
+      "idx": 2,
+      "version": "6",
+      "when": 1792758196040,
+      "tag": "0002_usage_daily",
+      "breakpoints": true
     }
   ]
 }

+ 4 - 2
apps/server/src/app.ts

@@ -19,6 +19,7 @@ import { RunTokenRegistry } from './mcp-gateway/tokens'
 import { RunManager } from './services/runs'
 import { SessionService } from './services/sessions'
 import { SettingsService } from './services/settings'
+import { UsageService } from './services/usage'
 
 export interface AppLogger {
   info(o: object, m?: string): void
@@ -38,6 +39,7 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
   const knowledge = new KnowledgeService(db, settings)
   const apiKeys = new ApiKeyService(db, profiles)
   const audit = new AuditService(db)
+  const usage = new UsageService(db)
   const interactions = new InteractionService(db, events, settings)
   const sessions = new SessionService(db, settings, profiles, apiKeys, files, events)
   const adminAuth = new AdminAuthService(db, log, config.adminPassword)
@@ -52,9 +54,9 @@ export function createApp(log: AppLogger, opts: { db?: DB; providers?: Partial<R
     summary => audit.log({ actor: 'admin', kind: 'admin', summary }),
   )
   sessions.extraModels = p => providers[p]?.knownModels?.() ?? []
-  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, plugins, communitySkills, gatewayTokens, providers })
+  const runs = new RunManager({ db, log, settings, sessions, profiles, knowledge, files, events, interactions, audit, usage, plugins, communitySkills, gatewayTokens, providers })
 
-  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
+  return { db, log, settings, events, files, profiles, knowledge, apiKeys, audit, interactions, sessions, usage, adminAuth, plugins, claudeLogin, codexSkills, communitySkills, codexPlugins, gatewayTokens, providers, runs }
 }
 
 export type App = ReturnType<typeof createApp>

+ 18 - 0
apps/server/src/db/schema.ts

@@ -141,3 +141,21 @@ export const adminSessions = sqliteTable('admin_sessions', {
   createdAt: createdAt(),
   expiresAt: text('expires_at').notNull(),
 })
+
+/** 按天汇总的用量(Run 结束时累加;删除会话不影响历史用量)。日期为服务器本地日期 */
+export const usageDaily = sqliteTable('usage_daily', {
+  date: text('date').notNull(),
+  /** API Key id,或后台的 'admin' */
+  source: text('source').notNull(),
+  provider: text('provider').notNull(),
+  model: text('model').notNull(),
+  runs: integer('runs').notNull().default(0),
+  failed: integer('failed').notNull().default(0),
+  interrupted: integer('interrupted').notNull().default(0),
+  inputTokens: integer('input_tokens').notNull().default(0),
+  outputTokens: integer('output_tokens').notNull().default(0),
+  cacheReadTokens: integer('cache_read_tokens').notNull().default(0),
+  cacheWriteTokens: integer('cache_write_tokens').notNull().default(0),
+  costUsd: real('cost_usd').notNull().default(0),
+  durationMs: integer('duration_ms').notNull().default(0),
+}, t => [primaryKey({ columns: [t.date, t.source, t.provider, t.model] })])

+ 4 - 0
apps/server/src/http/routes/admin.ts

@@ -70,6 +70,10 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
 
     g.get('/providers', async () => providerStatuses(app))
 
+    // 用量统计(全部来源)与最近的失败
+    g.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30 }))
+    g.get<{ Querystring: { limit?: string } }>('/usage/errors', async req => app.usage.recentErrors(Number(req.query.limit ?? 10) || 10))
+
     // 在后台登录 Claude(以服务账号运行官方 CLI 的登录流程)
     g.get('/providers/claude/login', async () => app.claudeLogin.current())
     g.post('/providers/claude/login', async req => {

+ 3 - 0
apps/server/src/http/routes/v1.ts

@@ -43,6 +43,9 @@ export default async function v1Routes(f: FastifyInstance, { app }: { app: App }
 
   // 引擎状态缓存 30 秒,避免频繁调用 CLI
   let statusCache: { at: number; data: ProviderStatusDto[] } | null = null
+  // 本平台(调用者自己的来源)的用量
+  f.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30, source: principal(req).source }))
+
   f.get('/providers', async () => {
     if (!statusCache || Date.now() - statusCache.at > 30_000) statusCache = { at: Date.now(), data: await providerStatuses(app) }
     return statusCache.data

+ 20 - 1
apps/server/src/services/runs.ts

@@ -6,7 +6,7 @@ import PQueue from 'p-queue'
 import { z } from 'zod'
 import type { AgentEventType, EventPayloads, ProviderId, RunDto, SendMessageBody, SessionDto } from '@agent-server/shared'
 import type { DB } from '../db'
-import { runs } from '../db/schema'
+import { runs, sessions } from '../db/schema'
 import { conflict, notFound } from '../lib/errors'
 import { newId, nowIso } from '../lib/ids'
 import type { AgentProvider, RunInput } from '../providers/types'
@@ -18,6 +18,7 @@ import type { FileService } from './files'
 import type { InteractionService } from './interactions'
 import type { CommunitySkillService } from './community-skills'
 import type { PluginService } from './plugins'
+import type { UsageService } from './usage'
 import type { RunTokenRegistry } from '../mcp-gateway/tokens'
 import { gatewayUrl } from '../config'
 import type { SessionRow, SessionService } from './sessions'
@@ -60,6 +61,7 @@ export class RunManager {
       events: EventStore
       interactions: InteractionService
       audit: AuditService
+      usage: UsageService
       plugins: PluginService
       communitySkills: CommunitySkillService
       gatewayTokens: RunTokenRegistry
@@ -266,6 +268,23 @@ export class RunManager {
       status, endedAt: nowIso(), error: status === 'failed' ? (p as EventPayloads['run.failed']).message : null,
     }).where(eq(runs.id, a.runId)).run()
     this.deps.events.append(a.sessionId, a.runId, `run.${status}`, p)
+    this.recordUsage(a, status)
+  }
+
+  /** 按天累加用量(usage_daily),失败不影响 Run 的结果 */
+  private recordUsage(a: ActiveRun, status: 'completed' | 'failed' | 'interrupted') {
+    try {
+      const run = this.deps.db.select().from(runs).where(eq(runs.id, a.runId)).get()
+      const session = this.deps.db.select().from(sessions).where(eq(sessions.id, a.sessionId)).get()
+      if (!run || !session) return
+      this.deps.usage.record({
+        source: session.source, provider: session.provider, model: session.model, status,
+        inputTokens: run.inputTokens, outputTokens: run.outputTokens, cacheReadTokens: run.cacheReadTokens, cacheWriteTokens: run.cacheWriteTokens,
+        costUsd: run.costUsd, durationMs: run.startedAt && run.endedAt ? Date.parse(run.endedAt) - Date.parse(run.startedAt) : 0,
+      })
+    } catch (err) {
+      this.deps.log.warn({ err }, 'usage record failed')
+    }
   }
 
   /** Claude 运行时加载的插件:已启用的插件 + 第三方技能目录 */

+ 105 - 0
apps/server/src/services/usage.ts

@@ -0,0 +1,105 @@
+// 用量统计:Run 结束时按(日期、来源、引擎、模型)累加到 usage_daily,供概览页与平台查询
+import { and, desc, eq, gte, lte, sql } from 'drizzle-orm'
+import type { RecentErrorDto, UsageSummaryDto, UsageTotals } from '@agent-server/shared'
+import type { DB } from '../db'
+import { apiKeys, runs, sessions, usageDaily } from '../db/schema'
+
+const MAX_DAYS = 366
+
+export interface UsageRecord {
+  source: string
+  provider: string
+  model: string
+  status: 'completed' | 'failed' | 'interrupted'
+  inputTokens?: number | null
+  outputTokens?: number | null
+  cacheReadTokens?: number | null
+  cacheWriteTokens?: number | null
+  costUsd?: number | null
+  durationMs?: number | null
+}
+
+/** 服务器本地日期 YYYY-MM-DD */
+export function localDate(d = new Date()): string {
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+const zero = (): UsageTotals => ({ runs: 0, failed: 0, interrupted: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0, durationMs: 0 })
+
+const totalsSql = {
+  runs: sql<number>`sum(${usageDaily.runs})`,
+  failed: sql<number>`sum(${usageDaily.failed})`,
+  interrupted: sql<number>`sum(${usageDaily.interrupted})`,
+  inputTokens: sql<number>`sum(${usageDaily.inputTokens})`,
+  outputTokens: sql<number>`sum(${usageDaily.outputTokens})`,
+  cacheReadTokens: sql<number>`sum(${usageDaily.cacheReadTokens})`,
+  cacheWriteTokens: sql<number>`sum(${usageDaily.cacheWriteTokens})`,
+  costUsd: sql<number>`sum(${usageDaily.costUsd})`,
+  durationMs: sql<number>`sum(${usageDaily.durationMs})`,
+}
+
+export class UsageService {
+  constructor(private db: DB) {}
+
+  record(r: UsageRecord, date = localDate()) {
+    const v = {
+      runs: 1, failed: r.status === 'failed' ? 1 : 0, interrupted: r.status === 'interrupted' ? 1 : 0,
+      inputTokens: r.inputTokens ?? 0, outputTokens: r.outputTokens ?? 0, cacheReadTokens: r.cacheReadTokens ?? 0,
+      cacheWriteTokens: r.cacheWriteTokens ?? 0, costUsd: r.costUsd ?? 0, durationMs: Math.max(0, Math.round(r.durationMs ?? 0)),
+    }
+    this.db.insert(usageDaily).values({ date, source: r.source, provider: r.provider, model: r.model, ...v })
+      .onConflictDoUpdate({
+        target: [usageDaily.date, usageDaily.source, usageDaily.provider, usageDaily.model],
+        set: Object.fromEntries(Object.keys(v).map(k => [k, sql.raw(`${col(k)} + excluded.${col(k)}`)])),
+      }).run()
+  }
+
+  /** 最近 days 天(含今天)的用量;source 指定时只统计该来源(平台查询自己的用量) */
+  summary(opts: { days?: number; source?: string; today?: string } = {}): UsageSummaryDto {
+    const days = Math.min(Math.max(1, Math.floor(opts.days ?? 30)), MAX_DAYS)
+    const to = opts.today ?? localDate()
+    const fromDate = new Date(`${to}T00:00:00`)
+    fromDate.setDate(fromDate.getDate() - (days - 1))
+    const from = localDate(fromDate)
+    const where = and(gte(usageDaily.date, from), lte(usageDaily.date, to), opts.source ? eq(usageDaily.source, opts.source) : undefined)
+
+    const norm = <T extends object>(r: T) => Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v ?? 0])) as T
+    const dailyRows = new Map(this.db.select({ date: usageDaily.date, ...totalsSql }).from(usageDaily).where(where).groupBy(usageDaily.date).all().map(r => [r.date, norm(r)]))
+    const daily: UsageSummaryDto['daily'] = []
+    for (let d = new Date(fromDate); localDate(d) <= to; d.setDate(d.getDate() + 1)) {
+      const date = localDate(d)
+      daily.push(dailyRows.get(date) ?? { date, ...zero() })
+    }
+    const total = daily.reduce((acc, d) => {
+      for (const k of Object.keys(acc) as (keyof UsageTotals)[]) acc[k] += d[k]
+      return acc
+    }, zero())
+
+    const keyNames = new Map(this.db.select({ id: apiKeys.id, name: apiKeys.name }).from(apiKeys).all().map(k => [k.id, k.name]))
+    const bySource = this.db.select({ source: usageDaily.source, ...totalsSql }).from(usageDaily).where(where).groupBy(usageDaily.source).all()
+      .map(r => ({ ...norm(r), name: r.source === 'admin' ? '后台 Playground' : keyNames.get(r.source) ?? `已删除的 Key(${r.source})` }))
+      .sort((a, b) => b.runs - a.runs)
+    const byModel = this.db.select({ provider: usageDaily.provider, model: usageDaily.model, ...totalsSql }).from(usageDaily).where(where)
+      .groupBy(usageDaily.provider, usageDaily.model).all().map(norm).sort((a, b) => b.runs - a.runs)
+
+    return { from, to, total, today: daily[daily.length - 1] ? strip(daily[daily.length - 1]!) : zero(), daily, bySource, byModel }
+  }
+
+  recentErrors(limit = 10): RecentErrorDto[] {
+    return this.db.select({
+      runId: runs.id, sessionId: runs.sessionId, sessionTitle: sessions.title, source: sessions.source, provider: sessions.provider,
+      error: runs.error, endedAt: runs.endedAt,
+    }).from(runs).innerJoin(sessions, eq(sessions.id, runs.sessionId)).where(eq(runs.status, 'failed'))
+      .orderBy(desc(runs.endedAt)).limit(Math.min(limit, 50)).all()
+      .map(r => ({ ...r, error: r.error ?? '' }))
+  }
+}
+
+function strip({ date: _date, ...t }: { date: string } & UsageTotals): UsageTotals {
+  return t
+}
+
+/** 驼峰字段名 → 列名 */
+function col(k: string) {
+  return `"${k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`)}"`
+}

+ 32 - 0
apps/server/test/api.test.ts

@@ -299,3 +299,35 @@ describe('内置 Profile 的兜底', () => {
     expect(profiles.getBuiltin('codex').model).toBe('gpt-6.1-sol')
   })
 })
+
+describe('用量统计', () => {
+  it('Run 结束后按天累加;删除会话后历史用量保留;平台只能看到自己的用量', async () => {
+    const before = app.usage.summary({ days: 7 }).total.runs
+    const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
+    await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '统计' } })
+    await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined)
+    await waitFor(() => app.usage.summary({ days: 7 }).total.runs > before || undefined)
+    await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, headers: auth() })
+
+    const s = app.usage.summary({ days: 7 })
+    expect(s.daily).toHaveLength(7)
+    expect(s.total.runs).toBe(before + 1)
+    expect(s.today.outputTokens).toBeGreaterThanOrEqual(2)
+    expect(s.bySource.find(x => x.name === 'test')).toBeTruthy()
+    expect(s.byModel[0]).toMatchObject({ provider: 'claude' })
+
+    const mine = (await server.inject({ method: 'GET', url: '/v1/usage?days=7', headers: auth() })).json()
+    expect(mine.bySource.map((x: { name: string }) => x.name)).toEqual(['test'])
+    const admin = await server.inject({ method: 'GET', url: '/admin/v1/usage', headers: auth() })
+    expect(admin.statusCode).toBe(401)
+  })
+
+  it('区间按日期补零;同一键累加', () => {
+    app.usage.record({ source: 'k', provider: 'codex', model: 'gpt', status: 'failed', inputTokens: 5, durationMs: 100 }, '2026-01-02')
+    app.usage.record({ source: 'k', provider: 'codex', model: 'gpt', status: 'completed', inputTokens: 7, costUsd: 0.5 }, '2026-01-02')
+    const s = app.usage.summary({ days: 3, today: '2026-01-03', source: 'k' })
+    expect(s.daily.map(d => [d.date, d.runs, d.inputTokens])).toEqual([['2026-01-01', 0, 0], ['2026-01-02', 2, 12], ['2026-01-03', 0, 0]])
+    expect(s.total).toMatchObject({ runs: 2, failed: 1, costUsd: 0.5, durationMs: 100 })
+    expect(s.bySource[0]!.name).toMatch(/已删除的 Key/)
+  })
+})

+ 1 - 0
docs/design/04-api.md

@@ -25,6 +25,7 @@
 | POST | `/v1/sessions/:id/messages` | `{ text, attachments?: fileId[] }` → `202 { runId }`;会话忙时返回 `409 session_busy` |
 | GET | `/v1/sessions/:id/stream` | **SSE**:订阅会话全部事件(推荐,一个会话一条连接) |
 | GET | `/v1/runs/:id` | Run 状态与用量 |
+| GET | `/v1/usage?days=30` | 本平台(调用者的 API Key)最近 N 天的用量:每日明细、按模型汇总 |
 | POST | `/v1/runs/:id/interrupt` | 中断 |
 
 ### 交互

+ 1 - 1
docs/design/05-data-model.md

@@ -29,7 +29,7 @@ settings   audit_logs   usage_daily   admin_sessions
 | `files` | id, session_id, rel_path, origin('upload'\|'agent'), mime, size, sha256, created_at | |
 | `settings` | key, value(json), updated_at | 全局配置,见 §3 |
 | `audit_logs` | id, ts, actor, session_id, kind('command'\|'file_access_denied'\|'approval'\|'admin'), summary, detail(json) | |
-| `usage_daily` | date, api_key_id, provider, model, runs, input_tokens, output_tokens, cost_usd | |
+| `usage_daily` | date(服务器本地日期), source(API Key id 或 admin), provider, model, runs, failed, interrupted, input/output/cache_read/cache_write_tokens, cost_usd, duration_ms | Run 结束时累加;删除会话不影响历史用量;迁移 0002 用已有 Run 补齐 |
 | `admin_sessions` | id, created_at, expires_at | 后台登录会话 |
 
 预留(暂不建表):`connectors`、`profile_connectors`(内部平台接入,见 08);`credentials`(API Key 类凭证,见 ADR-0008)。

+ 1 - 1
docs/design/11-roadmap.md

@@ -17,7 +17,7 @@
 2. 让 Agent 读取 `~/.ssh` 和 `~/.claude/.credentials.json`,通过 Read 工具和 Bash 两条路径都被拦截,并写入审计日志。
 3. 在后台新增一个资料库并在 Profile 中启用,Agent 能检索其中的内容;未启用的资料库读取会被拒绝。
 
-### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅、Codex openai-curated 插件目录(仅 Playground,ADR-0018)✅;之后:④ 用量统计、③ 业务平台接入)
+### M2:Codex 与客户端包(进行中:① Codex 接入 ✅、② MCP Gateway ✅、第三方技能(skills.sh,ADR-0017)✅、Codex openai-curated 插件目录(仅 Playground,ADR-0018)✅、④ 用量统计 ✅;之后:③ 业务平台接入)
 - CodexAdapter:app-server 进程管理、个人配置覆盖与自检、协议类型生成、事件映射、审批桥接、权限配置映射
 - MCP Gateway 上线(内置 `web_fetch`、`kb_list_roots`、`ask_user`)
 - Profile 管理(另存为、版本历史)

+ 38 - 0
packages/shared/src/api.ts

@@ -328,3 +328,41 @@ export interface CodexPluginInstallResultDto {
   /** 需要在 ChatGPT 中授权的应用(打开 installUrl 完成连接) */
   appsNeedingAuth: { id: string; name: string; installUrl: string | null }[]
 }
+
+// ---- 用量统计 ----
+
+export interface UsageTotals {
+  runs: number
+  failed: number
+  interrupted: number
+  inputTokens: number
+  outputTokens: number
+  cacheReadTokens: number
+  cacheWriteTokens: number
+  /** 订阅登录下为引擎给出的估算值 */
+  costUsd: number
+  durationMs: number
+}
+
+export interface UsageSummaryDto {
+  /** 统计区间(含首尾,服务器本地日期) */
+  from: string
+  to: string
+  total: UsageTotals
+  today: UsageTotals
+  /** 每天一项,没有用量的日期也会列出 */
+  daily: ({ date: string } & UsageTotals)[]
+  /** 按来源:API Key(平台)或后台 Playground(source = admin) */
+  bySource: ({ source: string; name: string } & UsageTotals)[]
+  byModel: ({ provider: string; model: string } & UsageTotals)[]
+}
+
+export interface RecentErrorDto {
+  runId: string
+  sessionId: string
+  sessionTitle: string
+  source: string
+  provider: string
+  error: string
+  endedAt: string | null
+}