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
100 changes: 96 additions & 4 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,15 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H {
if claims := extractCodexIDTokenClaims(auth); claims != nil {
entry["id_token"] = claims
}
if subscription := extractCodexSubscriptionMetadata(auth); subscription != nil {
entry["codex_subscription"] = subscription
if v, ok := subscription["plan_type"]; ok {
entry["plan_type"] = v
}
if v, ok := subscription["subscription_active_until"]; ok {
entry["subscription_active_until"] = v
}
}
// Expose priority from Attributes (set by synthesizer from JSON "priority" field).
// Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer).
if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" {
Expand Down Expand Up @@ -659,6 +668,71 @@ func extractCodexIDTokenClaims(auth *coreauth.Auth) gin.H {
return result
}

func extractCodexSubscriptionMetadata(auth *coreauth.Auth) gin.H {
if auth == nil || auth.Metadata == nil {
return nil
}
if !strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") {
return nil
}
result := gin.H{}
copyMetadataValue(result, auth.Metadata, "account_id")
copyMetadataValue(result, auth.Metadata, "chatgpt_account_id")
copyMetadataValue(result, auth.Metadata, "plan_type")
copyMetadataValue(result, auth.Metadata, "subscription_active_until")
copyMetadataValue(result, auth.Metadata, "chatgpt_subscription_active_until")
// Derive the expired flag from the expiry at response time rather than
// exposing the cached boolean, which goes stale once the stored expiry
// passes without a reload/enrichment. Pass the raw metadata value (which may
// be a JSON number for Unix timestamps) so IsSubscriptionExpired can apply
// the same scalar normalization the enrichment uses, instead of a stringified
// float in scientific notation.
rawActiveUntil := metadataActiveUntilValue(auth.Metadata)
if rawActiveUntil != nil {
result["subscription_expired"] = codex.IsSubscriptionExpired(rawActiveUntil)
} else {
copyMetadataValue(result, auth.Metadata, "subscription_expired")
}
copyMetadataValue(result, auth.Metadata, "chatgpt_subscription_last_checked")
if len(result) == 0 {
return nil
}
return result
}

// metadataActiveUntilValue returns the raw subscription expiry value (string or
// JSON number) from metadata, preferring subscription_active_until, or nil when
// neither key holds a usable value.
func metadataActiveUntilValue(metadata map[string]any) any {
for _, key := range []string{"subscription_active_until", "chatgpt_subscription_active_until"} {
value, ok := metadata[key]
if !ok || value == nil {
continue
}
if text, isString := value.(string); isString && strings.TrimSpace(text) == "" {
continue
}
return value
}
return nil
}

func copyMetadataValue(dst gin.H, metadata map[string]any, key string) {
if dst == nil || metadata == nil {
return
}
value, ok := metadata[key]
if !ok || value == nil {
return
}
if text, isString := value.(string); isString {
if strings.TrimSpace(text) == "" {
return
}
}
dst[key] = value
}

func authEmail(auth *coreauth.Auth) string {
if auth == nil {
return ""
Expand Down Expand Up @@ -2228,16 +2302,34 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
// Create token storage and persist
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
fileName := codex.CredentialFileName(tokenStorage.Email, planType, hashAccountID, true)
metadata := map[string]any{
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
}
// Bound this best-effort lookup so a slow/unresponsive ChatGPT backend
// cannot block the token save and OAuth session completion. Mirrors the
// SDK device-flow path (sdk/auth/codex_device.go).
enrichCtx, cancelEnrich := context.WithTimeout(ctx, 20*time.Second)
if _, errEnrich := openaiAuth.EnrichSubscriptionMetadata(
enrichCtx,
metadata,
tokenStorage.IDToken,
tokenStorage.AccessToken,
tokenStorage.AccountID,
); errEnrich != nil {
log.Warnf("Codex subscription metadata enrichment failed: %v", errEnrich)
}
cancelEnrich()
record := &coreauth.Auth{
ID: fileName,
Provider: "codex",
FileName: fileName,
Storage: tokenStorage,
Metadata: map[string]any{
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
},
Metadata: metadata,
}
// Mirror the enriched subscription fields into attributes the runtime
// reads (Codex model-catalog selection keys off Attributes["plan_type"]).
coreauth.ApplyCodexSubscriptionAttributes(record)
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
SetOAuthSessionError(state, "Failed to save authentication tokens")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package management

import (
"testing"
"time"

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

func TestExtractCodexSubscriptionMetadata_RecomputesExpired(t *testing.T) {
past := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339)
future := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339)

t.Run("stale cached false is recomputed to true once expiry passed", func(t *testing.T) {
auth := &coreauth.Auth{
Provider: "codex",
Metadata: map[string]any{
"subscription_active_until": past,
"subscription_expired": false, // stale cached value
},
}
got := extractCodexSubscriptionMetadata(auth)
if got == nil {
t.Fatalf("expected result")
}
if v, _ := got["subscription_expired"].(bool); !v {
t.Fatalf("subscription_expired=%v, want true (recomputed from past expiry)", got["subscription_expired"])
}
})

t.Run("future expiry yields not expired", func(t *testing.T) {
auth := &coreauth.Auth{
Provider: "codex",
Metadata: map[string]any{
"subscription_active_until": future,
"subscription_expired": true, // stale cached value
},
}
got := extractCodexSubscriptionMetadata(auth)
if v, _ := got["subscription_expired"].(bool); v {
t.Fatalf("subscription_expired=%v, want false (recomputed from future expiry)", got["subscription_expired"])
}
})
t.Run("numeric unix-seconds expiry parses without scientific notation", func(t *testing.T) {
futureUnix := float64(time.Now().UTC().Add(24 * time.Hour).Unix())
auth := &coreauth.Auth{
Provider: "codex",
Metadata: map[string]any{
"subscription_active_until": futureUnix, // JSON number, not string
"subscription_expired": true, // stale cached value
},
}
got := extractCodexSubscriptionMetadata(auth)
if v, _ := got["subscription_expired"].(bool); v {
t.Fatalf("subscription_expired=%v, want false (numeric future expiry)", got["subscription_expired"])
}
})
}
Loading
Loading