Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog/unreleased/codex-usage-windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Codex account cards now read the current usage response, so the 5-hour and weekly windows show up instead of staying on "Getting quota…". The subscription tier from that response is shown above the windows.

### 中文

- Codex 账号卡片现在按当前额度响应读取窗口,5 小时和每周额度会显示出来,不再一直停在「额度获取中…」。响应里的订阅等级会显示在额度窗口上方。
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type AccountQuota = {
resource_package_unit?: string
resource_package_available?: boolean
fetched_at?: string
plan?: string
}

export type ModelInfo = {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/account/QuotaMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Meter, Tooltip } from '@heroui/react'
import type { AccountQuota, AccountQuotaWindow } from '@/api/types'
import {
codexPlanLabel,
formatQuotaAmount,
quotaExpiryLabel,
quotaResetLabel,
Expand Down Expand Up @@ -100,6 +101,7 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa
const remaining = `${formatQuotaAmount(quota.remaining)}`
const extra = extraQuotaLines(quota, remainingLabel, addOnLabel, resourcePackageLabel, t, provider)
const windows = quotaWindows(quota)
const plan = provider === 'codex' ? codexPlanLabel(quota.plan) : ''
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
const color = tone === 'danger' ? 'danger' : tone === 'warn' ? 'warning' : 'success'
Expand All @@ -108,6 +110,12 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa
if (windows.length > 0) {
return (
<div className="account-quota-windows flex w-full min-w-0 flex-col gap-3">
{plan ? (
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-muted">{t('quotaPlan')}</span>
<span className="font-medium text-foreground">{plan}</span>
</div>
) : null}
{windows.map((window, index) => (
<div key={window.id || String(index)} className="w-full min-w-0">
<WindowMeter
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ export const messages: Record<Lang, Dict> = {
quotaMonthly: 'Monthly quota',
quotaFiveHour: '5-Hour Limit',
quotaWeeklyLimit: 'Weekly Limit',
quotaPlan: 'Plan',
quotaMonthlyLimit: 'Monthly Limit',
quotaUsedPercent: '{n}% used',
quotaResetsSoon: 'Resets soon',
Expand Down Expand Up @@ -1177,6 +1178,7 @@ export const messages: Record<Lang, Dict> = {
quotaMonthly: '月额度',
quotaFiveHour: '5 小时额度',
quotaWeeklyLimit: '周额度',
quotaPlan: '订阅',
quotaMonthlyLimit: '月额度',
quotaUsedPercent: '{n}%',
quotaResetsSoon: '即将重置',
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,30 @@ export function quotaWindows(quota: AccountQuota) {
return (quota.windows ?? []).filter((window) => Boolean(window.id))
}

const codexPlanLabels: Record<string, string> = {
free: 'Free',
go: 'Go',
plus: 'Plus',
pro: 'Pro',
prolite: 'Pro Lite',
promax: 'Pro Max',
team: 'Team',
business: 'Business',
enterprise: 'Enterprise',
edu: 'Edu',
edu_plus: 'Edu Plus',
edu_pro: 'Edu Pro',
education: 'Education',
}

// codexPlanLabel turns an upstream plan_type into the subscription name shown
// on the account card. Unknown values stay readable; empty stays hidden.
export function codexPlanLabel(plan: string | undefined) {
const raw = plan?.trim().toLowerCase() ?? ''
if (!raw || raw === 'unknown' || raw === 'guest') return ''
return codexPlanLabels[raw] ?? raw.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
}

export function quotaWindowLabel(window: AccountQuotaWindow, t: (key: string) => string) {
if (window.id === 'daily') return t('quotaDaily')
if (window.id === 'weekly') return t('quotaWeekly')
Expand Down
2 changes: 2 additions & 0 deletions internal/accounts/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,6 @@ type QuotaSnapshot struct {
ResourcePackageUnit string `json:"resource_package_unit"`
ResourcePackageAvailable *bool `json:"resource_package_available,omitempty"`
FetchedAt string `json:"fetched_at"`
// Plan is the upstream subscription tier. Empty means not reported.
Plan string `json:"plan,omitempty"`
}
62 changes: 44 additions & 18 deletions internal/providers/codex/quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,60 @@ func quotaFromHeaders(h http.Header) *providers.QuotaInfo {
return quotaFromWindows(primary, secondary, h.Get("X-Codex-Plan-Type"))
}

// quotaFromUsage decodes the /wham/usage body. The shape matches the websocket
// codex.rate_limits event: rate_limits.primary / secondary each carry
// used_percent, window_minutes, and reset_after_seconds or reset_at.
// quotaFromUsage decodes GET /wham/usage. The current ChatGPT payload is
// rate_limit.primary_window / secondary_window, each with used_percent,
// limit_window_seconds, reset_after_seconds, and reset_at. Older responses
// and the websocket codex.rate_limits event nest the same windows under
// rate_limits.primary / secondary with window_minutes instead.
func quotaFromUsage(body []byte) *providers.QuotaInfo {
var payload struct {
RateLimits struct {
Primary codexRateWindow `json:"primary"`
Secondary codexRateWindow `json:"secondary"`
} `json:"rate_limits"`
PlanType string `json:"plan_type"`
}
var payload usagePayload
if json.Unmarshal(body, &payload) != nil {
return nil
}
primary := payload.RateLimits.Primary.window("fiveHour", "5-hour limit")
secondary := payload.RateLimits.Secondary.window("weeklyLimit", "weekly limit")
primary, secondary := payload.windows()
return quotaFromWindows(primary, secondary, payload.PlanType)
}

type usagePayload struct {
PlanType string `json:"plan_type"`
RateLimit *codexLimitDetails `json:"rate_limit"`
RateLimits struct {
Primary codexRateWindow `json:"primary"`
Secondary codexRateWindow `json:"secondary"`
} `json:"rate_limits"`
}

func (p usagePayload) windows() (*providers.QuotaWindow, *providers.QuotaWindow) {
if p.RateLimit != nil {
primary := p.RateLimit.PrimaryWindow.window("fiveHour", "5-hour limit")
secondary := p.RateLimit.SecondaryWindow.window("weeklyLimit", "weekly limit")
if primary != nil || secondary != nil {
return primary, secondary
}
}
return p.RateLimits.Primary.window("fiveHour", "5-hour limit"),
p.RateLimits.Secondary.window("weeklyLimit", "weekly limit")
}

type codexLimitDetails struct {
PrimaryWindow codexRateWindow `json:"primary_window"`
SecondaryWindow codexRateWindow `json:"secondary_window"`
}

type codexRateWindow struct {
UsedPercent float64 `json:"used_percent"`
WindowMinutes int64 `json:"window_minutes"`
ResetAfterSeconds int64 `json:"reset_after_seconds"`
ResetAt int64 `json:"reset_at"`
UsedPercent float64 `json:"used_percent"`
WindowMinutes int64 `json:"window_minutes"`
LimitWindowSeconds int64 `json:"limit_window_seconds"`
ResetAfterSeconds int64 `json:"reset_after_seconds"`
ResetAt int64 `json:"reset_at"`
}

func (w codexRateWindow) window(id, label string) *providers.QuotaWindow {
if w.UsedPercent < 0 || w.UsedPercent > 100 || w.WindowMinutes <= 0 {
minutes := w.WindowMinutes
if minutes <= 0 && w.LimitWindowSeconds > 0 {
minutes = w.LimitWindowSeconds / 60
}
if w.UsedPercent < 0 || w.UsedPercent > 100 || minutes <= 0 {
return nil
}
if w.ResetAfterSeconds < 0 && w.ResetAt <= 0 {
Expand Down Expand Up @@ -92,7 +118,6 @@ func quotaFromWindows(primary, secondary *providers.QuotaWindow, planType string
}
exceeded = exceeded || secondary.Exceeded
}
_ = planType
return &providers.QuotaInfo{
Used: usedPct,
Total: 100,
Expand All @@ -103,6 +128,7 @@ func quotaFromWindows(primary, secondary *providers.QuotaWindow, planType string
FetchedAt: time.Now().UTC().Format(time.RFC3339),
Windows: windows,
ProviderID: "codex",
Plan: strings.TrimSpace(planType),
}
}

Expand Down
25 changes: 25 additions & 0 deletions internal/providers/codex/quota_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ import (
"testing"
)

func TestQuotaFromUsageReadsCurrentWindows(t *testing.T) {
body := []byte(`{
"plan_type":"plus",
"rate_limit":{
"allowed":true,
"limit_reached":false,
"primary_window":{"used_percent":0,"limit_window_seconds":18000,"reset_after_seconds":10800,"reset_at":1893456000},
"secondary_window":{"used_percent":7,"limit_window_seconds":604800,"reset_after_seconds":432000,"reset_at":1894000000}
}
}`)
info := quotaFromUsage(body)
if info == nil || len(info.Windows) != 2 {
t.Fatalf("windows = %+v", info)
}
if info.Windows[0].ID != "fiveHour" || info.Windows[0].Percentage != 0 || info.Windows[0].ResetAt == "" {
t.Fatalf("primary = %+v", info.Windows[0])
}
if info.Windows[1].ID != "weeklyLimit" || info.Windows[1].Percentage != 7 || info.Percentage != 7 {
t.Fatalf("secondary = %+v info=%+v", info.Windows[1], info)
}
if info.Plan != "plus" {
t.Fatalf("plan = %q", info.Plan)
}
}

func TestQuotaFromUsageReadsRateLimitWindows(t *testing.T) {
body := []byte(`{
"plan_type":"plus",
Expand Down
3 changes: 3 additions & 0 deletions internal/providers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ type QuotaInfo struct {
// to gate provider-specific fields (e.g. package expiry) instead of
// trusting that every adapter populates them.
ProviderID string
// Plan is the upstream subscription tier, such as Codex plan_type "plus".
// Empty means the provider did not report one.
Plan string
// ExpiresAt is the soonest package expiry (Unix seconds); 0 means the
// provider did not report one. ExpiringRemain is the remaining amount that
// expires at that time. Packages carries the per-pack expiry detail.
Expand Down
4 changes: 4 additions & 0 deletions internal/runtime/manager_quota.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"log"
"net/http"
"strings"
"time"

"github.com/caigee-cmd/cli2api/internal/accounts"
Expand Down Expand Up @@ -46,6 +47,9 @@ func (m *Manager) fetchProviderQuota(ctx context.Context, accountID string, prob
quota.ExpiringRemain = info.ExpiringRemain
quota.Packages = quotaPackagesFromInfo(info.Packages)
}
if info.ProviderID == "codex" {
quota.Plan = strings.TrimSpace(info.Plan)
}
m.persistQuota(ctx, accountID, quota)
}

Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-oNlXVfto.js"></script>
<script type="module" crossorigin src="/assets/index-CQrAj8e4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BEwWCUnU.css">
</head>
<body>
Expand Down
Loading