Skip to content
Open
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
41 changes: 41 additions & 0 deletions internal/runtime/executor/codex_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,7 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
auth.Metadata["account_id"] = td.AccountID
}
auth.Metadata["email"] = td.Email
applyCodexCapacityClaims(auth, td.IDToken)
// Use unified key in files
auth.Metadata["expired"] = td.Expire
auth.Metadata["type"] = "codex"
Expand All @@ -1410,6 +1411,46 @@ func (e *CodexExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*
return auth, nil
}

func applyCodexCapacityClaims(auth *cliproxyauth.Auth, idToken string) {
if auth == nil || strings.TrimSpace(idToken) == "" {
return
}
claims, errParse := codexauth.ParseJWTToken(idToken)
if errParse != nil || claims == nil {
if errParse != nil {
log.Warnf("codex executor: failed to parse refreshed capacity claims: %v", errParse)
}
return
}

info := claims.CodexAuthInfo
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
if planType := strings.TrimSpace(info.ChatgptPlanType); planType != "" {
auth.Attributes["plan_type"] = planType
auth.Metadata["chatgpt_plan_type"] = planType
}
if accountID := strings.TrimSpace(info.ChatgptAccountID); accountID != "" {
auth.Metadata["chatgpt_account_id"] = accountID
} else {
delete(auth.Metadata, "chatgpt_account_id")
}
if info.ChatgptSubscriptionActiveStart != nil {
auth.Metadata["chatgpt_subscription_active_start"] = info.ChatgptSubscriptionActiveStart
} else {
delete(auth.Metadata, "chatgpt_subscription_active_start")
}
if info.ChatgptSubscriptionActiveUntil != nil {
auth.Metadata["chatgpt_subscription_active_until"] = info.ChatgptSubscriptionActiveUntil
} else {
delete(auth.Metadata, "chatgpt_subscription_active_until")
}
}

type codexIdentityConfuseState struct {
enabled bool
authID string
Expand Down
97 changes: 97 additions & 0 deletions internal/runtime/executor/codex_executor_refresh_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package executor

import (
"encoding/base64"
"encoding/json"
"testing"

cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
)

func TestApplyCodexCapacityClaimsUpdatesPlanType(t *testing.T) {
auth := &cliproxyauth.Auth{
ID: "codex-auth",
Provider: "codex",
Attributes: map[string]string{
"plan_type": "free",
},
Metadata: map[string]any{
"chatgpt_plan_type": "free",
},
}

applyCodexCapacityClaims(auth, codexCapacityTestJWT(t, map[string]any{
"email": "user@example.com",
"https://api.openai.com/auth": map[string]any{
"chatgpt_account_id": "acct-123",
"chatgpt_plan_type": "plus",
"chatgpt_subscription_active_start": "2026-06-12T00:00:00Z",
"chatgpt_subscription_active_until": "2026-07-12T00:00:00Z",
"chatgpt_subscription_last_checked": "2026-06-12T00:01:00Z",
},
}))

if got := auth.Attributes["plan_type"]; got != "plus" {
t.Fatalf("plan_type attribute = %q, want plus", got)
}
if got := auth.Metadata["chatgpt_plan_type"]; got != "plus" {
t.Fatalf("chatgpt_plan_type metadata = %q, want plus", got)
}
if got := auth.Metadata["chatgpt_account_id"]; got != "acct-123" {
t.Fatalf("chatgpt_account_id metadata = %q, want acct-123", got)
}
if got := auth.Metadata["chatgpt_subscription_active_until"]; got != "2026-07-12T00:00:00Z" {
t.Fatalf("chatgpt_subscription_active_until metadata = %q, want 2026-07-12T00:00:00Z", got)
}
}

func TestApplyCodexCapacityClaimsPreservesMissingPlanAndClearsMissingValues(t *testing.T) {
auth := &cliproxyauth.Auth{
ID: "codex-auth",
Provider: "codex",
Attributes: map[string]string{
"plan_type": "plus",
},
Metadata: map[string]any{
"chatgpt_account_id": "acct-123",
"chatgpt_plan_type": "plus",
"chatgpt_subscription_active_start": "2026-06-12T00:00:00Z",
"chatgpt_subscription_active_until": "2026-07-12T00:00:00Z",
},
}

applyCodexCapacityClaims(auth, codexCapacityTestJWT(t, map[string]any{
"email": "user@example.com",
"https://api.openai.com/auth": map[string]any{},
}))

if got := auth.Attributes["plan_type"]; got != "plus" {
t.Fatalf("expected missing plan claim to preserve plan_type attribute, got %q", got)
}
if got := auth.Metadata["chatgpt_plan_type"]; got != "plus" {
t.Fatalf("expected missing plan claim to preserve chatgpt_plan_type metadata, got %q", got)
}
for _, key := range []string{
"chatgpt_account_id",
"chatgpt_subscription_active_start",
"chatgpt_subscription_active_until",
} {
if _, ok := auth.Metadata[key]; ok {
t.Fatalf("expected missing claim to clear metadata %q", key)
}
}
}

func codexCapacityTestJWT(t *testing.T, claims map[string]any) string {
t.Helper()
header, errMarshalHeader := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
if errMarshalHeader != nil {
t.Fatalf("marshal jwt header: %v", errMarshalHeader)
}
payload, errMarshalPayload := json.Marshal(claims)
if errMarshalPayload != nil {
t.Fatalf("marshal jwt claims: %v", errMarshalPayload)
}
return base64.RawURLEncoding.EncodeToString(header) + "." +
base64.RawURLEncoding.EncodeToString(payload) + "."
}
195 changes: 191 additions & 4 deletions sdk/cliproxy/auth/conductor.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net/http"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -1425,6 +1426,178 @@ func (m *Manager) Register(ctx context.Context, auth *Auth) (*Auth, error) {
return auth.Clone(), nil
}

