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

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 (213) hide show
  1. package/README.md +42 -83
  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 +112 -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 +318 -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 +25 -6
  39. package/dist/lib/codex-native/oauth-auth-methods.js.map +1 -1
  40. package/dist/lib/codex-native/oauth-server-debug.d.ts +10 -0
  41. package/dist/lib/codex-native/oauth-server-debug.d.ts.map +1 -0
  42. package/dist/lib/codex-native/oauth-server-debug.js +92 -0
  43. package/dist/lib/codex-native/oauth-server-debug.js.map +1 -0
  44. package/dist/lib/codex-native/oauth-server-network.d.ts +5 -0
  45. package/dist/lib/codex-native/oauth-server-network.d.ts.map +1 -0
  46. package/dist/lib/codex-native/oauth-server-network.js +39 -0
  47. package/dist/lib/codex-native/oauth-server-network.js.map +1 -0
  48. package/dist/lib/codex-native/oauth-server-types.d.ts +24 -0
  49. package/dist/lib/codex-native/oauth-server-types.d.ts.map +1 -0
  50. package/dist/lib/codex-native/oauth-server-types.js +2 -0
  51. package/dist/lib/codex-native/oauth-server-types.js.map +1 -0
  52. package/dist/lib/codex-native/oauth-server.d.ts +2 -16
  53. package/dist/lib/codex-native/oauth-server.d.ts.map +1 -1
  54. package/dist/lib/codex-native/oauth-server.js +63 -118
  55. package/dist/lib/codex-native/oauth-server.js.map +1 -1
  56. package/dist/lib/codex-native/openai-loader-fetch-quota.d.ts +18 -0
  57. package/dist/lib/codex-native/openai-loader-fetch-quota.d.ts.map +1 -0
  58. package/dist/lib/codex-native/openai-loader-fetch-quota.js +71 -0
  59. package/dist/lib/codex-native/openai-loader-fetch-quota.js.map +1 -0
  60. package/dist/lib/codex-native/openai-loader-fetch-state.d.ts +27 -0
  61. package/dist/lib/codex-native/openai-loader-fetch-state.d.ts.map +1 -0
  62. package/dist/lib/codex-native/openai-loader-fetch-state.js +91 -0
  63. package/dist/lib/codex-native/openai-loader-fetch-state.js.map +1 -0
  64. package/dist/lib/codex-native/openai-loader-fetch.d.ts.map +1 -1
  65. package/dist/lib/codex-native/openai-loader-fetch.js +49 -131
  66. package/dist/lib/codex-native/openai-loader-fetch.js.map +1 -1
  67. package/dist/lib/codex-native/originator.d.ts.map +1 -1
  68. package/dist/lib/codex-native/originator.js +18 -1
  69. package/dist/lib/codex-native/originator.js.map +1 -1
  70. package/dist/lib/codex-native/request-transform-instructions.d.ts +16 -0
  71. package/dist/lib/codex-native/request-transform-instructions.d.ts.map +1 -0
  72. package/dist/lib/codex-native/request-transform-instructions.js +114 -0
  73. package/dist/lib/codex-native/request-transform-instructions.js.map +1 -0
  74. package/dist/lib/codex-native/request-transform-model.d.ts +39 -0
  75. package/dist/lib/codex-native/request-transform-model.d.ts.map +1 -0
  76. package/dist/lib/codex-native/request-transform-model.js +270 -0
  77. package/dist/lib/codex-native/request-transform-model.js.map +1 -0
  78. package/dist/lib/codex-native/request-transform-payload-helpers.d.ts +26 -0
  79. package/dist/lib/codex-native/request-transform-payload-helpers.d.ts.map +1 -0
  80. package/dist/lib/codex-native/request-transform-payload-helpers.js +232 -0
  81. package/dist/lib/codex-native/request-transform-payload-helpers.js.map +1 -0
  82. package/dist/lib/codex-native/request-transform-payload.d.ts +53 -0
  83. package/dist/lib/codex-native/request-transform-payload.d.ts.map +1 -0
  84. package/dist/lib/codex-native/request-transform-payload.js +214 -0
  85. package/dist/lib/codex-native/request-transform-payload.js.map +1 -0
  86. package/dist/lib/codex-native/request-transform-shared.d.ts +8 -0
  87. package/dist/lib/codex-native/request-transform-shared.d.ts.map +1 -0
  88. package/dist/lib/codex-native/request-transform-shared.js +49 -0
  89. package/dist/lib/codex-native/request-transform-shared.js.map +1 -0
  90. package/dist/lib/codex-native/request-transform.d.ts +3 -122
  91. package/dist/lib/codex-native/request-transform.d.ts.map +1 -1
  92. package/dist/lib/codex-native/request-transform.js +3 -831
  93. package/dist/lib/codex-native/request-transform.js.map +1 -1
  94. package/dist/lib/codex-native/session-affinity-state.d.ts +14 -1
  95. package/dist/lib/codex-native/session-affinity-state.d.ts.map +1 -1
  96. package/dist/lib/codex-native/session-affinity-state.js +21 -8
  97. package/dist/lib/codex-native/session-affinity-state.js.map +1 -1
  98. package/dist/lib/codex-native.js.map +1 -1
  99. package/dist/lib/codex-prompts-cache.d.ts.map +1 -1
  100. package/dist/lib/codex-prompts-cache.js.map +1 -1
  101. package/dist/lib/codex-quota-fetch.d.ts.map +1 -1
  102. package/dist/lib/codex-quota-fetch.js +13 -10
  103. package/dist/lib/codex-quota-fetch.js.map +1 -1
  104. package/dist/lib/codex-status-storage.d.ts.map +1 -1
  105. package/dist/lib/codex-status-storage.js.map +1 -1
  106. package/dist/lib/codex-status.d.ts.map +1 -1
  107. package/dist/lib/codex-status.js +28 -3
  108. package/dist/lib/codex-status.js.map +1 -1
  109. package/dist/lib/config/io.d.ts +16 -0
  110. package/dist/lib/config/io.d.ts.map +1 -0
  111. package/dist/lib/config/io.js +64 -0
  112. package/dist/lib/config/io.js.map +1 -0
  113. package/dist/lib/config/parse.d.ts +21 -0
  114. package/dist/lib/config/parse.d.ts.map +1 -0
  115. package/dist/lib/config/parse.js +347 -0
  116. package/dist/lib/config/parse.js.map +1 -0
  117. package/dist/lib/config/resolve.d.ts +27 -0
  118. package/dist/lib/config/resolve.d.ts.map +1 -0
  119. package/dist/lib/config/resolve.js +152 -0
  120. package/dist/lib/config/resolve.js.map +1 -0
  121. package/dist/lib/config/types.d.ts +72 -0
  122. package/dist/lib/config/types.d.ts.map +1 -0
  123. package/dist/lib/config/types.js +151 -0
  124. package/dist/lib/config/types.js.map +1 -0
  125. package/dist/lib/config/validation.d.ts +6 -0
  126. package/dist/lib/config/validation.d.ts.map +1 -0
  127. package/dist/lib/config/validation.js +160 -0
  128. package/dist/lib/config/validation.js.map +1 -0
  129. package/dist/lib/config.d.ts +5 -111
  130. package/dist/lib/config.d.ts.map +1 -1
  131. package/dist/lib/config.js +5 -835
  132. package/dist/lib/config.js.map +1 -1
  133. package/dist/lib/fetch-orchestrator-helpers.d.ts +13 -0
  134. package/dist/lib/fetch-orchestrator-helpers.d.ts.map +1 -0
  135. package/dist/lib/fetch-orchestrator-helpers.js +63 -0
  136. package/dist/lib/fetch-orchestrator-helpers.js.map +1 -0
  137. package/dist/lib/fetch-orchestrator-types.d.ts +71 -0
  138. package/dist/lib/fetch-orchestrator-types.d.ts.map +1 -0
  139. package/dist/lib/fetch-orchestrator-types.js +11 -0
  140. package/dist/lib/fetch-orchestrator-types.js.map +1 -0
  141. package/dist/lib/fetch-orchestrator.d.ts +3 -69
  142. package/dist/lib/fetch-orchestrator.d.ts.map +1 -1
  143. package/dist/lib/fetch-orchestrator.js +78 -57
  144. package/dist/lib/fetch-orchestrator.js.map +1 -1
  145. package/dist/lib/identity.d.ts +6 -0
  146. package/dist/lib/identity.d.ts.map +1 -1
  147. package/dist/lib/identity.js +25 -4
  148. package/dist/lib/identity.js.map +1 -1
  149. package/dist/lib/model-catalog/cache-helpers.d.ts +23 -0
  150. package/dist/lib/model-catalog/cache-helpers.d.ts.map +1 -0
  151. package/dist/lib/model-catalog/cache-helpers.js +210 -0
  152. package/dist/lib/model-catalog/cache-helpers.js.map +1 -0
  153. package/dist/lib/model-catalog/catalog-fetch.d.ts +3 -0
  154. package/dist/lib/model-catalog/catalog-fetch.d.ts.map +1 -0
  155. package/dist/lib/model-catalog/catalog-fetch.js +159 -0
  156. package/dist/lib/model-catalog/catalog-fetch.js.map +1 -0
  157. package/dist/lib/model-catalog/provider.d.ts +6 -0
  158. package/dist/lib/model-catalog/provider.d.ts.map +1 -0
  159. package/dist/lib/model-catalog/provider.js +254 -0
  160. package/dist/lib/model-catalog/provider.js.map +1 -0
  161. package/dist/lib/model-catalog/shared.d.ts +95 -0
  162. package/dist/lib/model-catalog/shared.d.ts.map +1 -0
  163. package/dist/lib/model-catalog/shared.js +154 -0
  164. package/dist/lib/model-catalog/shared.js.map +1 -0
  165. package/dist/lib/model-catalog.d.ts +3 -68
  166. package/dist/lib/model-catalog.d.ts.map +1 -1
  167. package/dist/lib/model-catalog.js +3 -767
  168. package/dist/lib/model-catalog.js.map +1 -1
  169. package/dist/lib/opencode-install.d.ts.map +1 -1
  170. package/dist/lib/opencode-install.js +5 -6
  171. package/dist/lib/opencode-install.js.map +1 -1
  172. package/dist/lib/orchestrator-agent.d.ts.map +1 -1
  173. package/dist/lib/orchestrator-agent.js +2 -1
  174. package/dist/lib/orchestrator-agent.js.map +1 -1
  175. package/dist/lib/paths.d.ts.map +1 -1
  176. package/dist/lib/paths.js +8 -2
  177. package/dist/lib/paths.js.map +1 -1
  178. package/dist/lib/proactive-refresh.d.ts.map +1 -1
  179. package/dist/lib/proactive-refresh.js +48 -13
  180. package/dist/lib/proactive-refresh.js.map +1 -1
  181. package/dist/lib/quarantine.js.map +1 -1
  182. package/dist/lib/quota-threshold-alerts.d.ts.map +1 -1
  183. package/dist/lib/quota-threshold-alerts.js +3 -1
  184. package/dist/lib/quota-threshold-alerts.js.map +1 -1
  185. package/dist/lib/refresh-queue.d.ts.map +1 -1
  186. package/dist/lib/refresh-queue.js +1 -0
  187. package/dist/lib/refresh-queue.js.map +1 -1
  188. package/dist/lib/request-snapshots.d.ts.map +1 -1
  189. package/dist/lib/request-snapshots.js +46 -10
  190. package/dist/lib/request-snapshots.js.map +1 -1
  191. package/dist/lib/rotation.d.ts.map +1 -1
  192. package/dist/lib/rotation.js +3 -2
  193. package/dist/lib/rotation.js.map +1 -1
  194. package/dist/lib/session-affinity.d.ts.map +1 -1
  195. package/dist/lib/session-affinity.js +35 -20
  196. package/dist/lib/session-affinity.js.map +1 -1
  197. package/dist/lib/storage/domain-state.d.ts +23 -0
  198. package/dist/lib/storage/domain-state.d.ts.map +1 -0
  199. package/dist/lib/storage/domain-state.js +275 -0
  200. package/dist/lib/storage/domain-state.js.map +1 -0
  201. package/dist/lib/storage/migration.d.ts +13 -0
  202. package/dist/lib/storage/migration.d.ts.map +1 -0
  203. package/dist/lib/storage/migration.js +225 -0
  204. package/dist/lib/storage/migration.js.map +1 -0
  205. package/dist/lib/storage.d.ts +2 -9
  206. package/dist/lib/storage.d.ts.map +1 -1
  207. package/dist/lib/storage.js +44 -470
  208. package/dist/lib/storage.js.map +1 -1
  209. package/dist/lib/ui/auth-menu.d.ts +3 -2
  210. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  211. package/dist/lib/ui/auth-menu.js +1 -1
  212. package/dist/lib/ui/auth-menu.js.map +1 -1
  213. package/package.json +28 -15
