@iam-brain/opencode-codex-auth 1.2.4 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +15 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +41 -18
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/accounts-tools.d.ts.map +1 -1
  6. package/dist/lib/accounts-tools.js +114 -29
  7. package/dist/lib/accounts-tools.js.map +1 -1
  8. package/dist/lib/cache-io.d.ts.map +1 -1
  9. package/dist/lib/cache-io.js +6 -1
  10. package/dist/lib/cache-io.js.map +1 -1
  11. package/dist/lib/codex-native/accounts.d.ts.map +1 -1
  12. package/dist/lib/codex-native/accounts.js +18 -12
  13. package/dist/lib/codex-native/accounts.js.map +1 -1
  14. package/dist/lib/codex-native/acquire-auth.d.ts +1 -1
  15. package/dist/lib/codex-native/acquire-auth.d.ts.map +1 -1
  16. package/dist/lib/codex-native/acquire-auth.js +317 -204
  17. package/dist/lib/codex-native/acquire-auth.js.map +1 -1
  18. package/dist/lib/codex-native/auth-menu-flow.d.ts.map +1 -1
  19. package/dist/lib/codex-native/auth-menu-flow.js +27 -12
  20. package/dist/lib/codex-native/auth-menu-flow.js.map +1 -1
  21. package/dist/lib/codex-native/auth-menu-quotas.d.ts.map +1 -1
  22. package/dist/lib/codex-native/auth-menu-quotas.js +11 -4
  23. package/dist/lib/codex-native/auth-menu-quotas.js.map +1 -1
  24. package/dist/lib/codex-native/catalog-auth.d.ts.map +1 -1
  25. package/dist/lib/codex-native/catalog-auth.js +4 -2
  26. package/dist/lib/codex-native/catalog-auth.js.map +1 -1
  27. package/dist/lib/codex-native/chat-hooks.d.ts.map +1 -1
  28. package/dist/lib/codex-native/chat-hooks.js +0 -8
  29. package/dist/lib/codex-native/chat-hooks.js.map +1 -1
  30. package/dist/lib/codex-native/client-identity.d.ts.map +1 -1
  31. package/dist/lib/codex-native/client-identity.js +11 -4
  32. package/dist/lib/codex-native/client-identity.js.map +1 -1
  33. package/dist/lib/codex-native/collaboration.d.ts +1 -1
  34. package/dist/lib/codex-native/collaboration.d.ts.map +1 -1
  35. package/dist/lib/codex-native/collaboration.js +9 -116
  36. package/dist/lib/codex-native/collaboration.js.map +1 -1
  37. package/dist/lib/codex-native/oauth-auth-methods.d.ts.map +1 -1
  38. package/dist/lib/codex-native/oauth-auth-methods.js +22 -6
  39. package/dist/lib/codex-native/oauth-auth-methods.js.map +1 -1
  40. package/dist/lib/codex-native/oauth-server.d.ts +1 -0
  41. package/dist/lib/codex-native/oauth-server.d.ts.map +1 -1
  42. package/dist/lib/codex-native/oauth-server.js +92 -28
  43. package/dist/lib/codex-native/oauth-server.js.map +1 -1
  44. package/dist/lib/codex-native/openai-loader-fetch.d.ts.map +1 -1
  45. package/dist/lib/codex-native/openai-loader-fetch.js +91 -32
  46. package/dist/lib/codex-native/openai-loader-fetch.js.map +1 -1
  47. package/dist/lib/codex-native/originator.d.ts.map +1 -1
  48. package/dist/lib/codex-native/originator.js +18 -1
  49. package/dist/lib/codex-native/originator.js.map +1 -1
  50. package/dist/lib/codex-native/request-transform.d.ts.map +1 -1
  51. package/dist/lib/codex-native/request-transform.js +39 -8
  52. package/dist/lib/codex-native/request-transform.js.map +1 -1
  53. package/dist/lib/codex-native/session-affinity-state.d.ts +1 -1
  54. package/dist/lib/codex-native/session-affinity-state.d.ts.map +1 -1
  55. package/dist/lib/codex-native/session-affinity-state.js +1 -0
  56. package/dist/lib/codex-native/session-affinity-state.js.map +1 -1
  57. package/dist/lib/codex-native.js.map +1 -1
  58. package/dist/lib/codex-prompts-cache.d.ts.map +1 -1
  59. package/dist/lib/codex-prompts-cache.js.map +1 -1
  60. package/dist/lib/codex-quota-fetch.d.ts.map +1 -1
  61. package/dist/lib/codex-quota-fetch.js +12 -9
  62. package/dist/lib/codex-quota-fetch.js.map +1 -1
  63. package/dist/lib/codex-status-storage.d.ts.map +1 -1
  64. package/dist/lib/codex-status-storage.js.map +1 -1
  65. package/dist/lib/codex-status.d.ts.map +1 -1
  66. package/dist/lib/codex-status.js +28 -3
  67. package/dist/lib/codex-status.js.map +1 -1
  68. package/dist/lib/config.d.ts +3 -0
  69. package/dist/lib/config.d.ts.map +1 -1
  70. package/dist/lib/config.js +41 -21
  71. package/dist/lib/config.js.map +1 -1
  72. package/dist/lib/fetch-orchestrator.d.ts +2 -1
  73. package/dist/lib/fetch-orchestrator.d.ts.map +1 -1
  74. package/dist/lib/fetch-orchestrator.js +81 -11
  75. package/dist/lib/fetch-orchestrator.js.map +1 -1
  76. package/dist/lib/identity.d.ts +6 -0
  77. package/dist/lib/identity.d.ts.map +1 -1
  78. package/dist/lib/identity.js +25 -4
  79. package/dist/lib/identity.js.map +1 -1
  80. package/dist/lib/opencode-install.d.ts.map +1 -1
  81. package/dist/lib/opencode-install.js +5 -6
  82. package/dist/lib/opencode-install.js.map +1 -1
  83. package/dist/lib/orchestrator-agent.d.ts.map +1 -1
  84. package/dist/lib/orchestrator-agent.js +2 -1
  85. package/dist/lib/orchestrator-agent.js.map +1 -1
  86. package/dist/lib/paths.d.ts.map +1 -1
  87. package/dist/lib/paths.js +8 -2
  88. package/dist/lib/paths.js.map +1 -1
  89. package/dist/lib/proactive-refresh.d.ts.map +1 -1
  90. package/dist/lib/proactive-refresh.js +50 -13
  91. package/dist/lib/proactive-refresh.js.map +1 -1
  92. package/dist/lib/refresh-queue.d.ts.map +1 -1
  93. package/dist/lib/refresh-queue.js +1 -0
  94. package/dist/lib/refresh-queue.js.map +1 -1
  95. package/dist/lib/request-snapshots.d.ts.map +1 -1
  96. package/dist/lib/request-snapshots.js +46 -10
  97. package/dist/lib/request-snapshots.js.map +1 -1
  98. package/dist/lib/rotation.d.ts.map +1 -1
  99. package/dist/lib/rotation.js +3 -2
  100. package/dist/lib/rotation.js.map +1 -1
  101. package/dist/lib/session-affinity.d.ts.map +1 -1
  102. package/dist/lib/session-affinity.js +35 -20
  103. package/dist/lib/session-affinity.js.map +1 -1
  104. package/dist/lib/storage.d.ts.map +1 -1
  105. package/dist/lib/storage.js +110 -45
  106. package/dist/lib/storage.js.map +1 -1
  107. package/dist/lib/ui/auth-menu.d.ts +3 -2
  108. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  109. package/dist/lib/ui/auth-menu.js +1 -1
  110. package/dist/lib/ui/auth-menu.js.map +1 -1
  111. package/package.json +20 -8
