Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions backend/internal/service/openai_gateway_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,15 +126,20 @@ type NormalizedCodexLimits struct {
Window7dMinutes *int
}

func normalizeCodexFiveHourUsedPercent(raw *float64) *float64 {
func normalizeCodexFiveHourUsedPercent(raw *float64, fromPrimary bool) *float64 {
if raw == nil {
return nil
}
// OpenAI's 5h Codex quota header is remaining%, despite the upstream header
// name saying "used"; the canonical codex_5h_used_percent field stores used%.
used := 100 - *raw
used := *raw
if !fromPrimary {
// Legacy OpenAI secondary 5h quota snapshots report remaining%, despite the
// upstream header name saying "used"; the canonical field stores used%.
used = 100 - *raw
}
if used < 0 {
used = 0
} else if used > 100 {
used = 100
}
return &used
}
Expand Down Expand Up @@ -197,7 +202,7 @@ func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {

// Assign values
if use5hFromPrimary {
result.Used5hPercent = normalizeCodexFiveHourUsedPercent(s.PrimaryUsedPercent)
result.Used5hPercent = normalizeCodexFiveHourUsedPercent(s.PrimaryUsedPercent, true)
result.Reset5hSeconds = s.PrimaryResetAfterSeconds
result.Window5hMinutes = s.PrimaryWindowMinutes
result.Used7dPercent = s.SecondaryUsedPercent
Expand All @@ -207,7 +212,7 @@ func (s *OpenAICodexUsageSnapshot) Normalize() *NormalizedCodexLimits {
result.Used7dPercent = s.PrimaryUsedPercent
result.Reset7dSeconds = s.PrimaryResetAfterSeconds
result.Window7dMinutes = s.PrimaryWindowMinutes
result.Used5hPercent = normalizeCodexFiveHourUsedPercent(s.SecondaryUsedPercent)
result.Used5hPercent = normalizeCodexFiveHourUsedPercent(s.SecondaryUsedPercent, false)
result.Reset5hSeconds = s.SecondaryResetAfterSeconds
result.Window5hMinutes = s.SecondaryWindowMinutes
}
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/service/openai_gateway_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1774,7 +1774,7 @@ func TestOpenAIUpdateCodexUsageSnapshotFromHeaders(t *testing.T) {

select {
case updates := <-repo.updateExtraCalls:
require.Equal(t, 88.0, updates["codex_5h_used_percent"])
require.Equal(t, 12.0, updates["codex_5h_used_percent"])
require.Equal(t, 34.0, updates["codex_7d_used_percent"])
require.Equal(t, 600, updates["codex_5h_reset_after_seconds"])
require.Equal(t, 86400, updates["codex_7d_reset_after_seconds"])
Expand Down
67 changes: 66 additions & 1 deletion backend/internal/service/ratelimit_service_openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {

// Test when OpenAI sends primary as 5h and secondary as 7d (reversed)
headers := http.Header{}
headers.Set("x-codex-primary-used-percent", "0") // This is 5h remaining%
headers.Set("x-codex-primary-used-percent", "100")
headers.Set("x-codex-primary-reset-after-seconds", "3600") // 1 hour
headers.Set("x-codex-primary-window-minutes", "300") // 5 hours - smaller!
headers.Set("x-codex-secondary-used-percent", "50")
Expand All @@ -147,6 +147,34 @@ func TestCalculateOpenAI429ResetTime_ReversedWindowOrder(t *testing.T) {
}
}

func TestCalculateOpenAI429ResetTime_Primary5hExhaustedLiveScenario(t *testing.T) {
svc := &RateLimitService{}

headers := http.Header{}
headers.Set("x-codex-primary-used-percent", "100")
headers.Set("x-codex-primary-reset-after-seconds", "5919")
headers.Set("x-codex-primary-window-minutes", "300")
headers.Set("x-codex-secondary-used-percent", "36")
headers.Set("x-codex-secondary-reset-after-seconds", "448655")
headers.Set("x-codex-secondary-window-minutes", "10080")

before := time.Now()
resetAt := svc.calculateOpenAI429ResetTime(headers)
after := time.Now()

if resetAt == nil {
t.Fatal("expected non-nil resetAt")
}

expectedDuration := 5919 * time.Second
minExpected := before.Add(expectedDuration)
maxExpected := after.Add(expectedDuration)

if resetAt.Before(minExpected) || resetAt.After(maxExpected) {
t.Errorf("resetAt %v not in expected 5h range [%v, %v]", resetAt, minExpected, maxExpected)
}
}

type openAI429SnapshotRepo struct {
mockAccountRepoForGemini
rateLimitedID int64
Expand Down Expand Up @@ -257,6 +285,43 @@ func TestNormalizedCodexLimits(t *testing.T) {
}
}

func TestNormalizedCodexLimits_Primary5hReportsUsedPercent(t *testing.T) {
pUsed := 0.0
pReset := 17523
pWindow := 300
sUsed := 36.0
sReset := 441984
sWindow := 10080

snapshot := &OpenAICodexUsageSnapshot{
PrimaryUsedPercent: &pUsed,
PrimaryResetAfterSeconds: &pReset,
PrimaryWindowMinutes: &pWindow,
SecondaryUsedPercent: &sUsed,
SecondaryResetAfterSeconds: &sReset,
SecondaryWindowMinutes: &sWindow,
PrimaryOverSecondaryPercent: &pUsed,
}

normalized := snapshot.Normalize()
if normalized == nil {
t.Fatal("expected non-nil normalized")
}

if normalized.Used5hPercent == nil || *normalized.Used5hPercent != 0.0 {
t.Errorf("expected Used5hPercent=0, got %v", normalized.Used5hPercent)
}
if normalized.Reset5hSeconds == nil || *normalized.Reset5hSeconds != 17523 {
t.Errorf("expected Reset5hSeconds=17523, got %v", normalized.Reset5hSeconds)
}
if normalized.Used7dPercent == nil || *normalized.Used7dPercent != 36.0 {
t.Errorf("expected Used7dPercent=36, got %v", normalized.Used7dPercent)
}
if normalized.Reset7dSeconds == nil || *normalized.Reset7dSeconds != 441984 {
t.Errorf("expected Reset7dSeconds=441984, got %v", normalized.Reset7dSeconds)
}
}

func TestNormalizedCodexLimits_OnlyPrimaryData(t *testing.T) {
// Test when only primary has data, no window_minutes
pUsed := 80.0
Expand Down
Loading