diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 5b22c645..b44f29fd 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -406,13 +406,22 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account } defer func() { _ = resp.Body.Close() }() + if isOAuth && s.accountRepo != nil { + if updates, err := extractOpenAICodexProbeUpdates(resp); err == nil && len(updates) > 0 { + _ = s.accountRepo.UpdateExtra(ctx, account.ID, updates) + mergeAccountExtra(account, updates) + } + if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { + if resetAt := codexRateLimitResetAtFromSnapshot(snapshot, time.Now()); resetAt != nil { + _ = s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt) + account.RateLimitResetAt = resetAt + } + } + } + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) if isOAuth && s.accountRepo != nil { - if updates, err := extractOpenAICodexProbeUpdates(resp); err == nil && len(updates) > 0 { - _ = s.accountRepo.UpdateExtra(ctx, account.ID, updates) - mergeAccountExtra(account, updates) - } if resetAt := (&RateLimitService{}).calculateOpenAI429ResetTime(resp.Header); resetAt != nil { _ = s.accountRepo.SetRateLimited(ctx, account.ID, *resetAt) account.RateLimitResetAt = resetAt diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go index 61a755a7..efa6f7da 100644 --- a/backend/internal/service/account_test_service_openai_test.go +++ b/backend/internal/service/account_test_service_openai_test.go @@ -4,7 +4,9 @@ package service import ( "context" + "io" "net/http" + "strings" "testing" "time" @@ -30,6 +32,40 @@ func (r *openAIAccountTestRepo) SetRateLimited(_ context.Context, id int64, rese return nil } +func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, recorder := newSoraTestContext() + + resp := newJSONResponse(http.StatusOK, "") + resp.Body = io.NopCloser(strings.NewReader(`data: {"type":"response.completed"} + +`)) + resp.Header.Set("x-codex-primary-used-percent", "88") + resp.Header.Set("x-codex-primary-reset-after-seconds", "604800") + resp.Header.Set("x-codex-primary-window-minutes", "10080") + resp.Header.Set("x-codex-secondary-used-percent", "42") + resp.Header.Set("x-codex-secondary-reset-after-seconds", "18000") + resp.Header.Set("x-codex-secondary-window-minutes", "300") + + repo := &openAIAccountTestRepo{} + upstream := &queuedHTTPUpstream{responses: []*http.Response{resp}} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream} + account := &Account{ + ID: 89, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{"access_token": "test-token"}, + } + + err := svc.testOpenAIAccountConnection(ctx, account, "gpt-5.4") + require.NoError(t, err) + require.NotEmpty(t, repo.updatedExtra) + require.Equal(t, 42.0, repo.updatedExtra["codex_5h_used_percent"]) + require.Equal(t, 88.0, repo.updatedExtra["codex_7d_used_percent"]) + require.Contains(t, recorder.Body.String(), "test_complete") +} + func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimit(t *testing.T) { gin.SetMode(gin.TestMode) ctx, _ := newSoraTestContext() diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 44c8e209..0026920d 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -412,14 +412,24 @@ const isActiveOpenAIRateLimited = computed(() => { }) const preferFetchedOpenAIUsage = computed(() => { - return isActiveOpenAIRateLimited.value && hasOpenAIUsageFallback.value + return (isActiveOpenAIRateLimited.value || isOpenAICodexSnapshotStale.value) && hasOpenAIUsageFallback.value }) const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account)) +const isOpenAICodexSnapshotStale = computed(() => { + if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false + const extra = props.account.extra as Record | undefined + const updatedAtRaw = extra?.codex_usage_updated_at + if (!updatedAtRaw) return true + const updatedAt = Date.parse(String(updatedAtRaw)) + if (Number.isNaN(updatedAt)) return true + return Date.now() - updatedAt >= 10 * 60 * 1000 +}) + const shouldAutoLoadUsageOnMount = computed(() => { if (props.account.platform === 'openai' && props.account.type === 'oauth') { - return isActiveOpenAIRateLimited.value || !hasCodexUsage.value + return isActiveOpenAIRateLimited.value || !hasCodexUsage.value || isOpenAICodexSnapshotStale.value } return shouldFetchUsage.value }) @@ -807,7 +817,7 @@ onMounted(() => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return - if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value) return + if (!isActiveOpenAIRateLimited.value && hasCodexUsage.value && !isOpenAICodexSnapshotStale.value) return loadUsage().catch((e) => { console.error('Failed to refresh OpenAI usage:', e) diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 400eafbb..2681f0cb 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -69,6 +69,67 @@ describe('AccountUsageCell', () => { }) + it('OpenAI OAuth 快照已过期时首屏会重新请求 usage', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 15, + resets_at: '2026-03-08T12:00:00Z', + remaining_seconds: 3600, + window_stats: { + requests: 3, + tokens: 300, + cost: 0.03, + standard_cost: 0.03, + user_cost: 0.03 + } + }, + seven_day: { + utilization: 77, + resets_at: '2026-03-13T12:00:00Z', + remaining_seconds: 3600, + window_stats: { + requests: 3, + tokens: 300, + cost: 0.03, + standard_cost: 0.03, + user_cost: 0.03 + } + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: { + id: 2000, + platform: 'openai', + type: 'oauth', + extra: { + codex_usage_updated_at: '2026-03-07T00:00:00Z', + codex_5h_used_percent: 12, + codex_5h_reset_at: '2026-03-08T12:00:00Z', + codex_7d_used_percent: 34, + codex_7d_reset_at: '2026-03-13T12:00:00Z' + } + } as any + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'utilization', 'resetsAt', 'windowStats', 'color'], + template: '
{{ label }}|{{ utilization }}|{{ windowStats?.tokens }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + + expect(getUsage).toHaveBeenCalledWith(2000) + expect(wrapper.text()).toContain('5h|15|300') + expect(wrapper.text()).toContain('7d|77|300') + }) + it('OpenAI OAuth 有现成快照且未限额时不会首屏请求 usage', async () => { const wrapper = mount(AccountUsageCell, { props: { @@ -77,6 +138,7 @@ describe('AccountUsageCell', () => { platform: 'openai', type: 'oauth', extra: { + codex_usage_updated_at: '2099-03-07T10:00:00Z', codex_5h_used_percent: 12, codex_5h_reset_at: '2099-03-07T12:00:00Z', codex_7d_used_percent: 34,