// ShouldInheritModelStates reports whether an active auth update should keep
// previous per-model runtime availability. Capacity identity changes, such as a
// Codex plan upgrade, must clear stale quota cooldowns so the account can be
// retried immediately with its new limits.
func ShouldInheritModelStates(existing, incoming *Auth) bool {
if existing == nil || incoming == nil {
return false
}
if existing.Disabled || existing.Status == StatusDisabled || incoming.Disabled || incoming.Status == StatusDisabled {
return false
}
if len(incoming.ModelStates) > 0 || len(existing.ModelStates) == 0 {
return false
}
return !capacityIdentityChanged(existing, incoming)
}

func shouldClearModelStates(existing, incoming *Auth) bool {
if existing == nil || incoming == nil || len(existing.ModelStates) == 0 || len(incoming.ModelStates) == 0 {
return false
}
if existing.Disabled || existing.Status == StatusDisabled || incoming.Disabled || incoming.Status == StatusDisabled {
return true
}
return capacityIdentityChanged(existing, incoming)
}

var capacityIdentityKeys = map[string]struct{}{
"account_id": {},
"account_uuid": {},
"accountid": {},
"balance": {},
"chatgpt_account_id": {},
"chatgpt_plan_type": {},
"chatgpt_subscription_active_start": {},
"chatgpt_subscription_active_until": {},
"credit": {},
"credits": {},
"organization_id": {},
"org_id": {},
"plan": {},
"plan_type": {},
"project_id": {},
"quota": {},
"quota_limit": {},
"service_tier": {},
"subscription": {},
"subscription_plan": {},
"subscription_status": {},
"team_id": {},
"tier": {},
}

