Skip to content

Commit 525cdb8

Browse files
committed
feat: Anthropic 账号被动用量采样,页面默认展示被动数据
从上游 /v1/messages 响应头被动采集 5h/7d utilization 并存储到 Account.Extra,页面加载时直接读取本地数据而非调用外部 Usage API。 用户可点击"查询"按钮主动拉取最新数据,主动查询结果自动回写被动缓存。 后端: - UpdateSessionWindow 合并采集 5h + 7d headers 为单次 DB 写入 - 新增 GetPassiveUsage 从 Extra 构建 UsageInfo (复用 estimateSetupTokenUsage) - GetUsage 主动查询后 syncActiveToPassive 回写被动缓存 - passive_usage_ 前缀注册为 scheduler-neutral 前端: - Anthropic 账号 mount/refresh 默认 source=passive - 新增"被动采样"标签和"查询"按钮 (带 loading 动画)
1 parent a6764e8 commit 525cdb8

9 files changed

Lines changed: 183 additions & 17 deletions

File tree

backend/internal/handler/admin/account_handler.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,15 +1496,22 @@ func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) {
14961496
}
14971497

14981498
// GetUsage handles getting account usage information
1499-
// GET /api/v1/admin/accounts/:id/usage
1499+
// GET /api/v1/admin/accounts/:id/usage?source=passive|active
15001500
func (h *AccountHandler) GetUsage(c *gin.Context) {
15011501
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
15021502
if err != nil {
15031503
response.BadRequest(c, "Invalid account ID")
15041504
return
15051505
}
15061506

1507-
usage, err := h.accountUsageService.GetUsage(c.Request.Context(), accountID)
1507+
source := c.DefaultQuery("source", "active")
1508+
1509+
var usage *service.UsageInfo
1510+
if source == "passive" {
1511+
usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID)
1512+
} else {
1513+
usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID)
1514+
}
15081515
if err != nil {
15091516
response.ErrorFrom(c, err)
15101517
return

backend/internal/repository/account_repo.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
5656
"codex_secondary_",
5757
"codex_5h_",
5858
"codex_7d_",
59+
"passive_usage_",
5960
}
6061

6162
var schedulerNeutralExtraKeys = map[string]struct{}{

backend/internal/service/account_usage_service.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ type AICredit struct {
177177

178178
// UsageInfo 账号使用量信息
179179
type UsageInfo struct {
180+
Source string `json:"source,omitempty"` // "passive" or "active"
180181
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
181182
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
182183
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
@@ -393,6 +394,9 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
393394
// 4. 添加窗口统计(有独立缓存,1 分钟)
394395
s.addWindowStats(ctx, account, usage)
395396

397+
// 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值
398+
s.syncActiveToPassive(ctx, account.ID, usage)
399+
396400
s.tryClearRecoverableAccountError(ctx, account)
397401
return usage, nil
398402
}
@@ -409,6 +413,81 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
409413
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
410414
}
411415

416+
// GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。
417+
// 仅适用于 Anthropic OAuth / SetupToken 账号。
418+
func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
419+
account, err := s.accountRepo.GetByID(ctx, accountID)
420+
if err != nil {
421+
return nil, fmt.Errorf("get account failed: %w", err)
422+
}
423+
424+
if !account.IsAnthropicOAuthOrSetupToken() {
425+
return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts")
426+
}
427+
428+
// 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致)
429+
info := s.estimateSetupTokenUsage(account)
430+
info.Source = "passive"
431+
432+
// 设置采样时间
433+
if raw, ok := account.Extra["passive_usage_sampled_at"]; ok {
434+
if str, ok := raw.(string); ok {
435+
if t, err := time.Parse(time.RFC3339, str); err == nil {
436+
info.UpdatedAt = &t
437+
}
438+
}
439+
}
440+
441+
// 构建 7d 窗口(从被动采样数据)
442+
util7d := parseExtraFloat64(account.Extra["passive_usage_7d_utilization"])
443+
reset7dRaw := parseExtraFloat64(account.Extra["passive_usage_7d_reset"])
444+
if util7d > 0 || reset7dRaw > 0 {
445+
var resetAt *time.Time
446+
var remaining int
447+
if reset7dRaw > 0 {
448+
t := time.Unix(int64(reset7dRaw), 0)
449+
resetAt = &t
450+
remaining = int(time.Until(t).Seconds())
451+
if remaining < 0 {
452+
remaining = 0
453+
}
454+
}
455+
info.SevenDay = &UsageProgress{
456+
Utilization: util7d * 100,
457+
ResetsAt: resetAt,
458+
RemainingSeconds: remaining,
459+
}
460+
}
461+
462+
// 添加窗口统计
463+
s.addWindowStats(ctx, account, info)
464+
465+
return info, nil
466+
}
467+
468+
// syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存,
469+
// 这样下次被动加载时能看到最新值。
470+
func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID int64, usage *UsageInfo) {
471+
extraUpdates := make(map[string]any, 4)
472+
473+
if usage.FiveHour != nil {
474+
extraUpdates["session_window_utilization"] = usage.FiveHour.Utilization / 100
475+
}
476+
if usage.SevenDay != nil {
477+
extraUpdates["passive_usage_7d_utilization"] = usage.SevenDay.Utilization / 100
478+
if usage.SevenDay.ResetsAt != nil {
479+
extraUpdates["passive_usage_7d_reset"] = usage.SevenDay.ResetsAt.Unix()
480+
}
481+
}
482+
483+
if len(extraUpdates) > 0 {
484+
extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339)
485+
if err := s.accountRepo.UpdateExtra(ctx, accountID, extraUpdates); err != nil {
486+
slog.Warn("sync_active_to_passive_failed", "account_id", accountID, "error", err)
487+
}
488+
}
489+
}
490+
412491
func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
413492
now := time.Now()
414493
usage := &UsageInfo{UpdatedAt: &now}