@@ -1,22 +1,60 @@
1
1
  import { PluginFatalError, formatWaitTime, isPluginFatalError } from "../fatal-errors.js";
2
2
  import { ensureIdentityKey, normalizeEmail, normalizePlan } from "../identity.js";
3
3
  import { createStickySessionState, selectAccount } from "../rotation.js";
4
- import { ensureOpenAIOAuthDomain, saveAuthStorage } from "../storage.js";
4
+ import { ensureOpenAIOAuthDomain, loadAuthStorage, saveAuthStorage } from "../storage.js";
5
5
  import { parseJwtClaims } from "../claims.js";
6
6
  import { formatAccountLabel } from "./accounts.js";
7
7
  import { extractAccountId, refreshAccessToken } from "./oauth-utils.js";
8
8
  const AUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
9
9
  const AUTH_REFRESH_LEASE_MS = 30_000;
10
- const LAST_USED_WRITE_INTERVAL_MS = 5_000;
10
+ const LAST_USED_WRITE_INTERVAL_MS = 60_000;
11
11
  function isOAuthTokenRefreshError(value) {
12
12
  return value instanceof Error && ("status" in value || "oauthCode" in value);
13
13
  }
14
+ const TERMINAL_REFRESH_ERROR_CODES = new Set([
15
+ "invalid_grant",
16
+ "invalid_refresh_token",
17
+ "refresh_token_revoked",
18
+ "token_revoked"
19
+ ]);
20
+ function isTerminalRefreshCredentialError(error) {
21
+ if (isOAuthTokenRefreshError(error)) {
22
+ const oauthCode = typeof error.oauthCode === "string" ? error.oauthCode.trim().toLowerCase() : undefined;
23
+ if (oauthCode && TERMINAL_REFRESH_ERROR_CODES.has(oauthCode)) {
24
+ return true;
25
+ }
26
+ }
27
+ if (error instanceof Error) {
28
+ const message = error.message.trim().toLowerCase();
29
+ if (message.includes("invalid_grant"))
30
+ return true;
31
+ if (message.includes("refresh token") && (message.includes("invalid") || message.includes("expired"))) {
32
+ return true;
33
+ }
34
+ if (message.includes("refresh token") && message.includes("revoked")) {
35
+ return true;
36
+ }
37
+ }
38
+ return false;
39
+ }
14
40
  export function createAcquireOpenAIAuthInputDefaults() {
15
41
  return {
16
42
  stickySessionState: createStickySessionState(),
17
43
  hybridSessionState: createStickySessionState()
18
44
  };
19
45
  }