func authCapacityIdentitySignature(auth *Auth) map[string]string {
if auth == nil {
return nil
}
signature := make(map[string]string)
for key, value := range auth.Attributes {
normalized := strings.ToLower(strings.TrimSpace(key))
canonical := capacityIdentityCanonicalKey(normalized)
if canonical == "" {
continue
}
val := strings.TrimSpace(value)
if val == "" {
continue
}
signature[canonical] = val
}
for key, value := range auth.Metadata {
normalized := strings.ToLower(strings.TrimSpace(key))
canonical := capacityIdentityCanonicalKey(normalized)
if canonical == "" {
continue
}
val := capacityIdentityValue(value)
if val == "" {
continue
}
if _, exists := signature[canonical]; exists {
continue
}
signature[canonical] = val
}
return signature
}

func capacityIdentityCanonicalKey(normalized string) string {
if _, ok := capacityIdentityKeys[normalized]; !ok {
return ""
}
switch normalized {
case "account_id", "account_uuid", "accountid", "chatgpt_account_id":
return "account_id"
case "chatgpt_plan_type", "plan", "plan_type", "subscription_plan":
return "plan_type"
case "chatgpt_subscription_active_start":
return "subscription_active_start"
case "chatgpt_subscription_active_until":
return "subscription_active_until"
case "credit", "credits":
return "credits"
case "organization_id", "org_id":
return "org_id"
default:
return normalized
}
}

func capacityIdentityValue(value any) string {
if value == nil {
return ""
}
val := reflect.ValueOf(value)
for val.Kind() == reflect.Ptr {
if val.IsNil() {
return ""
}
val = val.Elem()
}
underlying := val.Interface()
switch typed := underlying.(type) {
case string:
return strings.TrimSpace(typed)
case []byte:
return strings.TrimSpace(string(typed))
case json.Number:
return strings.TrimSpace(typed.String())
case bool:
if typed {
return "true"
}
return "false"
case time.Time:
return typed.Format(time.RFC3339)
default:
raw, err := json.Marshal(typed)
if err != nil {
return ""
}
return strings.TrimSpace(string(raw))
}
}

func capacityIdentityChanged(existing, incoming *Auth) bool {
existingSignature := authCapacityIdentitySignature(existing)
if len(existingSignature) == 0 {
return false
}
incomingSignature := authCapacityIdentitySignature(incoming)
for key, existingValue := range existingSignature {
incomingValue, ok := incomingSignature[key]
if !ok || incomingValue != existingValue {
return true
}
}
return false
}

func capacityIdentitySignatureEqual(left, right map[string]string) bool {
if len(left) != len(right) {
return false
}
for key, leftValue := range left {
if right[key] != leftValue {
return false
}
}
return true
}

// Update replaces an existing auth entry and notifies hooks.
func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
if auth == nil || auth.ID == "" {
Expand All @@ -1443,10 +1616,10 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
auth.Success = existing.Success
auth.Failed = existing.Failed
auth.recentRequests = existing.recentRequests
if !existing.Disabled && existing.Status != StatusDisabled && !auth.Disabled && auth.Status != StatusDisabled {
if len(auth.ModelStates) == 0 && len(existing.ModelStates) > 0 {
auth.ModelStates = existing.ModelStates
}
if ShouldInheritModelStates(existing, auth) {
auth.ModelStates = existing.ModelStates
} else if shouldClearModelStates(existing, auth) {
clearRuntimeModelStates(auth)
}
auth.EnsureIndex()
authClone := auth.Clone()
Expand All @@ -1462,6 +1635,20 @@ func (m *Manager) Update(ctx context.Context, auth *Auth) (*Auth, error) {
return auth.Clone(), nil
}

func clearRuntimeModelStates(auth *Auth) {
if auth == nil {
return
}
auth.ModelStates = nil
clearAggregatedAvailability(auth)
if !auth.Disabled && auth.Status != StatusDisabled {
if auth.Status == StatusError {
auth.Status = StatusActive
}
auth.StatusMessage = ""
}
}

// Remove deletes an auth from runtime state without persisting.
// Disk and token-store deletion must be handled by the caller.
func (m *Manager) Remove(ctx context.Context, id string) {
Expand Down
Loading
Loading