feat: OpenAI OAuth账号显示Codex使用量

从响应头提取x-codex-*使用量信息并保存到账号Extra字段,
前端账号列表展示5h/7d窗口的使用进度条。
This commit is contained in:
shaw
2025-12-23 16:26:07 +08:00
parent f6341b7f2b
commit f25ac3aff5
6 changed files with 245 additions and 14 deletions

View File

@@ -23,14 +23,18 @@ func (r *AccountRepository) Create(ctx context.Context, account *model.Account)
func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Account, error) {
var account model.Account
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups").First(&account, id).Error
err := r.db.WithContext(ctx).Preload("Proxy").Preload("AccountGroups.Group").First(&account, id).Error
if err != nil {
return nil, err
}
// 填充 GroupIDs 虚拟字段
// 填充 GroupIDs 和 Groups 虚拟字段
account.GroupIDs = make([]int64, 0, len(account.AccountGroups))
account.Groups = make([]*model.Group, 0, len(account.AccountGroups))
for _, ag := range account.AccountGroups {
account.GroupIDs = append(account.GroupIDs, ag.GroupID)
if ag.Group != nil {
account.Groups = append(account.Groups, ag.Group)
}
}
return &account, nil
}
@@ -303,3 +307,31 @@ func (r *AccountRepository) SetSchedulable(ctx context.Context, id int64, schedu
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
Update("schedulable", schedulable).Error
}
// UpdateExtra updates specific fields in account's Extra JSONB field
// It merges the updates into existing Extra data without overwriting other fields
func (r *AccountRepository) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error {
if len(updates) == 0 {
return nil
}
// Get current account to preserve existing Extra data
var account model.Account
if err := r.db.WithContext(ctx).Select("extra").Where("id = ?", id).First(&account).Error; err != nil {
return err
}
// Initialize Extra if nil
if account.Extra == nil {
account.Extra = make(model.JSONB)
}
// Merge updates into existing Extra
for k, v := range updates {
account.Extra[k] = v
}
// Save updated Extra
return r.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", id).
Update("extra", account.Extra).Error
}

View File

