diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1296ef1b..52442ffe 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -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 +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 08ce2593..67400452 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -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) + }() +} diff --git a/backend/internal/service/ports/account.go b/backend/internal/service/ports/account.go index 96788f08..e2beb10e 100644 --- a/backend/internal/service/ports/account.go +++ b/backend/internal/service/ports/account.go @@ -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 } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index d2aee588..e47de5da 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -68,7 +68,31 @@ - + + + + @@ -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 diff --git a/frontend/src/components/common/SubscriptionProgressMini.vue b/frontend/src/components/common/SubscriptionProgressMini.vue index 0a0277f0..17cbb0b9 100644 --- a/frontend/src/components/common/SubscriptionProgressMini.vue +++ b/frontend/src/components/common/SubscriptionProgressMini.vue @@ -29,7 +29,7 @@

@@ -62,43 +62,43 @@
- {{ t('subscriptionProgress.daily') }} -
+ {{ t('subscriptionProgress.daily') }} +
- + {{ formatUsage(subscription.daily_usage_usd, subscription.group?.daily_limit_usd) }}
- {{ t('subscriptionProgress.weekly') }} -
+ {{ t('subscriptionProgress.weekly') }} +
- + {{ formatUsage(subscription.weekly_usage_usd, subscription.group?.weekly_limit_usd) }}
- {{ t('subscriptionProgress.monthly') }} -
+ {{ t('subscriptionProgress.monthly') }} +
- + {{ formatUsage(subscription.monthly_usage_usd, subscription.group?.monthly_limit_usd) }}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 330ecc2b..e3168863 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -316,6 +316,7 @@ export interface Account { platform: AccountPlatform; type: AccountType; credentials?: Record; + extra?: CodexUsageSnapshot & Record; // 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;