backend/internal/service/ratelimit_service.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1110,25 +1110,47 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
11101110
slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
11111111
}
11121112

1113-
// 窗口重置时清除旧的 utilization,避免残留上个窗口的数据
1113+
// 窗口重置时清除旧的 utilization 和被动采样数据,避免残留上个窗口的数据
11141114
if windowEnd != nil && needInitWindow {
11151115
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
1116-
"session_window_utilization": nil,
1116+
"session_window_utilization": nil,
1117+
"passive_usage_7d_utilization": nil,
1118+
"passive_usage_7d_reset": nil,
1119+
"passive_usage_sampled_at": nil,
11171120
})
11181121
}
11191122

11201123
if err := s.accountRepo.UpdateSessionWindow(ctx, account.ID, windowStart, windowEnd, status); err != nil {
11211124
slog.Warn("session_window_update_failed", "account_id", account.ID, "error", err)
11221125
}
11231126

1124-
// 存储真实的 utilization 值(0-1 小数),供 estimateSetupTokenUsage 使用
1127+
// 被动采样:从响应头收集 5h + 7d utilization,合并为一次 DB 写入
1128+
extraUpdates := make(map[string]any, 4)
1129+
// 5h utilization(0-1 小数),供 estimateSetupTokenUsage 使用
11251130
if utilStr := headers.Get("anthropic-ratelimit-unified-5h-utilization"); utilStr != "" {
11261131
if util, err := strconv.ParseFloat(utilStr, 64); err == nil {
1127-
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
1128-
"session_window_utilization": util,
1129-
}); err != nil {
1130-
slog.Warn("session_window_utilization_update_failed", "account_id", account.ID, "error", err)
1132+
extraUpdates["session_window_utilization"] = util
1133+
}
1134+
}
1135+
// 7d utilization(0-1 小数)
1136+
if utilStr := headers.Get("anthropic-ratelimit-unified-7d-utilization"); utilStr != "" {
1137+
if util, err := strconv.ParseFloat(utilStr, 64); err == nil {
1138+
extraUpdates["passive_usage_7d_utilization"] = util
1139+
}
1140+
}
1141+
// 7d reset timestamp
1142+
if resetStr := headers.Get("anthropic-ratelimit-unified-7d-reset"); resetStr != "" {
1143+
if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil {
1144+
if ts > 1e11 {
1145+
ts = ts / 1000
11311146
}
1147+
extraUpdates["passive_usage_7d_reset"] = ts
1148+
}
1149+
}
1150+
if len(extraUpdates) > 0 {
1151+
extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339)
1152+
if err := s.accountRepo.UpdateExtra(ctx, account.ID, extraUpdates); err != nil {
1153+
slog.Warn("passive_usage_update_failed", "account_id", account.ID, "error", err)
11321154
}
11331155
}
11341156

frontend/src/api/admin/accounts.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,8 +223,10 @@ export async function clearError(id: number): Promise<Account> {
223223
* @param id - Account ID
224224
* @returns Account usage info
225225
*/
226-
export async function getUsage(id: number): Promise<AccountUsageInfo> {
227-
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`)
226+
export async function getUsage(id: number, source?: 'passive' | 'active'): Promise<AccountUsageInfo> {
227+
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`, {
228+
params: source ? { source } : undefined
229+
})
228230
return data
229231
}
230232