@@ -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,141 @@ 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 ||
246
+ currentNow - previousLastUsed >= LAST_USED_WRITE_INTERVAL_MS) {
247
+ current.lastUsed = currentNow;
248
+ }
249
+ });
250
+ }
251
+ }
252
+ else if (!selected.refresh) {
253
+ sawMissingRefresh = true;
254
+ await saveAuthStorage(undefined, (authFile) => {
255
+ const currentDomain = ensureOpenAIOAuthDomain(authFile, input.authMode);
256
+ const currentByIdentity = selectedIdentityKey
257
+ ? currentDomain.accounts.find((account) => account.identityKey === selectedIdentityKey)
258
+ : undefined;
259
+ const current = currentByIdentity ?? currentDomain.accounts[selectedIndex];
260
+ if (!current)
261
+ return;
262
+ const currentIndex = currentDomain.accounts.findIndex((account) => account === current);
263
+ const currentAttemptKey = buildAttemptKeyForCandidate(current, currentIndex >= 0 ? currentIndex : selectedIndex);
264
+ if (currentAttemptKey !== attemptKey || current.enabled === false || current.refresh) {
265
+ return;
266
+ }
267
+ current.cooldownUntil = now + AUTH_REFRESH_FAILURE_COOLDOWN_MS;
268
+ });
269
+ }
270
+ else {
271
+ const leaseUntil = now + AUTH_REFRESH_LEASE_MS;
272
+ await saveAuthStorage(undefined, (authFile) => {
273
+ const currentDomain = ensureOpenAIOAuthDomain(authFile, input.authMode);
274
+ const currentByIdentity = currentDomain.accounts.find((account) => account.identityKey === selectedIdentityKey);
275
+ const current = currentByIdentity ?? currentDomain.accounts[selectedIndex];
276
+ if (!current)
277
+ return;
278
+ const currentIndex = currentDomain.accounts.findIndex((account) => account === current);
279
+ const currentAttemptKey = buildAttemptKeyForCandidate(current, currentIndex >= 0 ? currentIndex : selectedIndex);
280
+ if (currentAttemptKey !== attemptKey)
281
+ return;
282
+ if (current.enabled === false ||
283
+ !current.refresh ||
284
+ current.refresh !== selected.refresh ||
285
+ (typeof current.refreshLeaseUntil === "number" && current.refreshLeaseUntil > now)) {
286
+ return;
287
+ }
288
+ current.refreshLeaseUntil = leaseUntil;
289
+ refreshClaim = {
290
+ identityKey: current.identityKey ?? selectedIdentityKey,
291
+ refreshToken: current.refresh,
292
+ leaseUntil,
293
+ selectedIndex: currentIndex >= 0 ? currentIndex : selectedIndex
294
+ };
295
+ });
296
+ }
297
+ }
169
298
  }
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
299
  }
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
- });
300
+ }
195
301
  if (access)
