Skip to content

Commit fabab0d

Browse files
committed
fix(admin): classify codex at account identities
1 parent 02d2423 commit fabab0d

9 files changed

Lines changed: 191 additions & 37 deletions

File tree

admin/handler.go

Lines changed: 19 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,7 @@ type accountResponse struct {
567567
CreditSkipUsageWindow bool `json:"credit_skip_usage_window"`
568568
SkipWarmTier bool `json:"skip_warm_tier"`
569569
AccountType string `json:"account_type,omitempty"`
570+
AccessTokenType string `json:"access_token_type,omitempty"`
570571
OpenAIResponsesAPI bool `json:"openai_responses_api,omitempty"`
571572
BaseURL string `json:"base_url,omitempty"`
572573
Models []string `json:"models,omitempty"`
@@ -653,6 +654,16 @@ func accountEmailDomain(email string) string {
653654
return domain
654655
}
655656

657+
func accountAccessTokenType(row *database.AccountRow) string {
658+
if row == nil {
659+
return ""
660+
}
661+
if tokenType := strings.TrimSpace(row.GetCredential("access_token_type")); tokenType != "" {
662+
return tokenType
663+
}
664+
return accessTokenTypeForToken(row.GetCredential("access_token"))
665+
}
666+
656667
type schedulerBreakdownResponse struct {
657668
UnauthorizedPenalty float64 `json:"unauthorized_penalty"`
658669
RateLimitPenalty float64 `json:"rate_limit_penalty"`
@@ -719,6 +730,7 @@ func (h *Handler) ListAccounts(c *gin.Context) {
719730
CreditSkipUsageWindow: row.CreditSkipUsageWindow,
720731
SkipWarmTier: row.SkipWarmTier,
721732
AccountType: row.Type,
733+
AccessTokenType: accountAccessTokenType(row),
722734
OpenAIResponsesAPI: isOpenAIResponsesAccount,
723735
BaseURL: baseURL,
724736
Models: row.GetCredentialStringSlice("models"),
@@ -1812,40 +1824,13 @@ func (h *Handler) AddATAccount(c *gin.Context) {
18121824
successCount++
18131825
h.db.InsertAccountEventAsync(id, "added", "manual_at")
18141826

1815-
// 解析 AT JWT 提取账号信息(email、plan_type、account_id、过期时间)
1816-
atInfo := auth.ParseAccessToken(at)
1817-
1818-
// 热加载到内存池(AT-only,无 RT)
1819-
newAcc := &auth.Account{
1820-
DBID: id,
1821-
AccessToken: at,
1822-
ExpiresAt: time.Now().Add(1 * time.Hour),
1823-
ProxyURL: req.ProxyURL,
1824-
}
1825-
if atInfo != nil {
1826-
newAcc.Email = atInfo.Email
1827-
newAcc.AccountID = atInfo.ChatGPTAccountID
1828-
newAcc.PlanType = atInfo.PlanType
1829-
if !atInfo.ExpiresAt.IsZero() {
1830-
newAcc.ExpiresAt = atInfo.ExpiresAt
1831-
}
1832-
if !atInfo.SubscriptionExpiresAt.IsZero() {
1833-
newAcc.SubscriptionExpiresAt = atInfo.SubscriptionExpiresAt
1834-
}
1835-
}
1827+
// 热加载到内存池(AT-only,无 RT)。codex_at 不走 JWT 解码,
1828+
// 身份信息后续由 wham 用量查询补齐。
1829+
newAcc := accountFromCredentialSeed(id, req.ProxyURL, seed)
18361830
h.store.AddAccount(newAcc)
18371831

1838-
// 将解析到的信息持久化到数据库
1839-
if atInfo != nil {
1840-
creds := map[string]interface{}{
1841-
"email": atInfo.Email,
1842-
"account_id": atInfo.ChatGPTAccountID,
1843-
"plan_type": atInfo.PlanType,
1844-
"expires_at": newAcc.ExpiresAt.Format(time.RFC3339),
1845-
}
1846-
if !atInfo.SubscriptionExpiresAt.IsZero() {
1847-
creds["subscription_expires_at"] = atInfo.SubscriptionExpiresAt.Format(time.RFC3339)
1848-
}
1832+
// 将解析/识别到的信息持久化到数据库。
1833+
if creds := tokenCredentialMap(seed); len(creds) > 0 {
18491834
if err := h.db.UpdateCredentials(ctx, id, creds); err != nil {
18501835
log.Printf("AT 账号 %d 更新 credentials 失败: %v", id, err)
18511836
}
@@ -3242,6 +3227,7 @@ type recycleBinAccountResponse struct {
32423227
Email string `json:"email"`
32433228
PlanType string `json:"plan_type"`
32443229
ATOnly bool `json:"at_only"`
3230+
AccessTokenType string `json:"access_token_type,omitempty"`
32453231
OpenAIResponsesAPI bool `json:"openai_responses_api"`
32463232
BaseURL string `json:"base_url,omitempty"`
32473233
Models []string `json:"models,omitempty"`
@@ -3281,6 +3267,7 @@ func (h *Handler) ListRecycleBinAccounts(c *gin.Context) {
32813267
Email: email,
32823268
PlanType: planType,
32833269
ATOnly: !isOpenAIResponsesAccount && row.GetCredential("refresh_token") == "" && row.GetCredential("access_token") != "",
3270+
AccessTokenType: accountAccessTokenType(row),
32843271
OpenAIResponsesAPI: isOpenAIResponsesAccount,
32853272
BaseURL: baseURL,
32863273
Models: row.GetCredentialStringSlice("models"),

admin/handler_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,20 @@ func TestAccountEmailDomain(t *testing.T) {
7676
}
7777
}
7878

79+
func TestAccountAccessTokenTypeInfersCodexAT(t *testing.T) {
80+
row := &database.AccountRow{Credentials: map[string]interface{}{
81+
"access_token": "at-opaque-codex-token",
82+
}}
83+
if got := accountAccessTokenType(row); got != accessTokenTypeCodexAT {
84+
t.Fatalf("accountAccessTokenType() = %q, want %q", got, accessTokenTypeCodexAT)
85+
}
86+
87+
row.Credentials["access_token_type"] = "custom"
88+
if got := accountAccessTokenType(row); got != "custom" {
89+
t.Fatalf("accountAccessTokenType() with stored type = %q, want custom", got)
90+
}
91+
}
92+
7993
func TestSummarizeDashboardAccountsMatchesAccountPageBuckets(t *testing.T) {
8094
rows := []*database.AccountRow{
8195
{ID: 1, Status: "error", Enabled: true}, // DB stale, runtime active

admin/token_credentials.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type tokenCredentialSeed struct {
1212
refreshToken string
1313
sessionToken string
1414
accessToken string
15+
accessTokenType string
1516
idToken string
1617
accountID string
1718
email string
@@ -32,6 +33,7 @@ func normalizeTokenCredentialSeed(seed tokenCredentialSeed) tokenCredentialSeed
3233
seed.refreshToken = strings.TrimSpace(seed.refreshToken)
3334
seed.sessionToken = strings.TrimSpace(seed.sessionToken)
3435
seed.accessToken = strings.TrimSpace(seed.accessToken)
36+
seed.accessTokenType = strings.TrimSpace(seed.accessTokenType)
3537
seed.idToken = strings.TrimSpace(seed.idToken)
3638
seed.accountID = strings.TrimSpace(seed.accountID)
3739
seed.email = strings.TrimSpace(seed.email)
@@ -43,8 +45,15 @@ func normalizeTokenCredentialSeed(seed tokenCredentialSeed) tokenCredentialSeed
4345
seed.codex5HResetAt = strings.TrimSpace(seed.codex5HResetAt)
4446
seed.codex5HUsageUpdatedAt = strings.TrimSpace(seed.codex5HUsageUpdatedAt)
4547
seed.codexUsageUpdatedAt = strings.TrimSpace(seed.codexUsageUpdatedAt)
48+
if seed.accessTokenType == "" {
49+
seed.accessTokenType = accessTokenTypeForToken(seed.accessToken)
50+
}
4651

47-
if info := accountInfoFromTokens(seed.idToken, seed.accessToken); info != nil {
52+
accessTokenForJWT := seed.accessToken
53+
if seed.accessTokenType == accessTokenTypeCodexAT {
54+
accessTokenForJWT = ""
55+
}
56+
if info := accountInfoFromTokens(seed.idToken, accessTokenForJWT); info != nil {
4857
if seed.accountID == "" {
4958
seed.accountID = info.ChatGPTAccountID
5059
}
@@ -62,7 +71,7 @@ func normalizeTokenCredentialSeed(seed tokenCredentialSeed) tokenCredentialSeed
6271
if seed.expiresAt.IsZero() && seed.expiresIn > 0 {
6372
seed.expiresAt = time.Now().Add(time.Duration(seed.expiresIn) * time.Second)
6473
}
65-
if seed.expiresAt.IsZero() && seed.accessToken != "" {
74+
if seed.expiresAt.IsZero() && seed.accessToken != "" && seed.accessTokenType != accessTokenTypeCodexAT {
6675
if info := auth.ParseAccessToken(seed.accessToken); info != nil && !info.ExpiresAt.IsZero() {
6776
seed.expiresAt = info.ExpiresAt
6877
}
@@ -77,6 +86,15 @@ func normalizeTokenCredentialSeed(seed tokenCredentialSeed) tokenCredentialSeed
7786
return seed
7887
}
7988

89+
const accessTokenTypeCodexAT = "codex_at"
90+
91+
func accessTokenTypeForToken(accessToken string) string {
92+
if strings.HasPrefix(strings.TrimSpace(accessToken), "at-") {
93+
return accessTokenTypeCodexAT
94+
}
95+
return ""
96+
}
97+
8098
func accountInfoFromTokens(idToken, accessToken string) *auth.AccountInfo {
8199
info := auth.ParseIDToken(strings.TrimSpace(idToken))
82100
if info == nil {
@@ -111,6 +129,9 @@ func tokenCredentialMap(seed tokenCredentialSeed) map[string]interface{} {
111129
if seed.accessToken != "" {
112130
credentials["access_token"] = seed.accessToken
113131
}
132+
if seed.accessTokenType != "" {
133+
credentials["access_token_type"] = seed.accessTokenType
134+
}
114135
if seed.idToken != "" {
115136
credentials["id_token"] = seed.idToken
116137
}

admin/token_credentials_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,42 @@ func TestNormalizeTokenCredentialSeedPrefersAccessTokenExpiry(t *testing.T) {
3232
t.Fatalf("expiresAt = %s, want access token expiry %s", seed.expiresAt, accessExpiresAt)
3333
}
3434
}
35+
36+
func TestNormalizeTokenCredentialSeedTreatsCodexATAsOpaque(t *testing.T) {
37+
accessExpiresAt := time.Now().Add(2 * time.Hour).Truncate(time.Second)
38+
rawJWT := makeAdminTestJWT(t, map[string]interface{}{
39+
"exp": accessExpiresAt.Unix(),
40+
"https://api.openai.com/profile": map[string]interface{}{
41+
"email": "jwt@example.com",
42+
},
43+
"https://api.openai.com/auth": map[string]interface{}{
44+
"chatgpt_account_id": "acc-from-jwt",
45+
"chatgpt_plan_type": "team",
46+
},
47+
})
48+
codexAT := "at-" + rawJWT
49+
before := time.Now()
50+
51+
seed := normalizeTokenCredentialSeed(tokenCredentialSeed{accessToken: codexAT})
52+
53+
if seed.accessTokenType != accessTokenTypeCodexAT {
54+
t.Fatalf("accessTokenType = %q, want %q", seed.accessTokenType, accessTokenTypeCodexAT)
55+
}
56+
if seed.email != "" || seed.accountID != "" || seed.planType != "" {
57+
t.Fatalf("codex_at parsed JWT fields: email=%q accountID=%q planType=%q", seed.email, seed.accountID, seed.planType)
58+
}
59+
if seed.expiresAt.Before(before.Add(50*time.Minute)) || seed.expiresAt.After(before.Add(70*time.Minute)) {
60+
t.Fatalf("expiresAt = %s, want fallback around 1h from now", seed.expiresAt)
61+
}
62+
63+
credentials := tokenCredentialMap(seed)
64+
if got := credentials["access_token_type"]; got != accessTokenTypeCodexAT {
65+
t.Fatalf("credentials access_token_type = %q, want %q", got, accessTokenTypeCodexAT)
66+
}
67+
if _, ok := credentials["email"]; ok {
68+
t.Fatalf("credentials should not include email for opaque codex_at: %#v", credentials)
69+
}
70+
if _, ok := credentials["account_id"]; ok {
71+
t.Fatalf("credentials should not include account_id for opaque codex_at: %#v", credentials)
72+
}
73+
}

auth/store.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4936,6 +4936,44 @@ func (s *Store) UpdateAccountPlanType(acc *Account, planType string) bool {
49364936
return changed
49374937
}
49384938

4939+
// UpdateAccountIdentity persists account identity observed from upstream usage APIs.
4940+
func (s *Store) UpdateAccountIdentity(acc *Account, email, accountID string) bool {
4941+
if s == nil || acc == nil {
4942+
return false
4943+
}
4944+
email = strings.TrimSpace(email)
4945+
accountID = strings.TrimSpace(accountID)
4946+
if email == "" && accountID == "" {
4947+
return false
4948+
}
4949+
4950+
fields := make(map[string]interface{}, 2)
4951+
acc.mu.Lock()
4952+
changed := false
4953+
if email != "" && acc.Email != email {
4954+
acc.Email = email
4955+
fields["email"] = email
4956+
changed = true
4957+
}
4958+
if accountID != "" && acc.AccountID != accountID {
4959+
acc.AccountID = accountID
4960+
fields["account_id"] = accountID
4961+
changed = true
4962+
}
4963+
acc.mu.Unlock()
4964+
4965+
if s.db == nil || len(fields) == 0 {
4966+
return changed
4967+
}
4968+
4969+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
4970+
defer cancel()
4971+
if err := s.db.UpdateCredentials(ctx, acc.DBID, fields); err != nil {
4972+
log.Printf("[账号 %d] 持久化账号身份失败: %v", acc.DBID, err)
4973+
}
4974+
return changed
4975+
}
4976+
49394977
// ApplyUsageLimitMetadata applies metadata returned by Codex usage_limit_reached errors.
49404978
func (s *Store) ApplyUsageLimitMetadata(acc *Account, planType string, resetAt time.Time) {
49414979
if acc == nil {

frontend/src/pages/Accounts.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ function formatAccountListEmail(account: AccountRow): string {
252252
return account.email?.trim() || account.name || `ID ${account.id}`;
253253
}
254254

255+
function formatAccessTokenBadge(account: AccountRow): string {
256+
return account.access_token_type === "codex_at" ? "codex_at" : "AT";
257+
}
258+
255259
function getInitialAnalysisVisibility(): boolean {
256260
try {
257261
return (
@@ -3927,7 +3931,7 @@ export default function Accounts() {
39273931
<div className="flex flex-wrap gap-1">
39283932
{account.at_only && (
39293933
<span className="inline-flex items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950 dark:text-amber-400 dark:ring-amber-400/20">
3930-
AT
3934+
{formatAccessTokenBadge(account)}
39313935
</span>
39323936
)}
39333937
{account.openai_responses_api && (
@@ -8364,7 +8368,7 @@ function AccountMobileCard({
83648368
<div className="mt-3 flex min-h-6 min-w-0 flex-wrap items-center gap-1.5">
83658369
{account.at_only && (
83668370
<span className="inline-flex items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950 dark:text-amber-400 dark:ring-amber-400/20">
8367-
AT
8371+
{formatAccessTokenBadge(account)}
83688372
</span>
83698373
)}
83708374
{account.openai_responses_api && (
@@ -8690,7 +8694,7 @@ function AccountMobileCard({
86908694
<div className="mt-2 flex min-h-6 min-w-0 flex-wrap items-center gap-1.5">
86918695
{account.at_only && (
86928696
<span className="inline-flex items-center rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950 dark:text-amber-400 dark:ring-amber-400/20">
8693-
AT
8697+
{formatAccessTokenBadge(account)}
86948698
</span>
86958699
)}
86968700
{account.openai_responses_api && (

frontend/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface AccountRow {
3434
status: AccountStatus
3535
error_message?: string
3636
at_only?: boolean
37+
access_token_type?: string
3738
account_type?: string
3839
openai_responses_api?: boolean
3940
base_url?: string
@@ -154,6 +155,7 @@ export interface RecycleBinAccountRow {
154155
email: string
155156
plan_type: string
156157
at_only?: boolean
158+
access_token_type?: string
157159
openai_responses_api?: boolean
158160
base_url?: string
159161
models?: string[]

proxy/usage_wham.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,11 @@ func ApplyWhamUsage(store *auth.Store, account *auth.Account, usage *WhamUsage)
302302
store.UpdateAccountPlanType(account, usage.PlanType)
303303
}
304304
if store != nil {
305+
accountID := strings.TrimSpace(usage.AccountID)
306+
if accountID == "" {
307+
accountID = strings.TrimSpace(usage.UserID)
308+
}
309+
store.UpdateAccountIdentity(account, usage.Email, accountID)
305310
store.UpdateAccountSubscriptionExpiresAt(account, usage.SubscriptionExpiresAt())
306311
}
307312

proxy/usage_wham_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,50 @@ func TestApplyWhamUsage_PersistsPlanAnd5h7d(t *testing.T) {
148148
}
149149
}
150150

151+
func TestApplyWhamUsage_PersistsIdentity(t *testing.T) {
152+
ctx := context.Background()
153+
dbPath := filepath.Join(t.TempDir(), "codex2api.db")
154+
db, err := database.New("sqlite", dbPath)
155+
if err != nil {
156+
t.Fatalf("database.New: %v", err)
157+
}
158+
defer db.Close()
159+
160+
id, err := db.InsertAccountWithCredentials(ctx, "at-only", map[string]interface{}{"access_token": "at"}, "")
161+
if err != nil {
162+
t.Fatalf("InsertAccountWithCredentials: %v", err)
163+
}
164+
165+
store := auth.NewStore(db, nil, &database.SystemSettings{MaxConcurrency: 2, TestConcurrency: 1, TestModel: "gpt-5.4"})
166+
account := &auth.Account{DBID: id, AccessToken: "at"}
167+
usage := &WhamUsage{
168+
UserID: "user-from-wham",
169+
AccountID: "account-from-wham",
170+
Email: "wham@example.com",
171+
PlanType: "team",
172+
}
173+
174+
ApplyWhamUsage(store, account, usage)
175+
176+
if account.Email != "wham@example.com" {
177+
t.Fatalf("account.Email = %q, want wham@example.com", account.Email)
178+
}
179+
if account.AccountID != "account-from-wham" {
180+
t.Fatalf("account.AccountID = %q, want account-from-wham", account.AccountID)
181+
}
182+
183+
row, err := db.GetAccountByID(ctx, id)
184+
if err != nil {
185+
t.Fatalf("GetAccountByID: %v", err)
186+
}
187+
if got := row.GetCredential("email"); got != "wham@example.com" {
188+
t.Fatalf("credentials.email = %q, want wham@example.com", got)
189+
}
190+
if got := row.GetCredential("account_id"); got != "account-from-wham" {
191+
t.Fatalf("credentials.account_id = %q, want account-from-wham", got)
192+
}
193+
}
194+
151195
func TestApplyWhamUsage_PersistsSubscriptionExpiresAtWhenMemoryAlreadyMatches(t *testing.T) {
152196
ctx := context.Background()
153197
dbPath := filepath.Join(t.TempDir(), "codex2api.db")

0 commit comments

Comments
 (0)