46
+ function buildAttemptKeyForCandidate(account, index) {
47
+ const identityKey = account.identityKey?.trim();
48
+ if (identityKey)
49
+ return identityKey;
50
+ const accountId = account.accountId?.trim();
51
+ const email = normalizeEmail(account.email);
52
+ const plan = normalizePlan(account.plan);
53
+ if (accountId && email && plan) {
54
+ return `${accountId}|${email}|${plan}`;
55
+ }
56
+ return `idx:${index}`;
57
+ }
20
58
  export async function acquireOpenAIAuth(input) {
21
59
  let access;
22
60
  let accountId;
@@ -33,67 +71,74 @@ export async function acquireOpenAIAuth(input) {
33
71
  let rotationLogged = false;
34
72
  let lastSelectionTrace;
35
73
  try {
36
- if (input.isSubagentRequest && input.context?.sessionKey) {
37
- input.seenSessionKeys.delete(input.context.sessionKey);
38
- input.stickySessionState.bySessionKey.delete(input.context.sessionKey);
39
- input.hybridSessionState.bySessionKey.delete(input.context.sessionKey);
40
- }
41
74
  while (true) {
42
75
  let refreshClaim;
43
76
  let shouldStop = false;
44
- await saveAuthStorage(undefined, (authFile) => {
45
- const now = Date.now();
46
- const openai = authFile.openai;
47
- if (!openai || openai.type !== "oauth") {
48
- throw new PluginFatalError({
49
- message: "Not authenticated with OpenAI. Run `opencode auth login`.",
50
- status: 401,
51
- type: "oauth_not_configured",
52
- param: "auth"
53
- });
54
- }
55
- const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
56
- totalAccounts = domain.accounts.length;
57
- if (domain.accounts.length === 0) {
58
- throw new PluginFatalError({
59
- message: `No OpenAI ${input.authMode} accounts configured. Run \`opencode auth login\`.`,
60
- status: 401,
61
- type: "no_accounts_configured",
62
- param: "accounts"
63
- });
64
- }
65
- const enabled = domain.accounts.filter((account) => account.enabled !== false);
66
- if (enabled.length === 0) {
67
- throw new PluginFatalError({
68
- message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
69
- status: 403,
70
- type: "no_enabled_accounts",
71
- param: "accounts"
72
- });
73
- }
74
- const rotationStrategy = input.configuredRotationStrategy ?? domain.strategy ?? "sticky";
75
- if (!rotationLogged) {
76
- input.log?.debug("rotation begin", {
77
- strategy: rotationStrategy,
78
- activeIdentityKey: domain.activeIdentityKey,
79
- totalAccounts: domain.accounts.length,
80
- enabledAccounts: enabled.length,
81
- mode: input.authMode,
82
- sessionKey: input.context?.sessionKey ?? null
83
- });
84
- rotationLogged = true;
85
- }
86
- if (attempted.size >= domain.accounts.length) {
87
- shouldStop = true;
88
- return;
89
- }
77
+ let shouldPersistSessionAffinityState = false;
78
+ const now = Date.now();
79
+ const authSnapshot = await loadAuthStorage(undefined, { lockReads: false });
80
+ const openai = authSnapshot.openai;
81
+ if (!openai || openai.type !== "oauth") {
82
+ throw new PluginFatalError({
83
+ message: "Not authenticated with OpenAI. Run `opencode auth login`.",
84
+ status: 401,
85
+ type: "oauth_not_configured",
86
+ param: "auth"
87
+ });
88
+ }
89
+ const domain = ensureOpenAIOAuthDomain(authSnapshot, input.authMode);
90
+ totalAccounts = domain.accounts.length;
91
+ if (domain.accounts.length === 0) {
92
+ throw new PluginFatalError({
93
+ message: `No OpenAI ${input.authMode} accounts configured. Run \`opencode auth login\`.`,
94
+ status: 401,
95
+ type: "no_accounts_configured",
96
+ param: "accounts"
97
+ });
98
+ }
99
+ const enabled = domain.accounts.filter((account) => account.enabled !== false);
100
+ if (enabled.length === 0) {
101
+ throw new PluginFatalError({
102
+ message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
103
+ status: 403,
104
+ type: "no_enabled_accounts",
105
+ param: "accounts"
106
+ });
107
+ }
108
+ const rotationStrategy = input.configuredRotationStrategy ?? domain.strategy ?? "sticky";
109
+ if (!rotationLogged) {
110
+ input.log?.debug("rotation begin", {
111
+ strategy: rotationStrategy,
112
+ activeIdentityKey: domain.activeIdentityKey,
113
+ totalAccounts: domain.accounts.length,
114
+ enabledAccounts: enabled.length,
115
+ mode: input.authMode,
116
+ sessionKey: input.context?.sessionKey ?? null
117
+ });
118
+ rotationLogged = true;
119
+ }
120
+ const selectableEntries = domain.accounts
121
+ .map((account, index) => ({
122
+ account,
123
+ index,
124
+ attemptKey: buildAttemptKeyForCandidate(account, index)
125
+ }))
126
+ .filter((entry) => !attempted.has(entry.attemptKey));
127
+ if (selectableEntries.length === 0) {
128
+ input.log?.debug("rotation stop: exhausted candidate set", {
129
+ attempted: attempted.size,
130
+ totalAccounts: domain.accounts.length
131
+ });
132
+ shouldStop = true;
133
+ }
134
+ else {
90
135
  const sessionState = rotationStrategy === "sticky"
91
136
  ? input.stickySessionState
92
137
  : rotationStrategy === "hybrid"
93
138
  ? input.hybridSessionState
94
139
  : undefined;
95
140
  const selected = selectAccount({
96
- accounts: domain.accounts,
141
+ accounts: selectableEntries.map((entry) => entry.account),
97
142
  strategy: rotationStrategy,
98
143
  activeIdentityKey: domain.activeIdentityKey,
99
144
  now,
@@ -123,77 +168,140 @@ export async function acquireOpenAIAuth(input) {
123
168
  totalAccounts: domain.accounts.length
124
169
  });
125
170
  shouldStop = true;
126
- return;
127
- }
128
- const selectedIndex = domain.accounts.findIndex((account) => account === selected);
129
- const attemptKey = selected.identityKey ??
130
- selected.refresh ??
131
- (selectedIndex >= 0 ? `idx:${selectedIndex}` : `idx:${attempted.size}`);
132
- if (attempted.has(attemptKey)) {
133
- input.log?.debug("rotation stop: duplicate attempt key", {
134
- attemptKey,
135
- selectedIdentityKey: selected.identityKey,
136
- selectedIndex
137
- });
138
- shouldStop = true;
139
- return;
140
- }
141
- attempted.add(attemptKey);
142
- if (!input.isSubagentRequest && input.context?.sessionKey && sessionState) {
143
- input.persistSessionAffinityState();
144
- }
145
- input.log?.debug("rotation candidate selected", {
146
- attemptKey,
147
- selectedIdentityKey: selected.identityKey,
148
- selectedIndex,
149
- selectedEnabled: selected.enabled !== false,
150
- selectedCooldownUntil: selected.cooldownUntil ?? null,
151
- selectedExpires: selected.expires ?? null
152
- });
153
- if (lastSelectionTrace) {
154
- lastSelectionTrace = {
155
- ...lastSelectionTrace,
156
- attemptedCount: attempted.size,
157
- ...(selected.identityKey ? { selectedIdentityKey: selected.identityKey } : null),
158
- ...(selectedIndex >= 0 ? { selectedIndex } : null),
159
- attemptKey
160
- };
161
171
  }
162
- accountLabel = formatAccountLabel(selected, selectedIndex >= 0 ? selectedIndex : 0);
163
- email = selected.email;
164
- plan = selected.plan;
165
- if (selected.access && selected.expires && selected.expires > now) {
166
- const previousLastUsed = typeof selected.lastUsed === "number" ? selected.lastUsed : undefined;
167
- if (previousLastUsed === undefined || now - previousLastUsed >= LAST_USED_WRITE_INTERVAL_MS) {
168
- selected.lastUsed = now;
172
+ else {
173
+ const selectedEntry = selectableEntries.find((entry) => entry.account === selected);
174
+ if (!selectedEntry) {
175
+ shouldStop = true;
176
+ }
177
+ else {
178
+ const { index: selectedIndex, attemptKey } = selectedEntry;
179
+ if (attempted.has(attemptKey)) {
180
+ input.log?.debug("rotation skip: duplicate attempt key", {
181
+ attemptKey,
182
+ selectedIdentityKey: selected.identityKey,
183
+ selectedIndex
184
+ });
185
+ }
186
+ else {
187
+ attempted.add(attemptKey);
188
+ if (!input.isSubagentRequest && input.context?.sessionKey && sessionState) {
189
+ shouldPersistSessionAffinityState = true;
190
+ }
191
+ input.log?.debug("rotation candidate selected", {
192
+ attemptKey,
193
+ selectedIdentityKey: selected.identityKey,
194
+ selectedIndex,
195
+ selectedEnabled: selected.enabled !== false,
196
+ selectedCooldownUntil: selected.cooldownUntil ?? null,
197
+ selectedExpires: selected.expires ?? null
198
+ });
199
+ if (lastSelectionTrace) {
200
+ lastSelectionTrace = {
201
+ ...lastSelectionTrace,
202
+ attemptedCount: attempted.size,
203
+ ...(selected.identityKey ? { selectedIdentityKey: selected.identityKey } : null),
204
+ ...(selectedIndex >= 0 ? { selectedIndex } : null),
205
+ attemptKey
206
+ };
207
+ }
208
+ accountLabel = formatAccountLabel(selected, selectedIndex >= 0 ? selectedIndex : 0);
209
+ email = selected.email;
210
+ plan = selected.plan;
211
+ const selectedIdentityKey = ensureIdentityKey(selected).identityKey;
212
+ if (!selectedIdentityKey) {
213
+ sawMissingIdentity = true;
214
+ }
215
+ else if (selected.access && selected.expires && selected.expires > now) {
216
+ access = selected.access;
217
+ accountId = selected.accountId;
218
+ identityKey = selectedIdentityKey;
219
+ const selectionStrategy = lastSelectionTrace?.strategy ?? input.configuredRotationStrategy ?? domain.strategy;
220
+ if (selectionStrategy === "hybrid" || selectionStrategy === "round_robin") {
221
+ await saveAuthStorage(undefined, (authFile) => {
222
+ const currentDomain = ensureOpenAIOAuthDomain(authFile, input.authMode);
223
+ const currentByIdentity = selectedIdentityKey
224
+ ? currentDomain.accounts.find((account) => account.identityKey === selectedIdentityKey)
225
+ : undefined;
226
+ const current = currentByIdentity ?? currentDomain.accounts[selectedIndex];
227
+ if (!current)
228
+ return;
229
+ const currentIndex = currentDomain.accounts.findIndex((account) => account === current);
230
+ const currentAttemptKey = buildAttemptKeyForCandidate(current, currentIndex >= 0 ? currentIndex : selectedIndex);
231
+ if (currentAttemptKey !== attemptKey || current.enabled === false) {
232
+ return;
233
+ }
234
+ if (!current.identityKey && selectedIdentityKey) {
235
+ current.identityKey = selectedIdentityKey;
236
+ }
237
+ if (selectionStrategy === "round_robin" && current.identityKey) {
238
+ if (currentDomain.activeIdentityKey !== current.identityKey) {
239
+ currentDomain.activeIdentityKey = current.identityKey;
240
+ }
241
+ return;
242
+ }
243
+ const currentNow = Date.now();
244
+ const previousLastUsed = typeof current.lastUsed === "number" ? current.lastUsed : undefined;
245
+ if (previousLastUsed === undefined || currentNow - previousLastUsed >= LAST_USED_WRITE_INTERVAL_MS) {
246
+ current.lastUsed = currentNow;
247
+ }
248
+ });
249
+ }
250
+ }
251
+ else if (!selected.refresh) {
252
+ sawMissingRefresh = true;
253
+ await saveAuthStorage(undefined, (authFile) => {
254
+ const currentDomain = ensureOpenAIOAuthDomain(authFile, input.authMode);
255
+ const currentByIdentity = selectedIdentityKey
256
+ ? currentDomain.accounts.find((account) => account.identityKey === selectedIdentityKey)
257
+ : undefined;
258
+ const current = currentByIdentity ?? currentDomain.accounts[selectedIndex];
259
+ if (!current)
260
+ return;
261
+ const currentIndex = currentDomain.accounts.findIndex((account) => account === current);
262
+ const currentAttemptKey = buildAttemptKeyForCandidate(current, currentIndex >= 0 ? currentIndex : selectedIndex);
263
+ if (currentAttemptKey !== attemptKey || current.enabled === false || current.refresh) {
264
+ return;
265
+ }
266
+ current.cooldownUntil = now + AUTH_REFRESH_FAILURE_COOLDOWN_MS;
267
+ });
268
+ }
269
+ else {
270
+ const leaseUntil = now + AUTH_REFRESH_LEASE_MS;
271
+ await saveAuthStorage(undefined, (authFile) => {
272
+ const currentDomain = ensureOpenAIOAuthDomain(authFile, input.authMode);
273
+ const currentByIdentity = currentDomain.accounts.find((account) => account.identityKey === selectedIdentityKey);
274
+ const current = currentByIdentity ?? currentDomain.accounts[selectedIndex];
275
+ if (!current)
276
+ return;
277
+ const currentIndex = currentDomain.accounts.findIndex((account) => account === current);
278
+ const currentAttemptKey = buildAttemptKeyForCandidate(current, currentIndex >= 0 ? currentIndex : selectedIndex);
279
+ if (currentAttemptKey !== attemptKey)
280
+ return;
281
+ if (current.enabled === false ||
282
+ !current.refresh ||
283
+ current.refresh !== selected.refresh ||
284
+ (typeof current.refreshLeaseUntil === "number" && current.refreshLeaseUntil > now)) {
285
+ return;
286
+ }
287
+ current.refreshLeaseUntil = leaseUntil;
288
+ refreshClaim = {
289
+ identityKey: current.identityKey ?? selectedIdentityKey,
290
+ refreshToken: current.refresh,
291
+ leaseUntil,
292
+ selectedIndex: currentIndex >= 0 ? currentIndex : selectedIndex
293
+ };
294
+ });
295
+ }
296
+ }
169
297
  }
170
- access = selected.access;
171
- accountId = selected.accountId;
172
- identityKey = selected.identityKey;
173
- if (selected.identityKey)
174
- domain.activeIdentityKey = selected.identityKey;
175
- return;
176
- }
177
- if (!selected.refresh) {
178
- sawMissingRefresh = true;
179
- selected.cooldownUntil = now + AUTH_REFRESH_FAILURE_COOLDOWN_MS;
180
- return;
181
- }
182
- if (!selected.identityKey) {
183
- sawMissingIdentity = true;
184
- return;
185
298
  }
186
- const leaseUntil = now + AUTH_REFRESH_LEASE_MS;
187
- selected.refreshLeaseUntil = leaseUntil;
188
- refreshClaim = {
189
- identityKey: selected.identityKey,
190
- refreshToken: selected.refresh,
191
- leaseUntil,
192
- selectedIndex: selectedIndex >= 0 ? selectedIndex : 0
193
- };
194
- });
299
+ }
195
300
  if (access)
196
301
  break;
302
+ if (shouldPersistSessionAffinityState) {
303
+ await input.persistSessionAffinityState();
304
+ }
197
305
  if (!refreshClaim) {
198
306
  if (shouldStop || (totalAccounts > 0 && attempted.size >= totalAccounts)) {
199
307
  break;
@@ -201,21 +309,24 @@ export async function acquireOpenAIAuth(input) {
201
309
  continue;
202
310
  }
203
311
  try {
204
- const tokens = await refreshAccessToken(refreshClaim.refreshToken);
312
+ const activeRefreshClaim = refreshClaim;
313
+ const tokens = await refreshAccessToken(activeRefreshClaim.refreshToken);
205
314
  const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000;
206
315
  const refreshedAccountId = extractAccountId(tokens);
207
316
  const claims = parseJwtClaims(tokens.id_token ?? tokens.access_token);
208
317
  await saveAuthStorage(undefined, (authFile) => {
209
318
  const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
210
- const selected = domain.accounts.find((account) => account.identityKey === refreshClaim?.identityKey);
319
+ const selected = activeRefreshClaim.identityKey
320
+ ? domain.accounts.find((account) => account.identityKey === activeRefreshClaim.identityKey)
321
+ : domain.accounts[activeRefreshClaim.selectedIndex];
211
322
  if (!selected)
212
323
  return;
213
324
  const now = Date.now();
214
325
  if (typeof selected.refreshLeaseUntil !== "number" ||
215
- selected.refreshLeaseUntil !== refreshClaim?.leaseUntil ||
326
+ selected.refreshLeaseUntil !== activeRefreshClaim.leaseUntil ||
216
327
  selected.refreshLeaseUntil <= now ||
217
- selected.refresh !== refreshClaim?.refreshToken) {
218
- if (selected.refreshLeaseUntil === refreshClaim?.leaseUntil) {
328
+ selected.refresh !== activeRefreshClaim.refreshToken) {
329
+ if (selected.refreshLeaseUntil === activeRefreshClaim.leaseUntil) {
219
330
  delete selected.refreshLeaseUntil;
220
331
  }
221
332
  return;
@@ -241,7 +352,7 @@ export async function acquireOpenAIAuth(input) {
241
352
  delete selected.cooldownUntil;
242
353
  if (selected.identityKey)
243
354
  domain.activeIdentityKey = selected.identityKey;
244
- accountLabel = formatAccountLabel(selected, refreshClaim?.selectedIndex ?? 0);
355
+ accountLabel = formatAccountLabel(selected, activeRefreshClaim.selectedIndex);
245
356
  email = selected.email;
246
357
  plan = selected.plan;
247
358
  access = selected.access;
@@ -250,20 +361,23 @@ export async function acquireOpenAIAuth(input) {
250
361
  });
251
362
  }
252
363
  catch (error) {
253
- const invalidGrant = isOAuthTokenRefreshError(error) && error.oauthCode?.toLowerCase() === "invalid_grant";
364
+ const invalidGrant = isTerminalRefreshCredentialError(error);
254
365
  if (invalidGrant) {
255
366
  sawInvalidGrant = true;
256
367
  }
257
368
  else {
258
369
  sawRefreshFailure = true;
259
370
  }
371
+ const activeRefreshClaim = refreshClaim;
260
372
  await saveAuthStorage(undefined, (authFile) => {
261
373
  const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
262
- const selected = domain.accounts.find((account) => account.identityKey === refreshClaim?.identityKey);
374
+ const selected = activeRefreshClaim.identityKey
375
+ ? domain.accounts.find((account) => account.identityKey === activeRefreshClaim.identityKey)
376
+ : domain.accounts[activeRefreshClaim.selectedIndex];
263
377
  if (!selected)
264
378
  return;
265
- if (selected.refreshLeaseUntil !== refreshClaim?.leaseUntil ||
266
- selected.refresh !== refreshClaim?.refreshToken) {
379
+ if (selected.refreshLeaseUntil !== activeRefreshClaim.leaseUntil ||
380
+ selected.refresh !== activeRefreshClaim.refreshToken) {
267
381
  return;
268
382
  }
269
383
  delete selected.refreshLeaseUntil;
@@ -284,84 +398,83 @@ export async function acquireOpenAIAuth(input) {
284
398
  }
285
399
  }
286
400
  if (!access) {
287
- await saveAuthStorage(undefined, (authFile) => {
288
- const now = Date.now();
289
- const openai = authFile.openai;
290
- if (!openai || openai.type !== "oauth") {
291
- throw new PluginFatalError({
292
- message: "Not authenticated with OpenAI. Run `opencode auth login`.",
293
- status: 401,
294
- type: "oauth_not_configured",
295
- param: "auth"
296
- });
297
- }
298
- const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
299
- const enabledAfterAttempts = domain.accounts.filter((account) => account.enabled !== false);
300
- if (enabledAfterAttempts.length === 0 && sawInvalidGrant) {
301
- throw new PluginFatalError({
302
- message: "All enabled OpenAI refresh tokens were rejected (invalid_grant). Run `opencode auth login` to reauthenticate.",
303
- status: 401,
304
- type: "refresh_invalid_grant",
305
- param: "auth"
306
- });
307
- }
308
- const nextAvailableAt = enabledAfterAttempts.reduce((current, account) => {
309
- const cooldownUntil = typeof account.refreshLeaseUntil === "number" && account.refreshLeaseUntil > now
310
- ? account.refreshLeaseUntil
311
- : account.cooldownUntil;
312
- if (typeof cooldownUntil !== "number" || cooldownUntil <= now)
313
- return current;
314
- if (current === undefined || cooldownUntil < current)
315
- return cooldownUntil;
401
+ const now = Date.now();
402
+ const authSnapshot = await loadAuthStorage(undefined, { lockReads: false });
403
+ const openai = authSnapshot.openai;
404
+ if (!openai || openai.type !== "oauth") {
405
+ throw new PluginFatalError({
406
+ message: "Not authenticated with OpenAI. Run `opencode auth login`.",
407
+ status: 401,
408
+ type: "oauth_not_configured",
409
+ param: "auth"
410
+ });
411
+ }
412
+ const domain = ensureOpenAIOAuthDomain(authSnapshot, input.authMode);
413
+ const enabledAfterAttempts = domain.accounts.filter((account) => account.enabled !== false);
414
+ if (enabledAfterAttempts.length === 0 && sawInvalidGrant) {
415
+ throw new PluginFatalError({
416
+ message: "All enabled OpenAI refresh tokens were rejected (invalid_grant). Run `opencode auth login` to reauthenticate.",
417
+ status: 401,
418
+ type: "refresh_invalid_grant",
419
+ param: "auth"
420
+ });
421
+ }
422
+ const nextAvailableAt = enabledAfterAttempts.reduce((current, account) => {
423
+ const cooldownUntil = typeof account.refreshLeaseUntil === "number" && account.refreshLeaseUntil > now
424
+ ? account.refreshLeaseUntil
425
+ : account.cooldownUntil;
426
+ if (typeof cooldownUntil !== "number" || cooldownUntil <= now)
316
427
  return current;
317
- }, undefined);
318
- if (nextAvailableAt !== undefined) {
319
- const waitMs = Math.max(0, nextAvailableAt - now);
320
- throw new PluginFatalError({
321
- message: `All enabled OpenAI accounts are cooling down. Try again in ${formatWaitTime(waitMs)} or run \`opencode auth login\`.`,
322
- status: 429,
323
- type: "all_accounts_cooling_down",
324
- param: "accounts"
325
- });
326
- }
327
- if (sawInvalidGrant) {
328
- throw new PluginFatalError({
329
- message: "OpenAI refresh token was rejected (invalid_grant). Run `opencode auth login` to reauthenticate this account.",
330
- status: 401,
331
- type: "refresh_invalid_grant",
332
- param: "auth"
333
- });
334
- }
335
- if (sawMissingRefresh) {
336
- throw new PluginFatalError({
337
- message: "Selected OpenAI account is missing a refresh token. Run `opencode auth login` to reauthenticate.",
338
- status: 401,
339
- type: "missing_refresh_token",
340
- param: "accounts"
341
- });
342
- }
343
- if (sawMissingIdentity) {
344
- throw new PluginFatalError({
345
- message: "Selected OpenAI account is missing identity metadata. Run `opencode auth login` to reauthenticate.",
346
- status: 401,
347
- type: "missing_account_identity",
348
- param: "accounts"
349
- });
350
- }
351
- if (sawRefreshFailure) {
352
- throw new PluginFatalError({
353
- message: "Failed to refresh OpenAI access token. Run `opencode auth login` and try again.",
354
- status: 401,
355
- type: "refresh_failed",
356
- param: "auth"
357
- });
358
- }
428
+ if (current === undefined || cooldownUntil < current)
429
+ return cooldownUntil;
430
+ return current;
431
+ }, undefined);
432
+ if (nextAvailableAt !== undefined) {
433
+ const waitMs = Math.max(0, nextAvailableAt - now);
359
434
  throw new PluginFatalError({
360
- message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
361
- status: 403,
362
- type: "no_enabled_accounts",
435
+ message: `All enabled OpenAI accounts are cooling down. Try again in ${formatWaitTime(waitMs)} or run \`opencode auth login\`.`,
436
+ status: 429,
437
+ type: "all_accounts_cooling_down",
363
438
  param: "accounts"
364
439
  });
440
+ }
441
+ if (sawInvalidGrant) {
442
+ throw new PluginFatalError({
443
+ message: "OpenAI refresh token was rejected (invalid_grant). Run `opencode auth login` to reauthenticate this account.",
444
+ status: 401,
445
+ type: "refresh_invalid_grant",
446
+ param: "auth"
447
+ });
448
+ }
449
+ if (sawMissingRefresh) {
450
+ throw new PluginFatalError({
451
+ message: "Selected OpenAI account is missing a refresh token. Run `opencode auth login` to reauthenticate.",
452
+ status: 401,
453
+ type: "missing_refresh_token",
454
+ param: "accounts"
455
+ });
456
+ }
457
+ if (sawMissingIdentity) {
458
+ throw new PluginFatalError({
459
+ message: "Selected OpenAI account is missing identity metadata. Run `opencode auth login` to reauthenticate.",
460
+ status: 401,
461
+ type: "missing_account_identity",
462
+ param: "accounts"
463
+ });
464
+ }
465
+ if (sawRefreshFailure) {
466
+ throw new PluginFatalError({
467
+ message: "Failed to refresh OpenAI access token. Run `opencode auth login` and try again.",
468
+ status: 401,
469
+ type: "refresh_failed",
470
+ param: "auth"
471
+ });
472
+ }
473
+ throw new PluginFatalError({
474
+ message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
475
+ status: 403,
476
+ type: "no_enabled_accounts",
477
+ param: "accounts"
365
478
  });
366
479
  }
367
480
  }