196
302
  break;
303
+ if (shouldPersistSessionAffinityState) {
304
+ await input.persistSessionAffinityState();
305
+ }
197
306
  if (!refreshClaim) {
198
307
  if (shouldStop || (totalAccounts > 0 && attempted.size >= totalAccounts)) {
199
308
  break;
@@ -201,21 +310,24 @@ export async function acquireOpenAIAuth(input) {
201
310
  continue;
202
311
  }
203
312
  try {
204
- const tokens = await refreshAccessToken(refreshClaim.refreshToken);
313
+ const activeRefreshClaim = refreshClaim;
314
+ const tokens = await refreshAccessToken(activeRefreshClaim.refreshToken);
205
315
  const refreshedExpires = Date.now() + (tokens.expires_in ?? 3600) * 1000;
206
316
  const refreshedAccountId = extractAccountId(tokens);
207
317
  const claims = parseJwtClaims(tokens.id_token ?? tokens.access_token);
208
318
  await saveAuthStorage(undefined, (authFile) => {
209
319
  const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
210
- const selected = domain.accounts.find((account) => account.identityKey === refreshClaim?.identityKey);
320
+ const selected = activeRefreshClaim.identityKey
321
+ ? domain.accounts.find((account) => account.identityKey === activeRefreshClaim.identityKey)
322
+ : domain.accounts[activeRefreshClaim.selectedIndex];
211
323
  if (!selected)
212
324
  return;
213
325
  const now = Date.now();
214
326
  if (typeof selected.refreshLeaseUntil !== "number" ||
215
- selected.refreshLeaseUntil !== refreshClaim?.leaseUntil ||
327
+ selected.refreshLeaseUntil !== activeRefreshClaim.leaseUntil ||
216
328
  selected.refreshLeaseUntil <= now ||
217
- selected.refresh !== refreshClaim?.refreshToken) {
218
- if (selected.refreshLeaseUntil === refreshClaim?.leaseUntil) {
329
+ selected.refresh !== activeRefreshClaim.refreshToken) {
330
+ if (selected.refreshLeaseUntil === activeRefreshClaim.leaseUntil) {
219
331
  delete selected.refreshLeaseUntil;
220
332
  }
221
333
  return;
@@ -241,7 +353,7 @@ export async function acquireOpenAIAuth(input) {
241
353
  delete selected.cooldownUntil;
242
354
  if (selected.identityKey)
243
355
  domain.activeIdentityKey = selected.identityKey;
244
- accountLabel = formatAccountLabel(selected, refreshClaim?.selectedIndex ?? 0);
356
+ accountLabel = formatAccountLabel(selected, activeRefreshClaim.selectedIndex);
245
357
  email = selected.email;
246
358
  plan = selected.plan;
247
359
  access = selected.access;
@@ -250,20 +362,23 @@ export async function acquireOpenAIAuth(input) {
250
362
  });
251
363
  }
252
364
  catch (error) {
253
- const invalidGrant = isOAuthTokenRefreshError(error) && error.oauthCode?.toLowerCase() === "invalid_grant";
365
+ const invalidGrant = isTerminalRefreshCredentialError(error);
254
366
  if (invalidGrant) {
255
367
  sawInvalidGrant = true;
256
368
  }
257
369
  else {
258
370
  sawRefreshFailure = true;
259
371
  }
372
+ const activeRefreshClaim = refreshClaim;
260
373
  await saveAuthStorage(undefined, (authFile) => {
261
374
  const domain = ensureOpenAIOAuthDomain(authFile, input.authMode);
262
- const selected = domain.accounts.find((account) => account.identityKey === refreshClaim?.identityKey);
375
+ const selected = activeRefreshClaim.identityKey
376
+ ? domain.accounts.find((account) => account.identityKey === activeRefreshClaim.identityKey)
377
+ : domain.accounts[activeRefreshClaim.selectedIndex];
263
378
  if (!selected)
264
379
  return;
265
- if (selected.refreshLeaseUntil !== refreshClaim?.leaseUntil ||
266
- selected.refresh !== refreshClaim?.refreshToken) {
380
+ if (selected.refreshLeaseUntil !== activeRefreshClaim.leaseUntil ||
381
+ selected.refresh !== activeRefreshClaim.refreshToken) {
267
382
  return;
268
383
  }
269
384
  delete selected.refreshLeaseUntil;
@@ -284,84 +399,83 @@ export async function acquireOpenAIAuth(input) {
284
399
  }
285
400
  }
286
401
  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;
402
+ const now = Date.now();
403
+ const authSnapshot = await loadAuthStorage(undefined, { lockReads: false });
404
+ const openai = authSnapshot.openai;
405
+ if (!openai || openai.type !== "oauth") {
406
+ throw new PluginFatalError({
407
+ message: "Not authenticated with OpenAI. Run `opencode auth login`.",
408
+ status: 401,
409
+ type: "oauth_not_configured",
410
+ param: "auth"
411
+ });
412
+ }
413
+ const domain = ensureOpenAIOAuthDomain(authSnapshot, input.authMode);
414
+ const enabledAfterAttempts = domain.accounts.filter((account) => account.enabled !== false);
415
+ if (enabledAfterAttempts.length === 0 && sawInvalidGrant) {
416
+ throw new PluginFatalError({
417
+ message: "All enabled OpenAI refresh tokens were rejected (invalid_grant). Run `opencode auth login` to reauthenticate.",
418
+ status: 401,
419
+ type: "refresh_invalid_grant",
420
+ param: "auth"
421
+ });
422
+ }
423
+ const nextAvailableAt = enabledAfterAttempts.reduce((current, account) => {
424
+ const cooldownUntil = typeof account.refreshLeaseUntil === "number" && account.refreshLeaseUntil > now
425
+ ? account.refreshLeaseUntil
426
+ : account.cooldownUntil;
427
+ if (typeof cooldownUntil !== "number" || cooldownUntil <= now)
316
428
  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
- }
429
+ if (current === undefined || cooldownUntil < current)
430
+ return cooldownUntil;
431
+ return current;
432
+ }, undefined);
433
+ if (nextAvailableAt !== undefined) {
434
+ const waitMs = Math.max(0, nextAvailableAt - now);
359
435
  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",
436
+ message: `All enabled OpenAI accounts are cooling down. Try again in ${formatWaitTime(waitMs)} or run \`opencode auth login\`.`,
437
+ status: 429,
438
+ type: "all_accounts_cooling_down",
363
439
  param: "accounts"
364
440
  });
441
+ }
442
+ if (sawInvalidGrant) {
443
+ throw new PluginFatalError({
444
+ message: "OpenAI refresh token was rejected (invalid_grant). Run `opencode auth login` to reauthenticate this account.",
445
+ status: 401,
446
+ type: "refresh_invalid_grant",
447
+ param: "auth"
448
+ });
449
+ }
450
+ if (sawMissingRefresh) {
451
+ throw new PluginFatalError({
452
+ message: "Selected OpenAI account is missing a refresh token. Run `opencode auth login` to reauthenticate.",
453
+ status: 401,
454
+ type: "missing_refresh_token",
455
+ param: "accounts"
456
+ });
457
+ }
458
+ if (sawMissingIdentity) {
459
+ throw new PluginFatalError({
460
+ message: "Selected OpenAI account is missing identity metadata. Run `opencode auth login` to reauthenticate.",
461
+ status: 401,
462
+ type: "missing_account_identity",
463
+ param: "accounts"
464
+ });
465
+ }
466
+ if (sawRefreshFailure) {
467
+ throw new PluginFatalError({
468
+ message: "Failed to refresh OpenAI access token. Run `opencode auth login` and try again.",
469
+ status: 401,
470
+ type: "refresh_failed",
471
+ param: "auth"
472
+ });
473
+ }
474
+ throw new PluginFatalError({
475
+ message: `No enabled OpenAI ${input.authMode} accounts available. Enable an account or run \`opencode auth login\`.`,
476
+ status: 403,
477
+ type: "no_enabled_accounts",
478
+ param: "accounts"
365
479
  });
366
480
  }
367
481
  }