diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index ac93aa0c..f26ce03f 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2796,19 +2796,41 @@ func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot { return snapshot } -// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field -func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) { +func codexSnapshotBaseTime(snapshot *OpenAICodexUsageSnapshot, fallback time.Time) time.Time { if snapshot == nil { - return + return fallback } - if s == nil || s.accountRepo == nil { - return + if snapshot.UpdatedAt == "" { + return fallback + } + base, err := time.Parse(time.RFC3339, snapshot.UpdatedAt) + if err != nil { + return fallback + } + return base +} + +func codexResetAtRFC3339(base time.Time, resetAfterSeconds *int) *string { + if resetAfterSeconds == nil { + return nil + } + sec := *resetAfterSeconds + if sec < 0 { + sec = 0 + } + resetAt := base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339) + return &resetAt +} + +func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow time.Time) map[string]any { + if snapshot == nil { + return nil } - // Convert snapshot to map for merging into Extra + baseTime := codexSnapshotBaseTime(snapshot, fallbackNow) updates := make(map[string]any) - // Save raw primary/secondary fields for debugging/tracing + // 保存原始 primary/secondary 字段,便于排查问题 if snapshot.PrimaryUsedPercent != nil { updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent } @@ -2830,9 +2852,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc if snapshot.PrimaryOverSecondaryPercent != nil { updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent } - updates["codex_usage_updated_at"] = snapshot.UpdatedAt + updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339) - // Normalize to canonical 5h/7d fields + // 归一化到 5h/7d 规范字段 if normalized := snapshot.Normalize(); normalized != nil { if normalized.Used5hPercent != nil { updates["codex_5h_used_percent"] = *normalized.Used5hPercent @@ -2852,6 +2874,29 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc if normalized.Window7dMinutes != nil { updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes } + if reset5hAt := codexResetAtRFC3339(baseTime, normalized.Reset5hSeconds); reset5hAt != nil { + updates["codex_5h_reset_at"] = *reset5hAt + } + if reset7dAt := codexResetAtRFC3339(baseTime, normalized.Reset7dSeconds); reset7dAt != nil { + updates["codex_7d_reset_at"] = *reset7dAt + } + } + + return updates +} + +// 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 + } + if s == nil || s.accountRepo == nil { + return + } + + updates := buildCodexUsageExtraUpdates(snapshot, time.Now()) + if len(updates) == 0 { + return } // Update account's Extra field asynchronously diff --git a/backend/internal/service/openai_gateway_service_codex_snapshot_test.go b/backend/internal/service/openai_gateway_service_codex_snapshot_test.go new file mode 100644 index 00000000..654dd4ca --- /dev/null +++ b/backend/internal/service/openai_gateway_service_codex_snapshot_test.go @@ -0,0 +1,192 @@ +package service + +import ( + "testing" + "time" +) + +func TestCodexSnapshotBaseTime(t *testing.T) { + fallback := time.Date(2026, 2, 20, 9, 0, 0, 0, time.UTC) + + t.Run("nil snapshot uses fallback", func(t *testing.T) { + got := codexSnapshotBaseTime(nil, fallback) + if !got.Equal(fallback) { + t.Fatalf("got %v, want fallback %v", got, fallback) + } + }) + + t.Run("empty updatedAt uses fallback", func(t *testing.T) { + got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{}, fallback) + if !got.Equal(fallback) { + t.Fatalf("got %v, want fallback %v", got, fallback) + } + }) + + t.Run("valid updatedAt wins", func(t *testing.T) { + got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "2026-02-16T10:00:00Z"}, fallback) + want := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Fatalf("got %v, want %v", got, want) + } + }) + + t.Run("invalid updatedAt uses fallback", func(t *testing.T) { + got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "invalid"}, fallback) + if !got.Equal(fallback) { + t.Fatalf("got %v, want fallback %v", got, fallback) + } + }) +} + +func TestCodexResetAtRFC3339(t *testing.T) { + base := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC) + + t.Run("nil reset returns nil", func(t *testing.T) { + if got := codexResetAtRFC3339(base, nil); got != nil { + t.Fatalf("expected nil, got %v", *got) + } + }) + + t.Run("positive seconds", func(t *testing.T) { + sec := 90 + got := codexResetAtRFC3339(base, &sec) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != "2026-02-16T10:01:30Z" { + t.Fatalf("got %s, want %s", *got, "2026-02-16T10:01:30Z") + } + }) + + t.Run("negative seconds clamp to base", func(t *testing.T) { + sec := -3 + got := codexResetAtRFC3339(base, &sec) + if got == nil { + t.Fatal("expected non-nil") + } + if *got != "2026-02-16T10:00:00Z" { + t.Fatalf("got %s, want %s", *got, "2026-02-16T10:00:00Z") + } + }) +} + +func TestBuildCodexUsageExtraUpdates_UsesSnapshotUpdatedAt(t *testing.T) { + primaryUsed := 88.0 + primaryReset := 86400 + primaryWindow := 10080 + secondaryUsed := 12.0 + secondaryReset := 3600 + secondaryWindow := 300 + + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &primaryUsed, + PrimaryResetAfterSeconds: &primaryReset, + PrimaryWindowMinutes: &primaryWindow, + SecondaryUsedPercent: &secondaryUsed, + SecondaryResetAfterSeconds: &secondaryReset, + SecondaryWindowMinutes: &secondaryWindow, + UpdatedAt: "2026-02-16T10:00:00Z", + } + + updates := buildCodexUsageExtraUpdates(snapshot, time.Date(2026, 2, 20, 8, 0, 0, 0, time.UTC)) + if updates == nil { + t.Fatal("expected non-nil updates") + } + + if got := updates["codex_usage_updated_at"]; got != "2026-02-16T10:00:00Z" { + t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-16T10:00:00Z") + } + if got := updates["codex_5h_reset_at"]; got != "2026-02-16T11:00:00Z" { + t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T11:00:00Z") + } + if got := updates["codex_7d_reset_at"]; got != "2026-02-17T10:00:00Z" { + t.Fatalf("codex_7d_reset_at = %v, want %s", got, "2026-02-17T10:00:00Z") + } +} + +func TestBuildCodexUsageExtraUpdates_FallbackToNowWhenUpdatedAtInvalid(t *testing.T) { + primaryUsed := 15.0 + primaryReset := 30 + primaryWindow := 300 + + fallbackNow := time.Date(2026, 2, 20, 8, 30, 0, 0, time.UTC) + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &primaryUsed, + PrimaryResetAfterSeconds: &primaryReset, + PrimaryWindowMinutes: &primaryWindow, + UpdatedAt: "invalid-time", + } + + updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow) + if updates == nil { + t.Fatal("expected non-nil updates") + } + + if got := updates["codex_usage_updated_at"]; got != "2026-02-20T08:30:00Z" { + t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T08:30:00Z") + } + if got := updates["codex_5h_reset_at"]; got != "2026-02-20T08:30:30Z" { + t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-20T08:30:30Z") + } +} + +func TestBuildCodexUsageExtraUpdates_ClampNegativeResetSeconds(t *testing.T) { + primaryUsed := 90.0 + primaryReset := 7200 + primaryWindow := 10080 + secondaryUsed := 100.0 + secondaryReset := -15 + secondaryWindow := 300 + + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &primaryUsed, + PrimaryResetAfterSeconds: &primaryReset, + PrimaryWindowMinutes: &primaryWindow, + SecondaryUsedPercent: &secondaryUsed, + SecondaryResetAfterSeconds: &secondaryReset, + SecondaryWindowMinutes: &secondaryWindow, + UpdatedAt: "2026-02-16T10:00:00Z", + } + + updates := buildCodexUsageExtraUpdates(snapshot, time.Time{}) + if updates == nil { + t.Fatal("expected non-nil updates") + } + + if got := updates["codex_5h_reset_after_seconds"]; got != -15 { + t.Fatalf("codex_5h_reset_after_seconds = %v, want %d", got, -15) + } + if got := updates["codex_5h_reset_at"]; got != "2026-02-16T10:00:00Z" { + t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T10:00:00Z") + } +} + +func TestBuildCodexUsageExtraUpdates_NilSnapshot(t *testing.T) { + if got := buildCodexUsageExtraUpdates(nil, time.Now()); got != nil { + t.Fatalf("expected nil updates, got %v", got) + } +} + +func TestBuildCodexUsageExtraUpdates_WithoutNormalizedWindowFields(t *testing.T) { + primaryUsed := 42.0 + fallbackNow := time.Date(2026, 2, 20, 9, 15, 0, 0, time.UTC) + snapshot := &OpenAICodexUsageSnapshot{ + PrimaryUsedPercent: &primaryUsed, + UpdatedAt: "", + } + + updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow) + if updates == nil { + t.Fatal("expected non-nil updates") + } + + if got := updates["codex_usage_updated_at"]; got != "2026-02-20T09:15:00Z" { + t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T09:15:00Z") + } + if _, ok := updates["codex_5h_reset_at"]; ok { + t.Fatalf("did not expect codex_5h_reset_at in updates: %v", updates["codex_5h_reset_at"]) + } + if _, ok := updates["codex_7d_reset_at"]; ok { + t.Fatalf("did not expect codex_7d_reset_at in updates: %v", updates["codex_7d_reset_at"]) + } +} diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index c0212c5a..cada94c6 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -282,6 +282,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { adminAPI } from '@/api/admin' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' +import { resolveCodexUsageWindow } from '@/utils/codexUsage' import UsageProgressBar from './UsageProgressBar.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue' @@ -326,153 +327,18 @@ const geminiUsageAvailable = computed(() => { ) }) +const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h')) +const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d')) + // OpenAI Codex usage computed properties const hasCodexUsage = computed(() => { - const extra = props.account.extra - return ( - extra && - // Check for new canonical fields first - (extra.codex_5h_used_percent !== undefined || - extra.codex_7d_used_percent !== undefined || - // Fallback to legacy fields - extra.codex_primary_used_percent !== undefined || - extra.codex_secondary_used_percent !== undefined) - ) + return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null }) -// 5h window usage (prefer canonical field) -const codex5hUsedPercent = computed(() => { - const extra = props.account.extra - if (!extra) return null - - // Prefer canonical field - if (extra.codex_5h_used_percent !== undefined) { - return extra.codex_5h_used_percent - } - - // Fallback: detect from legacy fields using window_minutes - if ( - extra.codex_primary_window_minutes !== undefined && - extra.codex_primary_window_minutes <= 360 - ) { - return extra.codex_primary_used_percent ?? null - } - if ( - extra.codex_secondary_window_minutes !== undefined && - extra.codex_secondary_window_minutes <= 360 - ) { - return extra.codex_secondary_used_percent ?? null - } - - // Legacy assumption: secondary = 5h (may be incorrect) - return extra.codex_secondary_used_percent ?? null -}) - -const codex5hResetAt = computed(() => { - const extra = props.account.extra - if (!extra) return null - - // Prefer canonical field - if (extra.codex_5h_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_5h_reset_after_seconds * 1000) - return resetTime.toISOString() - } - - // Fallback: detect from legacy fields using window_minutes - if ( - extra.codex_primary_window_minutes !== undefined && - extra.codex_primary_window_minutes <= 360 - ) { - if (extra.codex_primary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - } - if ( - extra.codex_secondary_window_minutes !== undefined && - extra.codex_secondary_window_minutes <= 360 - ) { - if (extra.codex_secondary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - } - - // Legacy assumption: secondary = 5h - if (extra.codex_secondary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - - return null -}) - -// 7d window usage (prefer canonical field) -const codex7dUsedPercent = computed(() => { - const extra = props.account.extra - if (!extra) return null - - // Prefer canonical field - if (extra.codex_7d_used_percent !== undefined) { - return extra.codex_7d_used_percent - } - - // Fallback: detect from legacy fields using window_minutes - if ( - extra.codex_primary_window_minutes !== undefined && - extra.codex_primary_window_minutes >= 10000 - ) { - return extra.codex_primary_used_percent ?? null - } - if ( - extra.codex_secondary_window_minutes !== undefined && - extra.codex_secondary_window_minutes >= 10000 - ) { - return extra.codex_secondary_used_percent ?? null - } - - // Legacy assumption: primary = 7d (may be incorrect) - return extra.codex_primary_used_percent ?? null -}) - -const codex7dResetAt = computed(() => { - const extra = props.account.extra - if (!extra) return null - - // Prefer canonical field - if (extra.codex_7d_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_7d_reset_after_seconds * 1000) - return resetTime.toISOString() - } - - // Fallback: detect from legacy fields using window_minutes - if ( - extra.codex_primary_window_minutes !== undefined && - extra.codex_primary_window_minutes >= 10000 - ) { - if (extra.codex_primary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - } - if ( - extra.codex_secondary_window_minutes !== undefined && - extra.codex_secondary_window_minutes >= 10000 - ) { - if (extra.codex_secondary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - } - - // Legacy assumption: primary = 7d - if (extra.codex_primary_reset_after_seconds !== undefined) { - const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000) - return resetTime.toISOString() - } - - return null -}) +const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent) +const codex5hResetAt = computed(() => codex5hWindow.value.resetAt) +const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent) +const codex7dResetAt = computed(() => codex7dWindow.value.resetAt) // Antigravity quota types (用于 API 返回的数据) interface AntigravityUsageResult { diff --git a/frontend/src/utils/__tests__/codexUsage.spec.ts b/frontend/src/utils/__tests__/codexUsage.spec.ts new file mode 100644 index 00000000..cea8abe2 --- /dev/null +++ b/frontend/src/utils/__tests__/codexUsage.spec.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest' +import { resolveCodexUsageWindow } from '@/utils/codexUsage' + +describe('resolveCodexUsageWindow', () => { + it('快照为空时返回空窗口', () => { + const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z')) + expect(result).toEqual({ usedPercent: null, resetAt: null }) + }) + + it('优先使用后端提供的绝对重置时间', () => { + const now = new Date('2026-02-20T08:00:00Z') + const result = resolveCodexUsageWindow( + { + codex_5h_used_percent: 55, + codex_5h_reset_at: '2026-02-20T10:00:00Z', + codex_5h_reset_after_seconds: 1 + }, + '5h', + now + ) + + expect(result.usedPercent).toBe(55) + expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z') + }) + + it('窗口已过期时自动归零', () => { + const now = new Date('2026-02-20T08:00:00Z') + const result = resolveCodexUsageWindow( + { + codex_7d_used_percent: 100, + codex_7d_reset_at: '2026-02-20T07:00:00Z' + }, + '7d', + now + ) + + expect(result.usedPercent).toBe(0) + expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z') + }) + + it('无绝对时间时使用 updated_at + seconds 回退计算', () => { + const now = new Date('2026-02-20T07:00:00Z') + const result = resolveCodexUsageWindow( + { + codex_5h_used_percent: 20, + codex_5h_reset_after_seconds: 3600, + codex_usage_updated_at: '2026-02-20T06:30:00Z' + }, + '5h', + now + ) + + expect(result.usedPercent).toBe(20) + expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z') + }) + + it('支持 legacy primary/secondary 字段映射', () => { + const now = new Date('2026-02-20T07:05:00Z') + const result5h = resolveCodexUsageWindow( + { + codex_primary_window_minutes: 10080, + codex_primary_used_percent: 70, + codex_primary_reset_after_seconds: 86400, + codex_secondary_window_minutes: 300, + codex_secondary_used_percent: 15, + codex_secondary_reset_after_seconds: 1200, + codex_usage_updated_at: '2026-02-20T07:00:00Z' + }, + '5h', + now + ) + const result7d = resolveCodexUsageWindow( + { + codex_primary_window_minutes: 10080, + codex_primary_used_percent: 70, + codex_primary_reset_after_seconds: 86400, + codex_secondary_window_minutes: 300, + codex_secondary_used_percent: 15, + codex_secondary_reset_after_seconds: 1200, + codex_usage_updated_at: '2026-02-20T07:00:00Z' + }, + '7d', + now + ) + + expect(result5h.usedPercent).toBe(15) + expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z') + expect(result7d.usedPercent).toBe(70) + expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z') + }) + + it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => { + const result = resolveCodexUsageWindow( + { + codex_primary_window_minutes: '300', + codex_primary_used_percent: '21', + codex_primary_reset_after_seconds: '1800', + codex_secondary_window_minutes: '10080', + codex_secondary_used_percent: '99', + codex_secondary_reset_after_seconds: '99999', + codex_usage_updated_at: '2026-02-20T08:00:00Z' + }, + '5h', + new Date('2026-02-20T08:10:00Z') + ) + + expect(result.usedPercent).toBe(21) + expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z') + }) + + it('legacy 5h 在无窗口信息时回退 secondary', () => { + const result = resolveCodexUsageWindow( + { + codex_secondary_used_percent: 19, + codex_secondary_reset_after_seconds: 120, + codex_usage_updated_at: '2026-02-20T08:00:00Z' + }, + '5h', + new Date('2026-02-20T08:00:01Z') + ) + + expect(result.usedPercent).toBe(19) + expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z') + }) + + it('legacy 场景下 secondary 为 7d 时能正确识别', () => { + const now = new Date('2026-02-20T07:30:00Z') + const result = resolveCodexUsageWindow( + { + codex_primary_window_minutes: 300, + codex_primary_used_percent: 5, + codex_primary_reset_after_seconds: 600, + codex_secondary_window_minutes: 10080, + codex_secondary_used_percent: 66, + codex_secondary_reset_after_seconds: 7200, + codex_usage_updated_at: '2026-02-20T07:00:00Z' + }, + '7d', + now + ) + + expect(result.usedPercent).toBe(66) + expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z') + }) + + it('绝对时间非法时回退到 updated_at + seconds', () => { + const now = new Date('2026-02-20T07:40:00Z') + const result = resolveCodexUsageWindow( + { + codex_5h_used_percent: 33, + codex_5h_reset_at: 'not-a-date', + codex_5h_reset_after_seconds: 900, + codex_usage_updated_at: '2026-02-20T07:30:00Z' + }, + '5h', + now + ) + + expect(result.usedPercent).toBe(33) + expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z') + }) + + it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => { + const result = resolveCodexUsageWindow( + { + codex_5h_used_percent: 10, + codex_5h_reset_after_seconds: 123, + codex_usage_updated_at: 'invalid-time' + }, + '5h', + new Date('2026-02-20T08:00:00Z') + ) + + expect(result.usedPercent).toBe(10) + expect(result.resetAt).toBeNull() + }) + + it('reset_after_seconds 为负数时按 0 秒处理', () => { + const result = resolveCodexUsageWindow( + { + codex_5h_used_percent: 80, + codex_5h_reset_after_seconds: -30, + codex_usage_updated_at: '2026-02-20T08:00:00Z' + }, + '5h', + new Date('2026-02-20T07:59:00Z') + ) + + expect(result.usedPercent).toBe(80) + expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z') + }) + + it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => { + const result = resolveCodexUsageWindow( + { + codex_7d_reset_after_seconds: 60, + codex_usage_updated_at: '2026-02-20T08:00:00Z' + }, + '7d', + new Date('2026-02-20T08:00:01Z') + ) + + expect(result.usedPercent).toBeNull() + expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z') + }) +}) diff --git a/frontend/src/utils/codexUsage.ts b/frontend/src/utils/codexUsage.ts new file mode 100644 index 00000000..abe09d74 --- /dev/null +++ b/frontend/src/utils/codexUsage.ts @@ -0,0 +1,130 @@ +import type { CodexUsageSnapshot } from '@/types' + +export interface ResolvedCodexUsageWindow { + usedPercent: number | null + resetAt: string | null +} + +type WindowKind = '5h' | '7d' + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value) + if (Number.isFinite(n)) return n + } + return null +} + +function asString(value: unknown): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed === '' ? null : trimmed +} + +function asISOTime(value: unknown): string | null { + const raw = asString(value) + if (!raw) return null + const date = new Date(raw) + if (Number.isNaN(date.getTime())) return null + return date.toISOString() +} + +function resolveLegacy5h(snapshot: Record): { used: number | null; resetAfterSeconds: number | null } { + const primaryWindow = asNumber(snapshot.codex_primary_window_minutes) + const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes) + const primaryUsed = asNumber(snapshot.codex_primary_used_percent) + const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent) + const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds) + const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds) + + if (primaryWindow != null && primaryWindow <= 360) { + return { used: primaryUsed, resetAfterSeconds: primaryReset } + } + if (secondaryWindow != null && secondaryWindow <= 360) { + return { used: secondaryUsed, resetAfterSeconds: secondaryReset } + } + return { used: secondaryUsed, resetAfterSeconds: secondaryReset } +} + +function resolveLegacy7d(snapshot: Record): { used: number | null; resetAfterSeconds: number | null } { + const primaryWindow = asNumber(snapshot.codex_primary_window_minutes) + const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes) + const primaryUsed = asNumber(snapshot.codex_primary_used_percent) + const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent) + const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds) + const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds) + + if (primaryWindow != null && primaryWindow >= 10000) { + return { used: primaryUsed, resetAfterSeconds: primaryReset } + } + if (secondaryWindow != null && secondaryWindow >= 10000) { + return { used: secondaryUsed, resetAfterSeconds: secondaryReset } + } + return { used: primaryUsed, resetAfterSeconds: primaryReset } +} + +function resolveFromSeconds(snapshot: Record, resetAfterSeconds: number | null): string | null { + if (resetAfterSeconds == null) return null + + const baseRaw = asString(snapshot.codex_usage_updated_at) + const base = baseRaw ? new Date(baseRaw) : new Date() + if (Number.isNaN(base.getTime())) { + return null + } + + const sec = Math.max(0, resetAfterSeconds) + const resetAt = new Date(base.getTime() + sec * 1000) + return resetAt.toISOString() +} + +function applyExpiredRule(window: ResolvedCodexUsageWindow, now: Date): ResolvedCodexUsageWindow { + if (window.usedPercent == null || !window.resetAt) return window + const resetDate = new Date(window.resetAt) + if (Number.isNaN(resetDate.getTime())) return window + if (resetDate.getTime() <= now.getTime()) { + return { usedPercent: 0, resetAt: resetDate.toISOString() } + } + return window +} + +export function resolveCodexUsageWindow( + snapshot: (CodexUsageSnapshot & Record) | null | undefined, + window: WindowKind, + now: Date = new Date() +): ResolvedCodexUsageWindow { + if (!snapshot) { + return { usedPercent: null, resetAt: null } + } + + const typedSnapshot = snapshot as Record + let usedPercent: number | null + let resetAfterSeconds: number | null + let resetAt: string | null + + if (window === '5h') { + usedPercent = asNumber(typedSnapshot.codex_5h_used_percent) + resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds) + resetAt = asISOTime(typedSnapshot.codex_5h_reset_at) + if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) { + const legacy = resolveLegacy5h(typedSnapshot) + if (usedPercent == null) usedPercent = legacy.used + if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds + } + } else { + usedPercent = asNumber(typedSnapshot.codex_7d_used_percent) + resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds) + resetAt = asISOTime(typedSnapshot.codex_7d_reset_at) + if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) { + const legacy = resolveLegacy7d(typedSnapshot) + if (usedPercent == null) usedPercent = legacy.used + if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds + } + } + + if (!resetAt) { + resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds) + } + + return applyExpiredRule({ usedPercent, resetAt }, now) +}