mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-02 22:42:14 +08:00
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 动画)
This commit is contained in:
@@ -1496,7 +1496,7 @@ func (h *OAuthHandler) SetupTokenCookieAuth(c *gin.Context) {
|
||||
}
|
||||
|
||||
// GetUsage handles getting account usage information
|
||||
// GET /api/v1/admin/accounts/:id/usage
|
||||
// GET /api/v1/admin/accounts/:id/usage?source=passive|active
|
||||
func (h *AccountHandler) GetUsage(c *gin.Context) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -1504,7 +1504,14 @@ func (h *AccountHandler) GetUsage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
usage, err := h.accountUsageService.GetUsage(c.Request.Context(), accountID)
|
||||
source := c.DefaultQuery("source", "active")
|
||||
|
||||
var usage *service.UsageInfo
|
||||
if source == "passive" {
|
||||
usage, err = h.accountUsageService.GetPassiveUsage(c.Request.Context(), accountID)
|
||||
} else {
|
||||
usage, err = h.accountUsageService.GetUsage(c.Request.Context(), accountID)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
|
||||
@@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
|
||||
"codex_secondary_",
|
||||
"codex_5h_",
|
||||
"codex_7d_",
|
||||
"passive_usage_",
|
||||
}
|
||||
|
||||
var schedulerNeutralExtraKeys = map[string]struct{}{
|
||||
|
||||
@@ -177,6 +177,7 @@ type AICredit struct {
|
||||
|
||||
// UsageInfo 账号使用量信息
|
||||
type UsageInfo struct {
|
||||
Source string `json:"source,omitempty"` // "passive" or "active"
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty"` // 更新时间
|
||||
FiveHour *UsageProgress `json:"five_hour"` // 5小时窗口
|
||||
SevenDay *UsageProgress `json:"seven_day,omitempty"` // 7天窗口
|
||||
@@ -393,6 +394,9 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
||||
// 4. 添加窗口统计(有独立缓存,1 分钟)
|
||||
s.addWindowStats(ctx, account, usage)
|
||||
|
||||
// 5. 将主动查询结果同步到被动缓存,下次 passive 加载即为最新值
|
||||
s.syncActiveToPassive(ctx, account.ID, usage)
|
||||
|
||||
s.tryClearRecoverableAccountError(ctx, account)
|
||||
return usage, nil
|
||||
}
|
||||
@@ -409,6 +413,81 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
|
||||
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
|
||||
}
|
||||
|
||||
// GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。
|
||||
// 仅适用于 Anthropic OAuth / SetupToken 账号。
|
||||
func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
|
||||
account, err := s.accountRepo.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get account failed: %w", err)
|
||||
}
|
||||
|
||||
if !account.IsAnthropicOAuthOrSetupToken() {
|
||||
return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts")
|
||||
}
|
||||
|
||||
// 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致)
|
||||
info := s.estimateSetupTokenUsage(account)
|
||||
info.Source = "passive"
|
||||
|
||||
// 设置采样时间
|
||||
if raw, ok := account.Extra["passive_usage_sampled_at"]; ok {
|
||||
if str, ok := raw.(string); ok {
|
||||
if t, err := time.Parse(time.RFC3339, str); err == nil {
|
||||
info.UpdatedAt = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 7d 窗口(从被动采样数据)
|
||||
util7d := parseExtraFloat64(account.Extra["passive_usage_7d_utilization"])
|
||||
reset7dRaw := parseExtraFloat64(account.Extra["passive_usage_7d_reset"])
|
||||
if util7d > 0 || reset7dRaw > 0 {
|
||||
var resetAt *time.Time
|
||||
var remaining int
|
||||
if reset7dRaw > 0 {
|
||||
t := time.Unix(int64(reset7dRaw), 0)
|
||||
resetAt = &t
|
||||
remaining = int(time.Until(t).Seconds())
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
}
|
||||
info.SevenDay = &UsageProgress{
|
||||
Utilization: util7d * 100,
|
||||
ResetsAt: resetAt,
|
||||
RemainingSeconds: remaining,
|
||||
}
|
||||
}
|
||||
|
||||
// 添加窗口统计
|
||||
s.addWindowStats(ctx, account, info)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// syncActiveToPassive 将主动查询的最新数据回写到 Extra 被动缓存,
|
||||
// 这样下次被动加载时能看到最新值。
|
||||
func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID int64, usage *UsageInfo) {
|
||||
extraUpdates := make(map[string]any, 4)
|
||||
|
||||
if usage.FiveHour != nil {
|
||||
extraUpdates["session_window_utilization"] = usage.FiveHour.Utilization / 100
|
||||
}
|
||||
if usage.SevenDay != nil {
|
||||
extraUpdates["passive_usage_7d_utilization"] = usage.SevenDay.Utilization / 100
|
||||
if usage.SevenDay.ResetsAt != nil {
|
||||
extraUpdates["passive_usage_7d_reset"] = usage.SevenDay.ResetsAt.Unix()
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraUpdates) > 0 {
|
||||
extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := s.accountRepo.UpdateExtra(ctx, accountID, extraUpdates); err != nil {
|
||||
slog.Warn("sync_active_to_passive_failed", "account_id", accountID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
|
||||
now := time.Now()
|
||||
usage := &UsageInfo{UpdatedAt: &now}
|
||||
|
||||
@@ -1110,10 +1110,13 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
|
||||
slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
|
||||
}
|
||||
|
||||
// 窗口重置时清除旧的 utilization,避免残留上个窗口的数据
|
||||
// 窗口重置时清除旧的 utilization 和被动采样数据,避免残留上个窗口的数据
|
||||
if windowEnd != nil && needInitWindow {
|
||||
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
"session_window_utilization": nil,
|
||||
"passive_usage_7d_utilization": nil,
|
||||
"passive_usage_7d_reset": nil,
|
||||
"passive_usage_sampled_at": nil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1121,15 +1124,34 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
|
||||
slog.Warn("session_window_update_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
|
||||
// 存储真实的 utilization 值(0-1 小数),供 estimateSetupTokenUsage 使用
|
||||
// 被动采样:从响应头收集 5h + 7d utilization,合并为一次 DB 写入
|
||||
extraUpdates := make(map[string]any, 4)
|
||||
// 5h utilization(0-1 小数),供 estimateSetupTokenUsage 使用
|
||||
if utilStr := headers.Get("anthropic-ratelimit-unified-5h-utilization"); utilStr != "" {
|
||||
if util, err := strconv.ParseFloat(utilStr, 64); err == nil {
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
"session_window_utilization": util,
|
||||
}); err != nil {
|
||||
slog.Warn("session_window_utilization_update_failed", "account_id", account.ID, "error", err)
|
||||
extraUpdates["session_window_utilization"] = util
|
||||
}
|
||||
}
|
||||
// 7d utilization(0-1 小数)
|
||||
if utilStr := headers.Get("anthropic-ratelimit-unified-7d-utilization"); utilStr != "" {
|
||||
if util, err := strconv.ParseFloat(utilStr, 64); err == nil {
|
||||
extraUpdates["passive_usage_7d_utilization"] = util
|
||||
}
|
||||
}
|
||||
// 7d reset timestamp
|
||||
if resetStr := headers.Get("anthropic-ratelimit-unified-7d-reset"); resetStr != "" {
|
||||
if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil {
|
||||
if ts > 1e11 {
|
||||
ts = ts / 1000
|
||||
}
|
||||
extraUpdates["passive_usage_7d_reset"] = ts
|
||||
}
|
||||
}
|
||||
if len(extraUpdates) > 0 {
|
||||
extraUpdates["passive_usage_sampled_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, extraUpdates); err != nil {
|
||||
slog.Warn("passive_usage_update_failed", "account_id", account.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果状态为allowed且之前有限流,说明窗口已重置,清除限流状态
|
||||
|
||||
@@ -223,8 +223,10 @@ export async function clearError(id: number): Promise<Account> {
|
||||
* @param id - Account ID
|
||||
* @returns Account usage info
|
||||
*/
|
||||
export async function getUsage(id: number): Promise<AccountUsageInfo> {
|
||||
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`)
|
||||
export async function getUsage(id: number, source?: 'passive' | 'active'): Promise<AccountUsageInfo> {
|
||||
const { data } = await apiClient.get<AccountUsageInfo>(`/admin/accounts/${id}/usage`, {
|
||||
params: source ? { source } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,38 @@
|
||||
:resets-at="usageInfo.seven_day_sonnet.resets_at"
|
||||
color="purple"
|
||||
/>
|
||||
|
||||
<!-- Passive sampling label + active query button -->
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span
|
||||
v-if="usageInfo.source === 'passive'"
|
||||
class="text-[9px] text-gray-400 dark:text-gray-500 italic"
|
||||
>
|
||||
{{ t('admin.accounts.usageWindow.passiveSampled') }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
:disabled="activeQueryLoading"
|
||||
@click="loadActiveUsage"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
:class="{ 'animate-spin': activeQueryLoading }"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.usageWindow.activeQuery') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No data yet -->
|
||||
@@ -433,6 +465,7 @@ const props = withDefaults(
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const activeQueryLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
|
||||
@@ -888,14 +921,18 @@ const copyValidationURL = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadUsage = async () => {
|
||||
const isAnthropicOAuthOrSetupToken = computed(() => {
|
||||
return props.account.platform === 'anthropic' && (props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
})
|
||||
|
||||
const loadUsage = async (source?: 'passive' | 'active') => {
|
||||
if (!shouldFetchUsage.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id)
|
||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, source)
|
||||
} catch (e: any) {
|
||||
error.value = t('common.error')
|
||||
console.error('Failed to load usage:', e)
|
||||
@@ -904,6 +941,17 @@ const loadUsage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadActiveUsage = async () => {
|
||||
activeQueryLoading.value = true
|
||||
try {
|
||||
usageInfo.value = await adminAPI.accounts.getUsage(props.account.id, 'active')
|
||||
} catch (e: any) {
|
||||
console.error('Failed to load active usage:', e)
|
||||
} finally {
|
||||
activeQueryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API Key quota progress bars =====
|
||||
|
||||
interface QuotaBarInfo {
|
||||
@@ -993,7 +1041,8 @@ const formatKeyUserCost = computed(() => {
|
||||
|
||||
onMounted(() => {
|
||||
if (!shouldAutoLoadUsageOnMount.value) return
|
||||
loadUsage()
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
loadUsage(source)
|
||||
})
|
||||
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
@@ -1011,7 +1060,8 @@ watch(
|
||||
if (nextToken === prevToken) return
|
||||
if (!shouldFetchUsage.value) return
|
||||
|
||||
loadUsage().catch((e) => {
|
||||
const source = isAnthropicOAuthOrSetupToken.value ? 'passive' : undefined
|
||||
loadUsage(source).catch((e) => {
|
||||
console.error('Failed to refresh usage after manual refresh:', e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2760,7 +2760,9 @@ export default {
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G31FI',
|
||||
claude: 'Claude'
|
||||
claude: 'Claude',
|
||||
passiveSampled: 'Passive',
|
||||
activeQuery: 'Query'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
|
||||
@@ -2163,7 +2163,9 @@ export default {
|
||||
gemini3Pro: 'G3P',
|
||||
gemini3Flash: 'G3F',
|
||||
gemini3Image: 'G31FI',
|
||||
claude: 'Claude'
|
||||
claude: 'Claude',
|
||||
passiveSampled: '被动采样',
|
||||
activeQuery: '查询'
|
||||
},
|
||||
tier: {
|
||||
free: 'Free',
|
||||
|
||||
@@ -781,6 +781,7 @@ export interface AntigravityModelQuota {
|
||||
}
|
||||
|
||||
export interface AccountUsageInfo {
|
||||
source?: 'passive' | 'active'
|
||||
updated_at: string | null
|
||||
five_hour: UsageProgress | null
|
||||
seven_day: UsageProgress | null
|
||||
|
||||
Reference in New Issue
Block a user