diff --git a/pkg/providers/oauth.go b/pkg/providers/oauth.go index 9b7ea9d..2cb9d69 100644 --- a/pkg/providers/oauth.go +++ b/pkg/providers/oauth.go @@ -240,6 +240,12 @@ type OAuthAccountInfo struct { FailureCount int `json:"failure_count,omitempty"` LastFailure string `json:"last_failure,omitempty"` HealthScore int `json:"health_score,omitempty"` + PlanType string `json:"plan_type,omitempty"` + QuotaSource string `json:"quota_source,omitempty"` + BalanceLabel string `json:"balance_label,omitempty"` + BalanceDetail string `json:"balance_detail,omitempty"` + SubActiveStart string `json:"subscription_active_start,omitempty"` + SubActiveUntil string `json:"subscription_active_until,omitempty"` } type oauthAttempt struct { @@ -405,22 +411,7 @@ func (m *OAuthLoginManager) ListAccounts() ([]OAuthAccountInfo, error) { if session == nil { continue } - out = append(out, OAuthAccountInfo{ - Email: session.Email, - AccountID: session.AccountID, - CredentialFile: session.FilePath, - Expire: session.Expire, - LastRefresh: session.LastRefresh, - ProjectID: session.ProjectID, - AccountLabel: sessionLabel(session), - DeviceID: session.DeviceID, - ResourceURL: session.ResourceURL, - NetworkProxy: maskedProxyURL(session.NetworkProxy), - CooldownUntil: session.CooldownUntil, - FailureCount: session.FailureCount, - LastFailure: session.LastFailure, - HealthScore: sessionHealthScore(session), - }) + out = append(out, buildOAuthAccountInfo(session)) } return out, nil } @@ -443,26 +434,38 @@ func (m *OAuthLoginManager) RefreshAccount(ctx context.Context, credentialFile s if err != nil { return nil, err } - return &OAuthAccountInfo{ - Email: refreshed.Email, - AccountID: refreshed.AccountID, - CredentialFile: refreshed.FilePath, - Expire: refreshed.Expire, - LastRefresh: refreshed.LastRefresh, - ProjectID: refreshed.ProjectID, - AccountLabel: sessionLabel(refreshed), - DeviceID: refreshed.DeviceID, - ResourceURL: refreshed.ResourceURL, - NetworkProxy: maskedProxyURL(refreshed.NetworkProxy), - CooldownUntil: refreshed.CooldownUntil, - FailureCount: refreshed.FailureCount, - LastFailure: refreshed.LastFailure, - HealthScore: sessionHealthScore(refreshed), - }, nil + info := buildOAuthAccountInfo(refreshed) + return &info, nil } return nil, fmt.Errorf("oauth credential not found") } +func buildOAuthAccountInfo(session *oauthSession) OAuthAccountInfo { + planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil := extractOAuthBalanceMetadata(session) + return OAuthAccountInfo{ + Email: session.Email, + AccountID: session.AccountID, + CredentialFile: session.FilePath, + Expire: session.Expire, + LastRefresh: session.LastRefresh, + ProjectID: session.ProjectID, + AccountLabel: sessionLabel(session), + DeviceID: session.DeviceID, + ResourceURL: session.ResourceURL, + NetworkProxy: maskedProxyURL(session.NetworkProxy), + CooldownUntil: session.CooldownUntil, + FailureCount: session.FailureCount, + LastFailure: session.LastFailure, + HealthScore: sessionHealthScore(session), + PlanType: planType, + QuotaSource: quotaSource, + BalanceLabel: balanceLabel, + BalanceDetail: balanceDetail, + SubActiveStart: subActiveStart, + SubActiveUntil: subActiveUntil, + } +} + func (m *OAuthLoginManager) DeleteAccount(credentialFile string) error { if m == nil || m.manager == nil { return fmt.Errorf("oauth login manager not configured") @@ -1833,6 +1836,63 @@ func parseJWTClaims(token string) map[string]any { return claims } +func extractOAuthBalanceMetadata(session *oauthSession) (planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil string) { + if session == nil { + return "", "", "", "", "", "" + } + provider := normalizeOAuthProvider(session.Provider) + switch provider { + case defaultCodexOAuthProvider: + claims := parseJWTClaims(session.IDToken) + auth := mapFromAny(claims["https://api.openai.com/auth"]) + planType = firstNonEmpty(asString(auth["chatgpt_plan_type"]), asString(auth["plan_type"])) + subActiveStart = normalizeBalanceTime(firstNonEmpty(asString(auth["chatgpt_subscription_active_start"]), asString(auth["subscription_active_start"]))) + subActiveUntil = normalizeBalanceTime(firstNonEmpty(asString(auth["chatgpt_subscription_active_until"]), asString(auth["subscription_active_until"]))) + if planType != "" || subActiveUntil != "" || subActiveStart != "" { + quotaSource = provider + if planType != "" { + balanceLabel = strings.ToUpper(planType) + } else { + balanceLabel = "subscription" + } + switch { + case subActiveStart != "" && subActiveUntil != "": + balanceDetail = fmt.Sprintf("%s ~ %s", subActiveStart, subActiveUntil) + case subActiveUntil != "": + balanceDetail = fmt.Sprintf("until %s", subActiveUntil) + case subActiveStart != "": + balanceDetail = fmt.Sprintf("from %s", subActiveStart) + } + } + case defaultGeminiOAuthProvider, defaultAntigravityOAuthProvider: + if pid := strings.TrimSpace(session.ProjectID); pid != "" { + quotaSource = provider + balanceLabel = "project" + balanceDetail = pid + } + } + return planType, quotaSource, balanceLabel, balanceDetail, subActiveStart, subActiveUntil +} + +func normalizeBalanceTime(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, trimmed); err == nil { + return parsed.Format(time.RFC3339) + } + } + return trimmed +} + func firstNonEmpty(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { diff --git a/pkg/providers/oauth_test.go b/pkg/providers/oauth_test.go index 2502be6..9bbecbc 100644 --- a/pkg/providers/oauth_test.go +++ b/pkg/providers/oauth_test.go @@ -3,6 +3,7 @@ package providers import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "net/http" @@ -1136,6 +1137,67 @@ func TestOAuthManagerPrefersHealthierAccount(t *testing.T) { } } +func TestOAuthLoginManagerListAccountsIncludesCodexPlanMetadata(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + credFile := filepath.Join(dir, "codex-plan.json") + idToken := buildTestJWT(map[string]any{ + "email": "plan@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct-plan", + "chatgpt_plan_type": "pro", + "chatgpt_subscription_active_start": "2026-03-01T00:00:00Z", + "chatgpt_subscription_active_until": "2026-04-01T00:00:00Z", + }, + }) + raw, err := json.Marshal(oauthSession{ + Provider: "codex", + AccessToken: "token-plan", + RefreshToken: "refresh-plan", + IDToken: idToken, + Expire: time.Now().Add(time.Hour).Format(time.RFC3339), + }) + if err != nil { + t.Fatalf("marshal session failed: %v", err) + } + if err := os.WriteFile(credFile, raw, 0o600); err != nil { + t.Fatalf("write session failed: %v", err) + } + + manager, err := newOAuthManager(config.ProviderConfig{ + TimeoutSec: 5, + OAuth: config.ProviderOAuthConfig{ + Provider: "codex", + CredentialFile: credFile, + }, + }, 5*time.Second) + if err != nil { + t.Fatalf("new oauth manager failed: %v", err) + } + + accounts, err := (&OAuthLoginManager{manager: manager}).ListAccounts() + if err != nil { + t.Fatalf("list accounts failed: %v", err) + } + if len(accounts) != 1 { + t.Fatalf("expected one account, got %#v", accounts) + } + account := accounts[0] + if account.PlanType != "pro" { + t.Fatalf("expected plan type to be extracted, got %#v", account) + } + if account.BalanceLabel != "PRO" || account.SubActiveUntil != "2026-04-01T00:00:00Z" { + t.Fatalf("expected subscription metadata in account info, got %#v", account) + } +} + +func buildTestJWT(claims map[string]any) string { + header, _ := json.Marshal(map[string]any{"alg": "none", "typ": "JWT"}) + payload, _ := json.Marshal(claims) + return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "." +} + func TestClassifyOAuthFailureDifferentiatesReasons(t *testing.T) { t.Parallel() diff --git a/webui/src/components/FormControls.tsx b/webui/src/components/FormControls.tsx index acfcf79..aa00f65 100644 --- a/webui/src/components/FormControls.tsx +++ b/webui/src/components/FormControls.tsx @@ -37,6 +37,30 @@ type PanelFieldProps = FieldBlockProps & { dense?: boolean; }; +type CheckboxCardFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + +type ToolbarCheckboxFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + +type InlineCheckboxFieldProps = { + checked: boolean; + className?: string; + help?: React.ReactNode; + label: React.ReactNode; + onChange: (checked: boolean) => void; +}; + export function TextField({ dense = false, monospace = false, className, ...props }: TextFieldProps) { return ( ; } +export function CheckboxCardField({ + checked, + className, + help, + label, + onChange, +}: CheckboxCardFieldProps) { + return ( + + ); +} + +export function ToolbarCheckboxField({ + checked, + className, + help, + label, + onChange, +}: ToolbarCheckboxFieldProps) { + return ( + + ); +} + +export function InlineCheckboxField({ + checked, + className, + help, + label, + onChange, +}: InlineCheckboxFieldProps) { + return ( + + ); +} + export function FieldBlock({ label, help, meta, className, children }: FieldBlockProps) { return (