Răsfoiți Sursa

feat: 引擎信息显示本服务用量与订阅额度

- 概览页每个引擎显示本服务用量(今天、所选区间的次数与 token)和账户订阅额度(各窗口已用百分比、重置时间)
- Claude:用 SDK 的 usage 接口查询(不发送消息、不调用模型),失败时显示最近一次对话中 rate_limit_event 报告的数值
- Codex:account/rateLimits/read(5 小时 / 7 天窗口、订阅类型)
- 新增 GET /admin/v1/providers/:id/quota(缓存 1 分钟);用量汇总增加按引擎(今天 + 区间)
- 修复概览图表末尾日期标签重叠、时间范围下拉框文字被截断

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jerry Liang 1 săptămână în urmă
părinte
comite
67b0dc671d

+ 2 - 2
apps/admin/src/components/UsageChart.vue

@@ -25,7 +25,7 @@ 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 个,首尾必显示
+// x 轴标签:最多约 8 个,最后一天必显示(离它太近的常规标签省略,避免重叠)
 const labelEvery = computed(() => Math.max(1, Math.ceil(props.points.length / 8)))
 const shortDate = (d: string) => d.slice(5).replace('-', '/')
 
@@ -57,7 +57,7 @@ const tip = computed(() => {
         </template>
         <template v-for="(p, i) in points" :key="p.date">
           <text
-            v-if="i % labelEvery === 0 || i === points.length - 1"
+            v-if="(i % labelEvery === 0 && points.length - 1 - i >= labelEvery / 2) || 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>

+ 2 - 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, ProfileVersionDto,
-  ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
+  ProviderQuotaDto, ProviderStatusDto, RecentErrorDto, ResolvedAccessDto, SkillInfo, UsageSummaryDto,
 } from '@agent-server/shared'
 
 export const client = new AgentClient({ baseUrl: '' })
@@ -29,6 +29,7 @@ export const admin = {
     cancel: () => req<void>('DELETE', '/providers/codex/login'),
     logout: () => req<{ ok: true }>('POST', '/providers/codex/logout'),
   },
+  providerQuota: (id: string) => req<ProviderQuotaDto>('GET', `/providers/${id}/quota`),
   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'),

+ 51 - 2
apps/admin/src/views/OverviewView.vue

@@ -1,6 +1,6 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
-import type { ProviderStatusDto, RecentErrorDto, UsageSummaryDto, UsageTotals } from '@agent-server/shared'
+import type { ProviderQuotaDto, 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'
@@ -30,6 +30,27 @@ const METRICS = {
 } as const
 const points = computed(() => (usage.value?.daily ?? []).map(d => ({ date: d.date, value: METRICS[metric.value].value(d) })))
 
+// 引擎账户的订阅额度(查询较慢,引擎列表出来后再逐个加载)
+const quotas = ref<Record<string, ProviderQuotaDto | { error: string }>>({})
+function loadQuotas() {
+  for (const p of providers.value.filter(x => x.available)) {
+    admin.providerQuota(p.id)
+      .then(q => (quotas.value = { ...quotas.value, [p.id]: q }))
+      .catch(err => (quotas.value = { ...quotas.value, [p.id]: { error: errorMessage(err) } }))
+  }
+}
+const engineUsage = (id: string) => usage.value?.byProvider.find(p => p.provider === id) ?? null
+/** 重置时间:一天内显示"x 小时 y 分钟后",更远显示日期 */
+function resetIn(iso: string | null) {
+  if (!iso) return ''
+  const mins = Math.round((Date.parse(iso) - Date.now()) / 60_000)
+  if (mins <= 0) return '即将重置'
+  if (mins < 60) return `${mins} 分钟后重置`
+  if (mins < 1440) return `${Math.floor(mins / 60)} 小时 ${mins % 60} 分钟后重置`
+  return `${formatTime(iso)} 重置`
+}
+const meterColor = (pct: number) => (pct >= 90 ? 'var(--app-error-foreground)' : pct >= 70 ? 'var(--app-warning)' : 'var(--app-link-foreground)')
+
 async function loadUsage() {
   ;[usage.value, errors.value] = await Promise.all([
     admin.usage(days.value).catch(err => (toast(errorMessage(err), 'error'), null)),
@@ -66,6 +87,7 @@ async function load() {
     const o = await admin.overview()
     providers.value = o.providers
     runs.value = o.runs
+    loadQuotas()
     await loadUsage()
   } catch (err) {
     toast(errorMessage(err), 'error')
@@ -95,7 +117,7 @@ onMounted(load)
           <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="时间范围">
+          <select v-model.number="days" class="field w-32" aria-label="时间范围">
             <option :value="7">最近 7 天</option>
             <option :value="30">最近 30 天</option>
             <option :value="90">最近 90 天</option>
@@ -180,6 +202,33 @@ onMounted(load)
             <span class="text-fg-muted">默认模型</span><span>{{ p.defaultModel || '—' }}</span>
             <span class="text-fg-muted">可选模型</span><span>{{ p.models.join('、') || '—' }}</span>
             <template v-if="p.message"><span class="text-fg-muted">说明</span><span class="text-warning">{{ p.message }}</span></template>
+            <template v-if="usage">
+              <span class="text-fg-muted">本服务用量</span>
+              <span>
+                今天 {{ engineUsage(p.id)?.today.runs ?? 0 }} 次 · {{ compact(engineUsage(p.id) ? tokens(engineUsage(p.id)!.today) : 0) }} token
+                <span class="text-fg-muted">| {{ days }} 天 {{ engineUsage(p.id)?.total.runs ?? 0 }} 次 · {{ compact(engineUsage(p.id) ? tokens(engineUsage(p.id)!.total) : 0) }} token</span>
+              </span>
+            </template>
+            <template v-if="p.available">
+              <span class="text-fg-muted">订阅额度</span>
+              <div class="flex flex-col gap-1.5 min-w-0">
+                <template v-if="!quotas[p.id]"><span class="hint">查询中…</span></template>
+                <span v-else-if="'error' in quotas[p.id]!" class="hint">无法获取:{{ (quotas[p.id] as { error: string }).error }}</span>
+                <template v-else>
+                  <div v-for="w in (quotas[p.id] as ProviderQuotaDto).windows" :key="w.key" class="grid grid-cols-[88px_1fr_auto] items-center gap-2">
+                    <span>{{ w.label }}</span>
+                    <div class="h-1.5 rounded-full bg-[color-mix(in_srgb,var(--app-primary-foreground)_12%,transparent)] overflow-hidden" role="meter" :aria-valuenow="w.usedPercent ?? 0" aria-valuemin="0" aria-valuemax="100" :aria-label="`${w.label}已用`">
+                      <div class="h-full rounded-full" :style="{ width: `${Math.min(100, w.usedPercent ?? 0)}%`, background: meterColor(w.usedPercent ?? 0) }" />
+                    </div>
+                    <span class="whitespace-nowrap"><b>{{ w.usedPercent ?? '—' }}%</b> <span class="text-fg-muted">已用 · {{ resetIn(w.resetsAt) }}</span></span>
+                  </div>
+                  <span class="hint">
+                    <template v-if="(quotas[p.id] as ProviderQuotaDto).plan">{{ (quotas[p.id] as ProviderQuotaDto).plan }} 订阅 · </template>与你在终端、网页中的使用共享同一份额度
+                    <template v-if="(quotas[p.id] as ProviderQuotaDto).message"> · <span class="text-warning">{{ (quotas[p.id] as ProviderQuotaDto).message }}</span></template>
+                  </span>
+                </template>
+              </div>
+            </template>
           </div>
         </div>
         <div class="hint">服务以专用账号 agentsvc 运行,登录的是你自己的账号:Claude 粘贴授权码完成,Codex 用设备码完成(在 OpenAI 页面输入短码)。也可以在服务器终端执行 <code class="font-mono">agent-claude auth login</code> / <code class="font-mono">agent-codex login --device-auth</code>。无需重启服务。</div>

+ 7 - 1
apps/server/src/http/routes/admin.ts

@@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify'
 import { z } from 'zod'
 import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, type AppSettings, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
 import type { App } from '../../app'
-import { AppError } from '../../lib/errors'
+import { AppError, notFound } from '../../lib/errors'
 import { runClaudeCli } from '../../providers/claude/cli'
 import { CodexProvider } from '../../providers/codex/adapter'
 import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
@@ -69,6 +69,12 @@ export default async function adminRoutes(f: FastifyInstance, { app }: { app: Ap
     }))
 
     g.get('/providers', async () => providerStatuses(app))
+    // 引擎账户的订阅额度(较慢,概览页单独加载)
+    g.get<{ Params: { id: string } }>('/providers/:id/quota', async req => {
+      const provider = app.providers[req.params.id as 'claude' | 'codex']
+      if (!provider?.quota) throw notFound(`引擎 ${req.params.id} 的额度信息`)
+      return provider.quota(app.settings.get())
+    })
 
     // 用量统计(全部来源)与最近的失败
     g.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30 }))

+ 60 - 1
apps/server/src/providers/claude/adapter.ts

@@ -6,9 +6,10 @@ import {
   type HookCallback,
   type PermissionResult,
   type SDKMessage,
+  type SDKRateLimitInfo,
   type SDKUserMessage,
 } from '@anthropic-ai/claude-agent-sdk'
-import type { AppSettings, InteractionQuestion, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import type { AppSettings, InteractionQuestion, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared'
 import { toClaudeSandbox } from '../../services/access-policy'
 import { newId } from '../../lib/ids'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
@@ -23,6 +24,9 @@ export class ClaudeProvider implements AgentProvider {
   readonly id = 'claude' as const
   /** 会话级"本会话允许"规则(进程内保存,重启后失效) */
   private sessionAllowed = new Map<string, Set<string>>()
+  /** 对话中引擎报告的额度(rate_limit_event),查询额度失败时作为后备 */
+  private lastRateLimits = new Map<string, QuotaWindowDto>()
+  private quotaCache: { at: number; value: ProviderQuotaDto } | null = null
 
   async run(ctx: RunContext): Promise<RunResult> {
     try {
@@ -127,6 +131,7 @@ export class ClaudeProvider implements AgentProvider {
     const mapper = new MessageMapper(ctx, deniedToolIds)
     try {
       for await (const m of q) {
+        if (m.type === 'rate_limit_event') this.recordRateLimit(m.rate_limit_info)
         const done = mapper.handle(m)
         if (done) return done
       }
@@ -166,6 +171,51 @@ export class ClaudeProvider implements AgentProvider {
     return { behavior: 'allow', updatedInput: { ...input, answers } }
   }
 
+  private recordRateLimit(info: SDKRateLimitInfo) {
+    if (!info.rateLimitType || info.utilization === undefined) return
+    const key = info.rateLimitType
+    this.lastRateLimits.set(key, {
+      key, label: QUOTA_LABELS[key] ?? key, usedPercent: Math.round(info.utilization * (info.utilization <= 1 ? 100 : 1)),
+      resetsAt: info.resetsAt ? new Date(info.resetsAt * 1000).toISOString() : null,
+    })
+  }
+
+  /**
+   * 订阅额度:用 SDK 的 usage 接口查询(与 /usage 命令相同的数据,不发送消息、不调用模型),缓存 1 分钟。
+   * 该接口标注为实验性,失败时显示最近一次对话中引擎报告的数值
+   */
+  async quota(settings: AppSettings): Promise<ProviderQuotaDto> {
+    if (this.quotaCache && Date.now() - this.quotaCache.at < 60_000) return this.quotaCache.value
+    const ac = new AbortController()
+    const timer = setTimeout(() => ac.abort(), 30_000)
+    try {
+      const q = query({
+        prompt: idleInput(ac.signal),
+        options: { abortController: ac, settingSources: [], cwd: settings.paths.workspaceRoot, pathToClaudeCodeExecutable: settings.providers.claude.cliPath },
+      })
+      const u = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true })
+      const limits = (u.rate_limits ?? {}) as Record<string, { utilization: number | null; resets_at: string | null } | null | undefined>
+      const windows = Object.keys(QUOTA_LABELS).flatMap(key => {
+        const w = limits[key]
+        return w && (w.utilization !== null || w.resets_at) ? [{ key, label: QUOTA_LABELS[key]!, usedPercent: w.utilization, resetsAt: w.resets_at }] : []
+      })
+      const value: ProviderQuotaDto = {
+        provider: 'claude', plan: u.subscription_type, windows, source: 'live', fetchedAt: new Date().toISOString(),
+        message: u.rate_limits_available ? null : '当前登录方式没有订阅额度信息',
+      }
+      this.quotaCache = { at: Date.now(), value }
+      return value
+    } catch (err) {
+      return {
+        provider: 'claude', plan: null, windows: [...this.lastRateLimits.values()], source: 'last-run', fetchedAt: new Date().toISOString(),
+        message: `查询额度失败:${(err as Error).message.slice(0, 200)}${this.lastRateLimits.size ? ';显示最近一次对话中的数值' : ''}`,
+      }
+    } finally {
+      clearTimeout(timer)
+      ac.abort()
+    }
+  }
+
   async status(settings: AppSettings): Promise<ProviderStatusDto> {
     const s = settings.providers.claude
     const base: ProviderStatusDto = {
@@ -189,6 +239,15 @@ export class ClaudeProvider implements AgentProvider {
   }
 }
 
+const QUOTA_LABELS: Record<string, string> = {
+  five_hour: '5 小时', seven_day: '7 天', seven_day_opus: '7 天 · Opus', seven_day_sonnet: '7 天 · Sonnet',
+}
+
+/** 不发送任何消息的输入流(只为查询账户信息启动 CLI),中止时结束 */
+async function* idleInput(signal: AbortSignal): AsyncGenerator<SDKUserMessage> {
+  await new Promise(resolve => signal.addEventListener('abort', resolve, { once: true }))
+}
+
 /** 流式输入:只发送一条用户消息(支持图片),发完即结束输入 */
 async function* singleMessage(msg: SDKUserMessage): AsyncGenerator<SDKUserMessage> {
   yield msg

+ 28 - 2
apps/server/src/providers/codex/adapter.ts

@@ -2,7 +2,7 @@
 import { createHash } from 'node:crypto'
 import fs from 'node:fs'
 import path from 'node:path'
-import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import type { AppSettings, ProviderQuotaDto, ProviderStatusDto, QuotaWindowDto, UsagePayload } from '@agent-server/shared'
 import { communitySkillsDir } from '../../config'
 import { checkAccess } from '../../services/access-policy'
 import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
@@ -11,7 +11,7 @@ import { GATEWAY_SERVER } from '../../mcp-gateway/server'
 import { buildThreadConfig } from './permissions'
 import { ALWAYS_DISABLED, skillDisplayId, skillsToDisable } from './skills'
 import type {
-  AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
+  AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountRateLimitsResponse, GetAccountResponse, RateLimitSnapshot,
   ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse, SkillMetadata, SkillsListResponse,
   McpServerElicitationRequestParams, PermissionsRequestApprovalParams, ThreadForkResponse, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
   ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
@@ -37,6 +37,7 @@ export class CodexProvider implements AgentProvider {
   private starting: Promise<AppServerClient> | null = null
   private clientKey = ''
   private runs = new Map<string, ThreadRun>()
+  private quotaCache: { at: number; value: ProviderQuotaDto } | null = null
   /**
    * 当前 app-server 进程中已加载的会话及其加载时的配置指纹。已加载会话的配置不会被 thread/resume 更新
    * (已验证,codex 0.164),配置变化时改为 thread/fork:保留历史、采用新配置(ADR-0020)
@@ -210,6 +211,22 @@ export class CodexProvider implements AgentProvider {
       .map(s => ({ id: skillDisplayId(s, codexHome()), name: s.name, description: s.shortDescription ?? s.description, scope: s.scope }))
   }
 
+  /** ChatGPT 订阅额度(account/rateLimits/read),缓存 1 分钟 */
+  async quota(settings: AppSettings): Promise<ProviderQuotaDto> {
+    if (this.quotaCache && Date.now() - this.quotaCache.at < 60_000) return this.quotaCache.value
+    const client = await this.ensure(settings)
+    const r = await client.request<GetAccountRateLimitsResponse>('account/rateLimits/read', {}, 30_000)
+    const snapshots = Object.values(r.rateLimitsByLimitId ?? {}).filter((s): s is RateLimitSnapshot => !!s)
+    const list = snapshots.length ? snapshots : [r.rateLimits]
+    const windows = list.flatMap(s => quotaWindows(s, list.length > 1))
+    const value: ProviderQuotaDto = {
+      provider: 'codex', plan: list.find(s => s.planType)?.planType ?? null, windows, source: 'live', fetchedAt: new Date().toISOString(),
+      message: r.ordinaryUsageAllowed === false ? '当前账户的额度已用完或不可用' : windows.length ? null : '账户没有返回额度信息',
+    }
+    this.quotaCache = { at: Date.now(), value }
+    return value
+  }
+
   /** 直接调用 app-server(插件目录等后台功能使用) */
   async request<T>(settings: AppSettings, method: string, params: unknown, timeoutMs = 60_000): Promise<T> {
     const client = await this.ensure(settings)
@@ -528,6 +545,15 @@ class ThreadRun {
   }
 }
 
+function quotaWindows(s: RateLimitSnapshot, named: boolean): QuotaWindowDto[] {
+  const label = (mins: number | null) => (mins === null ? '窗口' : mins % 1440 === 0 ? `${mins / 1440} 天` : mins % 60 === 0 ? `${mins / 60} 小时` : `${mins} 分钟`)
+  const prefix = named && (s.limitName ?? s.limitId) ? `${s.limitName ?? s.limitId} · ` : ''
+  return ([['primary', s.primary], ['secondary', s.secondary]] as const).flatMap(([k, w]) => (w ? [{
+    key: `${s.limitId ?? 'default'}:${k}`, label: prefix + label(w.windowDurationMins), usedPercent: w.usedPercent,
+    resetsAt: w.resetsAt ? new Date(w.resetsAt * 1000).toISOString() : null,
+  }] : []))
+}
+
 /** Codex 把命令包成 `/usr/bin/bash -lc "..."`,显示时去掉外层,只保留第一行 */
 export function commandTitle(command: string): string {
   let c = command.trim()

+ 3 - 1
apps/server/src/providers/types.ts

@@ -1,4 +1,4 @@
-import type { AgentEventType, AppSettings, EventPayloads, PermissionMode, ProviderId, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
+import type { AgentEventType, AppSettings, EventPayloads, PermissionMode, ProviderId, ProviderQuotaDto, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
 import type { ResolvedAccess } from '../services/access-policy'
 import type { PluginRef } from './claude/skills'
 import type { InteractionOutcome, NewApproval, NewQuestion } from '../services/interactions'
@@ -49,6 +49,8 @@ export interface AgentProvider {
   status(settings: AppSettings): Promise<ProviderStatusDto>
   /** 引擎动态发现的模型(如 Codex 的 model/list),与设置中的可选模型合并 */
   knownModels?(): string[]
+  /** 账户的订阅额度(引擎支持时) */
+  quota?(settings: AppSettings): Promise<ProviderQuotaDto>
 }
 
 export class ProviderRunError extends Error {

+ 10 - 1
apps/server/src/services/usage.ts

@@ -82,7 +82,16 @@ export class UsageService {
     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 }
+    const providerRows = (w: typeof where) => new Map(this.db.select({ provider: usageDaily.provider, ...totalsSql }).from(usageDaily).where(w)
+      .groupBy(usageDaily.provider).all().map(r => [r.provider, norm(r)]))
+    const rangeByProvider = providerRows(where)
+    const todayByProvider = providerRows(and(eq(usageDaily.date, to), opts.source ? eq(usageDaily.source, opts.source) : undefined))
+    const byProvider = [...rangeByProvider].map(([provider, { provider: _p, ...t }]) => {
+      const today = todayByProvider.get(provider)
+      return { provider, total: t, today: today ? (({ provider: _q, ...rest }) => rest)(today) : zero() }
+    })
+
+    return { from, to, total, today: daily[daily.length - 1] ? strip(daily[daily.length - 1]!) : zero(), daily, bySource, byModel, byProvider }
   }
 
   recentErrors(limit = 10): RecentErrorDto[] {

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

@@ -10,6 +10,7 @@ import type { AgentProvider, RunContext } from '../src/providers/types'
 /** 假引擎:输出文本 → 请求审批 → 写产出文件 */
 class FakeProvider implements AgentProvider {
   readonly id = 'claude' as const
+  quota?: AgentProvider['quota']
   lastCtx: RunContext | null = null
   async run(ctx: RunContext) {
     this.lastCtx = ctx
@@ -329,6 +330,19 @@ describe('用量统计', () => {
     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/)
+    expect(s.byProvider).toEqual([{ provider: 'codex', total: expect.objectContaining({ runs: 2, inputTokens: 12 }), today: expect.objectContaining({ runs: 0 }) }])
+    const onDay = app.usage.summary({ days: 1, today: '2026-01-02', source: 'k' })
+    expect(onDay.byProvider[0]!.today).toMatchObject({ runs: 2, failed: 1 })
+  })
+
+  it('引擎不支持额度查询时返回 404', async () => {
+    const cookie = { agent_admin: app.adminAuth.login('test-password').id }
+    const r = await server.inject({ method: 'GET', url: '/admin/v1/providers/claude/quota', cookies: cookie })
+    expect(r.statusCode).toBe(404)
+    fake.quota = async () => ({ provider: 'claude', plan: 'team', windows: [{ key: 'five_hour', label: '5 小时', usedPercent: 27, resetsAt: null }], source: 'live', fetchedAt: '', message: null })
+    const ok = await server.inject({ method: 'GET', url: '/admin/v1/providers/claude/quota', cookies: cookie })
+    expect(ok.json()).toMatchObject({ plan: 'team', windows: [{ usedPercent: 27 }] })
+    delete (fake as { quota?: unknown }).quota
   })
 })
 

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

@@ -139,6 +139,27 @@ export interface ProviderStatusDto {
   message: string | null
 }
 
+/** 订阅额度的一个计量窗口(如 5 小时、7 天) */
+export interface QuotaWindowDto {
+  key: string
+  label: string
+  /** 已用百分比 0-100;引擎没有给出时为 null */
+  usedPercent: number | null
+  resetsAt: string | null
+}
+
+/** 引擎账户的订阅额度(ChatGPT / Claude 订阅的用量限制) */
+export interface ProviderQuotaDto {
+  provider: ProviderId
+  /** 订阅类型,如 team、plus */
+  plan: string | null
+  windows: QuotaWindowDto[]
+  /** live:刚从引擎查询;last-run:查询失败,显示最近一次对话中引擎报告的数值 */
+  source: 'live' | 'last-run'
+  fetchedAt: string
+  message: string | null
+}
+
 export interface ResolvedAccessDto {
   readable: string[]
   writable: string[]
@@ -366,6 +387,8 @@ export interface UsageSummaryDto {
   /** 按来源:API Key(平台)或后台 Playground(source = admin) */
   bySource: ({ source: string; name: string } & UsageTotals)[]
   byModel: ({ provider: string; model: string } & UsageTotals)[]
+  /** 按引擎:今天与整个区间 */
+  byProvider: { provider: string; today: UsageTotals; total: UsageTotals }[]
 }
 
 export interface RecentErrorDto {