frontend/src/components/account/AccountUsageCell.vue

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,38 @@
6767
:resets-at="usageInfo.seven_day_sonnet.resets_at"
6868
color="purple"
6969
/>
70+
71+
<!-- Passive sampling label + active query button -->
72+
<div class="flex items-center gap-1.5 mt-0.5">
73+
<span
74+
v-if="usageInfo.source === 'passive'"
75+
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
76+
>
77+
{{ t('admin.accounts.usageWindow.passiveSampled') }}
78+
</span>
79+
<button
80+
type="button"
81+
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[9px] font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30 transition-colors"
82+
:disabled="activeQueryLoading"
83+
@click="loadActiveUsage"
84+
>
85+
<svg
86+
class="h-2.5 w-2.5"
87+
:class="{ 'animate-spin': activeQueryLoading }"
88+
fill="none"
89+
stroke="currentColor"
90+
viewBox="0 0 24 24"
91+
>
92+
<path
93+
stroke-linecap="round"
94+
stroke-linejoin="round"
95+
stroke-width="2"
96+
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
97+
/>
98+
</svg>
99+
{{ t('admin.accounts.usageWindow.activeQuery') }}
100+
</button>
101+
</div>
70102
</div>
71103

72104
<!-- No data yet -->
@@ -433,6 +465,7 @@ const props = withDefaults(
433465
const { t } = useI18n()
434466
435467
const loading = ref(false)
468+
const activeQueryLoading = ref(false)
436469
const error = ref<string | null>(null)
437470
const usageInfo = ref<AccountUsageInfo | null>(null)
438471
@@ -888,14 +921,18 @@ const copyValidationURL = async () => {
888921
}
889922
}
890923
891-
const loadUsage = async () => {
924+
const isAnthropicOAuthOrSetupToken = computed(() => {
925+
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
926+
})
927+
928+
const loadUsage = async (source?: 'passive' | 'active') => {
892929
if (!shouldFetchUsage.value) return
893930
894931
loading.value = true
895932
error.value = null
896933
897934
try {
898-
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
935+
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, source)
899936
} catch (e: any) {
900937
error.value = t('common.error')
901938
console.error('Failed to load usage:', e)
@@ -904,6 +941,17 @@ const loadUsage = async () => {
904941
}
905942
}
906943
944+
const loadActiveUsage = async () => {
945+
activeQueryLoading.value = true
946+
try {
947+
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, 'active')
948+
} catch (e: any) {
949+
console.error('Failed to load active usage:', e)
950+
} finally {
951+
activeQueryLoading.value = false
952+
}
953+
}
954+
907955
// ===== API Key quota progress bars =====
908956
909957
interface QuotaBarInfo {
@@ -993,7 +1041,8 @@ const formatKeyUserCost = computed(() => {
9931041
9941042
onMounted(() => {
9951043
if (!shouldAutoLoadUsageOnMount.value) return
996-
loadUsage()
1044+
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
1045+
loadUsage(source)
9971046
})
9981047
9991048
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
@@ -1011,7 +1060,8 @@ watch(
10111060
if (nextToken === prevToken) return
10121061
if (!shouldFetchUsage.value) return
10131062
1014-
loadUsage().catch((e) => {
1063+
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
1064+
loadUsage(source).catch((e) => {
10151065
console.error('Failed to refresh usage after manual refresh:', e)
10161066
})
10171067
}

frontend/src/i18n/locales/en.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2760,7 +2760,9 @@ export default {
27602760
gemini3Pro: 'G3P',
27612761
gemini3Flash: 'G3F',
27622762
gemini3Image: 'G31FI',
2763-
claude: 'Claude'
2763+
claude: 'Claude',
2764+
passiveSampled: 'Passive',
2765+
activeQuery: 'Query'
27642766
},
27652767
tier: {
27662768
free: 'Free',

frontend/src/i18n/locales/zh.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2163,7 +2163,9 @@ export default {
21632163
gemini3Pro: 'G3P',
21642164
gemini3Flash: 'G3F',
21652165
gemini3Image: 'G31FI',
2166-
claude: 'Claude'
2166+
claude: 'Claude',
2167+
passiveSampled: '被动采样',
2168+
activeQuery: '查询'
21672169
},
21682170
tier: {
21692171
free: 'Free',

frontend/src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,7 @@ export interface AntigravityModelQuota {
781781
}
782782

783783
export interface AccountUsageInfo {
784+
source?: 'passive' | 'active'
784785
updated_at: string | null
785786
five_hour: UsageProgress | null
786787
seven_day: UsageProgress | null

0 commit comments

Comments
 (0)