@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
@@ -38,6 +39,18 @@ var openaiAllowedHeaders = map[string]bool{
"session_id": true,
}
// OpenAICodexUsageSnapshot represents Codex API usage limits from response headers
type OpenAICodexUsageSnapshot struct {
PrimaryUsedPercent *float64 `json:"primary_used_percent,omitempty"`
PrimaryResetAfterSeconds *int `json:"primary_reset_after_seconds,omitempty"`
PrimaryWindowMinutes *int `json:"primary_window_minutes,omitempty"`
SecondaryUsedPercent *float64 `json:"secondary_used_percent,omitempty"`
SecondaryResetAfterSeconds *int `json:"secondary_reset_after_seconds,omitempty"`
SecondaryWindowMinutes *int `json:"secondary_window_minutes,omitempty"`
PrimaryOverSecondaryPercent *float64 `json:"primary_over_secondary_percent,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// OpenAIUsage represents OpenAI API response usage
type OpenAIUsage struct {
InputTokens int `json:"input_tokens"`
@@ -284,6 +297,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
// Extract and save Codex usage snapshot from response headers (for OAuth accounts)
if account.Type == model.AccountTypeOAuth {
if snapshot := extractCodexUsageHeaders(resp.Header); snapshot != nil {
s.updateCodexUsageSnapshot(ctx, account.ID, snapshot)
}
}
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
@@ -708,3 +728,109 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
return nil
}
// extractCodexUsageHeaders extracts Codex usage limits from response headers
func extractCodexUsageHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
snapshot := &OpenAICodexUsageSnapshot{}
hasData := false
// Helper to parse float64 from header
parseFloat := func(key string) *float64 {
if v := headers.Get(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return &f
}
}
return nil
}
// Helper to parse int from header
parseInt := func(key string) *int {
if v := headers.Get(key); v != "" {
if i, err := strconv.Atoi(v); err == nil {
return &i
}
}
return nil
}
// Primary (weekly) limits
if v := parseFloat("x-codex-primary-used-percent"); v != nil {
snapshot.PrimaryUsedPercent = v
hasData = true
}
if v := parseInt("x-codex-primary-reset-after-seconds"); v != nil {
snapshot.PrimaryResetAfterSeconds = v
hasData = true
}
if v := parseInt("x-codex-primary-window-minutes"); v != nil {
snapshot.PrimaryWindowMinutes = v
hasData = true
}
// Secondary (5h) limits
if v := parseFloat("x-codex-secondary-used-percent"); v != nil {
snapshot.SecondaryUsedPercent = v
hasData = true
}
if v := parseInt("x-codex-secondary-reset-after-seconds"); v != nil {
snapshot.SecondaryResetAfterSeconds = v
hasData = true
}
if v := parseInt("x-codex-secondary-window-minutes"); v != nil {
snapshot.SecondaryWindowMinutes = v
hasData = true
}
// Overflow ratio
if v := parseFloat("x-codex-primary-over-secondary-limit-percent"); v != nil {
snapshot.PrimaryOverSecondaryPercent = v
hasData = true
}
if !hasData {
return nil
}
snapshot.UpdatedAt = time.Now().Format(time.RFC3339)
return snapshot
}
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
if snapshot == nil {
return
}
// Convert snapshot to map for merging into Extra
updates := make(map[string]any)
if snapshot.PrimaryUsedPercent != nil {
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
}
if snapshot.PrimaryResetAfterSeconds != nil {
updates["codex_primary_reset_after_seconds"] = *snapshot.PrimaryResetAfterSeconds
}
if snapshot.PrimaryWindowMinutes != nil {
updates["codex_primary_window_minutes"] = *snapshot.PrimaryWindowMinutes
}
if snapshot.SecondaryUsedPercent != nil {
updates["codex_secondary_used_percent"] = *snapshot.SecondaryUsedPercent
}
if snapshot.SecondaryResetAfterSeconds != nil {
updates["codex_secondary_reset_after_seconds"] = *snapshot.SecondaryResetAfterSeconds
}
if snapshot.SecondaryWindowMinutes != nil {
updates["codex_secondary_window_minutes"] = *snapshot.SecondaryWindowMinutes
}
if snapshot.PrimaryOverSecondaryPercent != nil {
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
}
updates["codex_usage_updated_at"] = snapshot.UpdatedAt
// Update account's Extra field asynchronously
go func() {
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
}()
}

View File

@@ -34,4 +34,5 @@ type AccountRepository interface {
SetOverloaded(ctx context.Context, id int64, until time.Time) error
ClearRateLimit(ctx context.Context, id int64) error
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error
UpdateExtra(ctx context.Context, id int64, updates map[string]any) error
}

View File

@@ -68,7 +68,31 @@
<SetupTokenTimeWindow :account="account" />
</template>
<!-- OpenAI accounts: no usage window API, show dash -->
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
<div v-if="hasCodexUsage" class="space-y-1">
<!-- 5h Window (Secondary) -->
<UsageProgressBar
v-if="codexSecondaryUsedPercent !== null"
label="5h"
:utilization="codexSecondaryUsedPercent"
:resets-at="codexSecondaryResetAt"
color="indigo"
/>
<!-- Weekly Window (Primary) -->
<UsageProgressBar
v-if="codexPrimaryUsedPercent !== null"
label="7d"
:utilization="codexPrimaryUsedPercent"
:resets-at="codexPrimaryResetAt"
color="emerald"
/>
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- Other accounts: no usage window -->
<template v-else>
<div class="text-xs text-gray-400">-</div>
</template>
@@ -100,9 +124,44 @@ const showUsageWindows = computed(() =>
props.account.type === 'oauth' || props.account.type === 'setup-token'
)
// OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => {
const extra = props.account.extra
return extra && (
extra.codex_primary_used_percent !== undefined ||
extra.codex_secondary_used_percent !== undefined
)
})
const codexPrimaryUsedPercent = computed(() => {
const extra = props.account.extra
if (!extra || extra.codex_primary_used_percent === undefined) return null
return extra.codex_primary_used_percent
})
const codexSecondaryUsedPercent = computed(() => {
const extra = props.account.extra
if (!extra || extra.codex_secondary_used_percent === undefined) return null
return extra.codex_secondary_used_percent
})
const codexPrimaryResetAt = computed(() => {
const extra = props.account.extra
if (!extra || extra.codex_primary_reset_after_seconds === undefined) return null
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString()
})
const codexSecondaryResetAt = computed(() => {
const extra = props.account.extra
if (!extra || extra.codex_secondary_reset_after_seconds === undefined) return null
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString()
})
const loadUsage = async () => {
// Only fetch usage for Anthropic OAuth accounts
// OpenAI doesn't have a usage window API - usage is updated from response headers during forwarding
// OpenAI usage comes from account.extra field (updated during forwarding)
if (props.account.platform !== 'anthropic' || props.account.type !== 'oauth') return
loading.value = true

View File

@@ -29,7 +29,7 @@
<transition name="dropdown">
<div
v-if="tooltipOpen"
class="absolute right-0 mt-2 w-80 bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50"
class="absolute right-0 mt-2 w-[340px] bg-white dark:bg-dark-800 rounded-xl shadow-xl border border-gray-200 dark:border-dark-700 z-50 overflow-hidden"
>
<div class="p-3 border-b border-gray-100 dark:border-dark-700">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
@@ -62,43 +62,43 @@
<!-- Progress bars -->
<div class="space-y-1.5">
<div v-if="subscription.group?.daily_limit_usd" class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.daily') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.daily') }}</span>
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.daily_usage_usd, subscription.group?.daily_limit_usd)"
:style="{ width: getProgressWidth(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
{{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
</span>
</div>
<div v-if="subscription.group?.weekly_limit_usd" class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.weekly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.weekly') }}</span>
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd)"
:style="{ width: getProgressWidth(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
{{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
</span>
</div>
<div v-if="subscription.group?.monthly_limit_usd" class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 w-8">{{ t('subscriptionProgress.monthly') }}</span>
<div class="flex-1 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<span class="text-[10px] text-gray-500 w-8 flex-shrink-0">{{ t('subscriptionProgress.monthly') }}</span>
<div class="flex-1 min-w-0 bg-gray-200 dark:bg-dark-600 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all"
:class="getProgressBarClass(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd)"
:style="{ width: getProgressWidth(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }"
></div>
</div>
<span class="text-[10px] text-gray-500 w-16 text-right">
<span class="text-[10px] text-gray-500 w-24 text-right flex-shrink-0">
{{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
</span>
</div>

View File

@@ -316,6 +316,7 @@ export interface Account {
platform: AccountPlatform;
type: AccountType;
credentials?: Record<string, unknown>;
extra?: CodexUsageSnapshot & Record<string, unknown>; // Extra fields including Codex usage
proxy_id: number | null;
concurrency: number;
priority: number;
@@ -361,6 +362,18 @@ export interface AccountUsageInfo {
seven_day_sonnet: UsageProgress | null;
}
// OpenAI Codex usage snapshot (from response headers)
export interface CodexUsageSnapshot {
codex_primary_used_percent?: number; // Weekly limit usage percentage
codex_primary_reset_after_seconds?: number; // Seconds until weekly reset
codex_primary_window_minutes?: number; // Weekly window in minutes
codex_secondary_used_percent?: number; // 5h limit usage percentage
codex_secondary_reset_after_seconds?: number; // Seconds until 5h reset
codex_secondary_window_minutes?: number; // 5h window in minutes
codex_primary_over_secondary_percent?: number; // Overflow ratio
codex_usage_updated_at?: string; // Last update timestamp
}
export interface CreateAccountRequest {
name: string;
platform: AccountPlatform;