icebear icebear0828 commited on
Commit
7e5ecc4
·
unverified ·
1 Parent(s): 4349d56

fix: allow multiple team accounts sharing same chatgpt_account_id (#126) (#127)

Browse files

Team members share chatgpt_account_id but have distinct chatgpt_user_id.
Change dedup key from accountId alone to accountId + userId composite,
so each team member gets their own entry in the pool.

- Add userId field to AccountEntry/AccountInfo types
- Dedup by (accountId, userId) in addAccount()
- Backfill userId from JWT on persistence load
- 5 new tests for team dedup scenarios

Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>

CHANGELOG.md CHANGED
@@ -8,6 +8,9 @@
8
 
9
  ### Fixed
10
 
 
 
 
11
  - 额度耗尽账号仍显示「活跃」并接收请求的问题(#115)
12
  - `markQuotaExhausted()` 现在可以覆盖 `rate_limited` 状态(仅延长,不缩短 reset 时间)
13
  - 后台额度刷新现在同时检查 `rate_limited` 账号,防止因 429 短暂 backoff 导致漏检
 
8
 
9
  ### Fixed
10
 
11
+ - 同一 Team 的多个账号因共享 `chatgpt_account_id` 只能添加一个的问题(#126)
12
+ - 去重逻辑改为 `accountId + userId` 组合键,Team 成员各自保留独立条目
13
+ - `AccountEntry` 新增 `userId` 字段,持久化层自动回填
14
  - 额度耗尽账号仍显示「活跃」并接收请求的问题(#115)
15
  - `markQuotaExhausted()` 现在可以覆盖 `rate_limited` 状态(仅延长,不缩短 reset 时间)
16
  - 后台额度刷新现在同时检查 `rate_limited` 账号,防止因 429 短暂 backoff 导致漏检
src/auth/__tests__/account-pool-team-dedup.test.ts ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tests for team account deduplication.
3
+ *
4
+ * Team accounts share the same chatgpt_account_id but have distinct
5
+ * chatgpt_user_id values. They should be treated as separate accounts.
6
+ * See: https://github.com/icebear0828/codex-proxy/issues/126
7
+ */
8
+
9
+ import { describe, it, expect, vi, beforeEach } from "vitest";
10
+
11
+ vi.mock("../../models/model-store.js", () => ({
12
+ getModelPlanTypes: vi.fn(() => []),
13
+ }));
14
+
15
+ vi.mock("../../config.js", () => ({
16
+ getConfig: vi.fn(() => ({
17
+ server: { proxy_api_key: null },
18
+ auth: { jwt_token: "", rotation_strategy: "least_used", rate_limit_backoff_seconds: 60 },
19
+ })),
20
+ }));
21
+
22
+ // Team members share the same accountId but have distinct user IDs
23
+ const TEAM_ACCOUNT_ID = "acct-team-abc123";
24
+
25
+ let profileForToken: Record<string, { chatgpt_plan_type: string; email: string; chatgpt_user_id?: string }> = {};
26
+
27
+ vi.mock("../../auth/jwt-utils.js", () => ({
28
+ isTokenExpired: vi.fn(() => false),
29
+ decodeJwtPayload: vi.fn(() => ({ exp: Math.floor(Date.now() / 1000) + 3600 })),
30
+ extractChatGptAccountId: vi.fn((token: string) => {
31
+ // All "team-*" tokens share the same account ID
32
+ if (token.startsWith("team-")) return TEAM_ACCOUNT_ID;
33
+ return `acct-${token}`;
34
+ }),
35
+ extractUserProfile: vi.fn((token: string) => profileForToken[token] ?? null),
36
+ }));
37
+
38
+ vi.mock("../../utils/jitter.js", () => ({
39
+ jitter: vi.fn((val: number) => val),
40
+ }));
41
+
42
+ vi.mock("fs", () => ({
43
+ readFileSync: vi.fn(() => JSON.stringify({ accounts: [] })),
44
+ writeFileSync: vi.fn(),
45
+ existsSync: vi.fn(() => false),
46
+ mkdirSync: vi.fn(),
47
+ renameSync: vi.fn(),
48
+ }));
49
+
50
+ import { AccountPool } from "../account-pool.js";
51
+
52
+ describe("team account dedup (issue #126)", () => {
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ profileForToken = {};
56
+ });
57
+
58
+ it("allows multiple team members with same accountId but different userId", () => {
59
+ profileForToken = {
60
+ "team-alice": { chatgpt_plan_type: "team", email: "alice@corp.com", chatgpt_user_id: "user-alice" },
61
+ "team-bob": { chatgpt_plan_type: "team", email: "bob@corp.com", chatgpt_user_id: "user-bob" },
62
+ };
63
+
64
+ const pool = new AccountPool();
65
+ const idAlice = pool.addAccount("team-alice");
66
+ const idBob = pool.addAccount("team-bob");
67
+
68
+ // Both should exist as separate entries
69
+ expect(idAlice).not.toBe(idBob);
70
+ expect(pool.getAccounts()).toHaveLength(2);
71
+
72
+ const accounts = pool.getAccounts();
73
+ expect(accounts.map((a) => a.email).sort()).toEqual(["alice@corp.com", "bob@corp.com"]);
74
+ expect(accounts.map((a) => a.userId).sort()).toEqual(["user-alice", "user-bob"]);
75
+ // Both share the same accountId
76
+ expect(accounts[0].accountId).toBe(TEAM_ACCOUNT_ID);
77
+ expect(accounts[1].accountId).toBe(TEAM_ACCOUNT_ID);
78
+ });
79
+
80
+ it("still deduplicates when same user re-adds their token", () => {
81
+ profileForToken = {
82
+ "team-alice": { chatgpt_plan_type: "team", email: "alice@corp.com", chatgpt_user_id: "user-alice" },
83
+ "team-alice-refreshed": { chatgpt_plan_type: "team", email: "alice@corp.com", chatgpt_user_id: "user-alice" },
84
+ };
85
+
86
+ const pool = new AccountPool();
87
+ const id1 = pool.addAccount("team-alice");
88
+ const id2 = pool.addAccount("team-alice-refreshed");
89
+
90
+ // Same user → should update, not duplicate
91
+ expect(id1).toBe(id2);
92
+ expect(pool.getAccounts()).toHaveLength(1);
93
+ });
94
+
95
+ it("third team member adds without overwriting existing members", () => {
96
+ profileForToken = {
97
+ "team-alice": { chatgpt_plan_type: "team", email: "alice@corp.com", chatgpt_user_id: "user-alice" },
98
+ "team-bob": { chatgpt_plan_type: "team", email: "bob@corp.com", chatgpt_user_id: "user-bob" },
99
+ "team-carol": { chatgpt_plan_type: "team", email: "carol@corp.com", chatgpt_user_id: "user-carol" },
100
+ };
101
+
102
+ const pool = new AccountPool();
103
+ pool.addAccount("team-alice");
104
+ pool.addAccount("team-bob");
105
+ pool.addAccount("team-carol");
106
+
107
+ expect(pool.getAccounts()).toHaveLength(3);
108
+ const emails = pool.getAccounts().map((a) => a.email).sort();
109
+ expect(emails).toEqual(["alice@corp.com", "bob@corp.com", "carol@corp.com"]);
110
+ });
111
+
112
+ it("userId is included in AccountInfo", () => {
113
+ profileForToken = {
114
+ "team-alice": { chatgpt_plan_type: "team", email: "alice@corp.com", chatgpt_user_id: "user-alice" },
115
+ };
116
+
117
+ const pool = new AccountPool();
118
+ pool.addAccount("team-alice");
119
+
120
+ const info = pool.getAccounts()[0];
121
+ expect(info.userId).toBe("user-alice");
122
+ });
123
+
124
+ it("accounts without userId still dedup by accountId alone", () => {
125
+ // Legacy tokens without chatgpt_user_id
126
+ profileForToken = {
127
+ "solo-token1": { chatgpt_plan_type: "free", email: "user@test.com" },
128
+ "solo-token2": { chatgpt_plan_type: "free", email: "user@test.com" },
129
+ };
130
+
131
+ // Both map to the same accountId (acct-solo-token1 vs acct-solo-token2 — different!)
132
+ // but if they had the same accountId, they'd dedup since both userId are null
133
+ const pool = new AccountPool();
134
+ pool.addAccount("solo-token1");
135
+ pool.addAccount("solo-token2");
136
+
137
+ // Different accountId → separate entries
138
+ expect(pool.getAccounts()).toHaveLength(2);
139
+ });
140
+ });
src/auth/account-persistence.ts CHANGED
@@ -92,6 +92,7 @@ function migrateFromLegacy(): AccountEntry[] {
92
  refreshToken: null,
93
  email: data.userInfo?.email ?? null,
94
  accountId: accountId,
 
95
  planType: data.userInfo?.planType ?? null,
96
  proxyApiKey: data.proxyApiKey ?? "codex-proxy-" + randomBytes(24).toString("hex"),
97
  status: isTokenExpired(data.token) ? "expired" : "active",
@@ -144,7 +145,7 @@ function loadPersisted(): { entries: AccountEntry[]; needsPersist: boolean } {
144
  if (!entry.id || !entry.token) continue;
145
 
146
  // Backfill missing fields from JWT
147
- if (!entry.planType || !entry.email || !entry.accountId) {
148
  const profile = extractUserProfile(entry.token);
149
  const accountId = extractChatGptAccountId(entry.token);
150
  if (!entry.planType && profile?.chatgpt_plan_type) {
@@ -159,6 +160,15 @@ function loadPersisted(): { entries: AccountEntry[]; needsPersist: boolean } {
159
  entry.accountId = accountId;
160
  needsPersist = true;
161
  }
 
 
 
 
 
 
 
 
 
162
  }
163
  // Backfill empty_response_count
164
  if (entry.usage.empty_response_count == null) {
 
92
  refreshToken: null,
93
  email: data.userInfo?.email ?? null,
94
  accountId: accountId,
95
+ userId: extractUserProfile(data.token)?.chatgpt_user_id ?? null,
96
  planType: data.userInfo?.planType ?? null,
97
  proxyApiKey: data.proxyApiKey ?? "codex-proxy-" + randomBytes(24).toString("hex"),
98
  status: isTokenExpired(data.token) ? "expired" : "active",
 
145
  if (!entry.id || !entry.token) continue;
146
 
147
  // Backfill missing fields from JWT
148
+ if (!entry.planType || !entry.email || !entry.accountId || !entry.userId) {
149
  const profile = extractUserProfile(entry.token);
150
  const accountId = extractChatGptAccountId(entry.token);
151
  if (!entry.planType && profile?.chatgpt_plan_type) {
 
160
  entry.accountId = accountId;
161
  needsPersist = true;
162
  }
163
+ if (!entry.userId && profile?.chatgpt_user_id) {
164
+ entry.userId = profile.chatgpt_user_id;
165
+ needsPersist = true;
166
+ }
167
+ }
168
+ // Backfill userId for entries missing it (pre-v1.0.68)
169
+ if (entry.userId === undefined) {
170
+ entry.userId = null;
171
+ needsPersist = true;
172
  }
173
  // Backfill empty_response_count
174
  if (entry.usage.empty_response_count == null) {
src/auth/account-pool.ts CHANGED
@@ -240,11 +240,15 @@ export class AccountPool {
240
  addAccount(token: string, refreshToken?: string | null): string {
241
  const accountId = extractChatGptAccountId(token);
242
  const profile = extractUserProfile(token);
 
243
 
244
- // Deduplicate by accountId
245
  if (accountId) {
246
  for (const existing of this.accounts.values()) {
247
- if (existing.accountId === accountId) {
 
 
 
248
  // Update the existing entry's token
249
  existing.token = token;
250
  if (refreshToken !== undefined) {
@@ -266,6 +270,7 @@ export class AccountPool {
266
  refreshToken: refreshToken ?? null,
267
  email: profile?.email ?? null,
268
  accountId,
 
269
  planType: profile?.chatgpt_plan_type ?? null,
270
  proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"),
271
  status: isTokenExpired(token) ? "expired" : "active",
@@ -362,6 +367,7 @@ export class AccountPool {
362
  entry.email = profile?.email ?? entry.email;
363
  entry.planType = profile?.chatgpt_plan_type ?? entry.planType;
364
  entry.accountId = extractChatGptAccountId(newToken) ?? entry.accountId;
 
365
  entry.status = "active";
366
  this.persistNow(); // Critical data — persist immediately
367
  }
@@ -568,6 +574,7 @@ export class AccountPool {
568
  id: entry.id,
569
  email: entry.email,
570
  accountId: entry.accountId,
 
571
  planType: entry.planType,
572
  status: entry.status,
573
  usage: { ...entry.usage },
 
240
  addAccount(token: string, refreshToken?: string | null): string {
241
  const accountId = extractChatGptAccountId(token);
242
  const profile = extractUserProfile(token);
243
+ const userId = profile?.chatgpt_user_id ?? null;
244
 
245
+ // Deduplicate by accountId + userId (team members share accountId but have distinct userId)
246
  if (accountId) {
247
  for (const existing of this.accounts.values()) {
248
+ if (
249
+ existing.accountId === accountId &&
250
+ existing.userId === userId
251
+ ) {
252
  // Update the existing entry's token
253
  existing.token = token;
254
  if (refreshToken !== undefined) {
 
270
  refreshToken: refreshToken ?? null,
271
  email: profile?.email ?? null,
272
  accountId,
273
+ userId,
274
  planType: profile?.chatgpt_plan_type ?? null,
275
  proxyApiKey: "codex-proxy-" + randomBytes(24).toString("hex"),
276
  status: isTokenExpired(token) ? "expired" : "active",
 
367
  entry.email = profile?.email ?? entry.email;
368
  entry.planType = profile?.chatgpt_plan_type ?? entry.planType;
369
  entry.accountId = extractChatGptAccountId(newToken) ?? entry.accountId;
370
+ entry.userId = profile?.chatgpt_user_id ?? entry.userId;
371
  entry.status = "active";
372
  this.persistNow(); // Critical data — persist immediately
373
  }
 
574
  id: entry.id,
575
  email: entry.email,
576
  accountId: entry.accountId,
577
+ userId: entry.userId,
578
  planType: entry.planType,
579
  status: entry.status,
580
  usage: { ...entry.usage },
src/auth/types.ts CHANGED
@@ -36,6 +36,8 @@ export interface AccountEntry {
36
  refreshToken: string | null;
37
  email: string | null;
38
  accountId: string | null;
 
 
39
  planType: string | null;
40
  proxyApiKey: string;
41
  status: AccountStatus;
@@ -52,6 +54,7 @@ export interface AccountInfo {
52
  id: string;
53
  email: string | null;
54
  accountId: string | null;
 
55
  planType: string | null;
56
  status: AccountStatus;
57
  usage: AccountUsage;
 
36
  refreshToken: string | null;
37
  email: string | null;
38
  accountId: string | null;
39
+ /** Per-user unique ID (chatgpt_user_id). Team members share accountId but have distinct userId. */
40
+ userId: string | null;
41
  planType: string | null;
42
  proxyApiKey: string;
43
  status: AccountStatus;
 
54
  id: string;
55
  email: string | null;
56
  accountId: string | null;
57
+ userId: string | null;
58
  planType: string | null;
59
  status: AccountStatus;
60
  usage: AccountUsage;