@aryee337/aery-ai 0.2.28 → 0.2.29

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 (417) hide show
  1. package/CHANGELOG.md +2914 -0
  2. package/README.md +614 -813
  3. package/package.json +140 -105
  4. package/src/api-registry.ts +96 -0
  5. package/src/auth-broker/client.ts +358 -0
  6. package/src/auth-broker/index.ts +5 -0
  7. package/src/auth-broker/refresher.ts +117 -0
  8. package/src/auth-broker/remote-store.ts +623 -0
  9. package/src/auth-broker/server.ts +644 -0
  10. package/src/auth-broker/types.ts +127 -0
  11. package/src/auth-broker/wire-schemas.ts +200 -0
  12. package/src/auth-gateway/http.ts +194 -0
  13. package/src/auth-gateway/index.ts +3 -0
  14. package/src/auth-gateway/server.ts +818 -0
  15. package/src/auth-gateway/types.ts +143 -0
  16. package/src/auth-storage.ts +4422 -0
  17. package/src/index.ts +54 -0
  18. package/src/model-cache.ts +129 -0
  19. package/src/model-manager.ts +469 -0
  20. package/src/model-thinking.ts +782 -0
  21. package/src/models.json +83530 -0
  22. package/src/models.json.d.ts +9 -0
  23. package/src/models.ts +56 -0
  24. package/src/prompts/turn-aborted-guidance.md +4 -0
  25. package/src/provider-details.ts +90 -0
  26. package/src/provider-models/bundled-references.ts +38 -0
  27. package/src/provider-models/descriptors.ts +355 -0
  28. package/src/provider-models/google.ts +88 -0
  29. package/src/provider-models/index.ts +5 -0
  30. package/src/provider-models/ollama.ts +153 -0
  31. package/src/provider-models/openai-compat.ts +2817 -0
  32. package/src/provider-models/special.ts +67 -0
  33. package/src/providers/aery-native-client.ts +228 -0
  34. package/src/providers/aery-native-server.ts +212 -0
  35. package/src/providers/amazon-bedrock.ts +873 -0
  36. package/src/providers/anthropic-client.ts +318 -0
  37. package/src/providers/anthropic-messages-server-schema.ts +243 -0
  38. package/src/providers/anthropic-messages-server.ts +683 -0
  39. package/src/providers/anthropic-wire.ts +268 -0
  40. package/src/providers/anthropic.ts +3094 -0
  41. package/src/providers/aws-credentials.ts +501 -0
  42. package/src/providers/aws-eventstream.ts +185 -0
  43. package/src/providers/aws-sigv4.ts +218 -0
  44. package/src/providers/azure-openai-responses.ts +361 -0
  45. package/src/providers/cursor/gen/agent_pb.ts +15274 -0
  46. package/src/providers/cursor/proto/agent.proto +3526 -0
  47. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  48. package/src/providers/cursor/proto/buf.yaml +17 -0
  49. package/src/providers/cursor.ts +2621 -0
  50. package/src/providers/error-message.ts +21 -0
  51. package/src/providers/github-copilot-headers.ts +140 -0
  52. package/src/providers/gitlab-duo.ts +372 -0
  53. package/src/providers/google-auth.ts +252 -0
  54. package/src/providers/google-gemini-cli.ts +809 -0
  55. package/src/providers/google-gemini-headers.ts +41 -0
  56. package/src/providers/google-shared.ts +917 -0
  57. package/src/providers/google-types.ts +167 -0
  58. package/src/providers/google-vertex.ts +91 -0
  59. package/src/providers/google.ts +41 -0
  60. package/src/providers/grammar.ts +70 -0
  61. package/src/providers/kimi.ts +52 -0
  62. package/src/providers/mock.ts +496 -0
  63. package/src/providers/ollama.ts +644 -0
  64. package/src/providers/openai-anthropic-shim.ts +138 -0
  65. package/src/providers/openai-chat-server-schema.ts +252 -0
  66. package/src/providers/openai-chat-server.ts +647 -0
  67. package/src/providers/openai-codex/constants.ts +43 -0
  68. package/src/providers/openai-codex/request-transformer.ts +161 -0
  69. package/src/providers/openai-codex/response-handler.ts +81 -0
  70. package/src/providers/openai-codex-responses.ts +3018 -0
  71. package/src/providers/openai-completions-compat.ts +300 -0
  72. package/src/providers/openai-completions.ts +1979 -0
  73. package/src/providers/openai-responses-server-schema.ts +290 -0
  74. package/src/providers/openai-responses-server.ts +1183 -0
  75. package/src/providers/openai-responses-shared.ts +873 -0
  76. package/src/providers/openai-responses.ts +679 -0
  77. package/src/providers/register-builtins.ts +436 -0
  78. package/src/providers/synthetic.ts +50 -0
  79. package/src/providers/transform-messages.ts +382 -0
  80. package/src/providers/vision-guard.ts +31 -0
  81. package/src/providers/xai-responses.ts +82 -0
  82. package/src/rate-limit-utils.ts +84 -0
  83. package/src/stream.ts +1065 -0
  84. package/src/types.ts +944 -0
  85. package/src/usage/claude.ts +482 -0
  86. package/src/usage/gemini.ts +250 -0
  87. package/src/usage/github-copilot.ts +421 -0
  88. package/src/usage/google-antigravity.ts +201 -0
  89. package/src/usage/kimi.ts +271 -0
  90. package/src/usage/minimax-code.ts +31 -0
  91. package/src/usage/openai-codex.ts +503 -0
  92. package/src/usage/shared.ts +10 -0
  93. package/src/usage/zai.ts +247 -0
  94. package/src/usage.ts +185 -0
  95. package/src/utils/abort.ts +51 -0
  96. package/src/utils/abortable-iterator.ts +69 -0
  97. package/src/utils/anthropic-auth.ts +93 -0
  98. package/src/utils/discovery/antigravity.ts +261 -0
  99. package/src/utils/discovery/codex.ts +371 -0
  100. package/src/utils/discovery/cursor.ts +306 -0
  101. package/src/utils/discovery/gemini.ts +248 -0
  102. package/src/utils/discovery/index.ts +4 -0
  103. package/src/utils/discovery/openai-compatible.ts +224 -0
  104. package/src/utils/event-stream.ts +142 -0
  105. package/src/utils/fireworks-model-id.ts +30 -0
  106. package/src/utils/foundry.ts +8 -0
  107. package/src/utils/http-inspector.ts +176 -0
  108. package/src/utils/idle-iterator.ts +267 -0
  109. package/src/utils/json-parse.ts +182 -0
  110. package/src/utils/oauth/__tests__/xai-oauth.test.ts +107 -0
  111. package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
  112. package/src/utils/oauth/anthropic.ts +273 -0
  113. package/src/utils/oauth/api-key-login.ts +87 -0
  114. package/src/utils/oauth/api-key-validation.ts +92 -0
  115. package/src/utils/oauth/callback-server.ts +276 -0
  116. package/src/utils/oauth/cerebras.ts +16 -0
  117. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  118. package/src/utils/oauth/cursor.ts +157 -0
  119. package/src/utils/oauth/deepseek.ts +53 -0
  120. package/src/utils/oauth/firepass.ts +24 -0
  121. package/src/utils/oauth/fireworks.ts +15 -0
  122. package/src/utils/oauth/github-copilot.ts +362 -0
  123. package/src/utils/oauth/gitlab-duo.ts +123 -0
  124. package/src/utils/oauth/google-antigravity.ts +200 -0
  125. package/src/utils/oauth/google-gemini-cli.ts +256 -0
  126. package/src/utils/oauth/google-oauth-shared.ts +110 -0
  127. package/src/utils/oauth/huggingface.ts +62 -0
  128. package/src/utils/oauth/index.ts +484 -0
  129. package/src/utils/oauth/kagi.ts +47 -0
  130. package/src/utils/oauth/kilo.ts +87 -0
  131. package/src/utils/oauth/kimi.ts +254 -0
  132. package/src/utils/oauth/litellm.ts +47 -0
  133. package/src/utils/oauth/lm-studio.ts +38 -0
  134. package/src/utils/oauth/minimax-code.ts +78 -0
  135. package/src/utils/oauth/moonshot.ts +23 -0
  136. package/src/utils/oauth/nanogpt.ts +15 -0
  137. package/src/utils/oauth/nvidia.ts +70 -0
  138. package/src/utils/oauth/oauth.html +203 -0
  139. package/src/utils/oauth/ollama-cloud.ts +28 -0
  140. package/src/utils/oauth/ollama.ts +47 -0
  141. package/src/utils/oauth/openai-codex.ts +299 -0
  142. package/src/utils/oauth/opencode.ts +49 -0
  143. package/src/utils/oauth/openrouter.ts +20 -0
  144. package/src/utils/oauth/parallel.ts +46 -0
  145. package/src/utils/oauth/perplexity.ts +206 -0
  146. package/src/utils/oauth/pkce.ts +18 -0
  147. package/src/utils/oauth/qianfan.ts +58 -0
  148. package/src/utils/oauth/qwen-portal.ts +60 -0
  149. package/src/utils/oauth/synthetic.ts +15 -0
  150. package/src/utils/oauth/tavily.ts +46 -0
  151. package/src/utils/oauth/together.ts +16 -0
  152. package/src/utils/oauth/types.ts +99 -0
  153. package/src/utils/oauth/venice.ts +59 -0
  154. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  155. package/src/utils/oauth/vllm.ts +40 -0
  156. package/src/utils/oauth/wafer.ts +50 -0
  157. package/src/utils/oauth/xai-oauth.ts +342 -0
  158. package/src/utils/oauth/xiaomi.ts +139 -0
  159. package/src/utils/oauth/zai.ts +60 -0
  160. package/src/utils/oauth/zenmux.ts +15 -0
  161. package/src/utils/oauth/zhipu.ts +60 -0
  162. package/src/utils/overflow.ts +137 -0
  163. package/src/utils/parse-bind.ts +54 -0
  164. package/src/utils/provider-response.ts +30 -0
  165. package/src/utils/request-debug.ts +336 -0
  166. package/src/utils/retry-after.ts +110 -0
  167. package/src/utils/retry.ts +54 -0
  168. package/src/utils/schema/CONSTRAINTS.md +164 -0
  169. package/src/utils/schema/adapt.ts +36 -0
  170. package/src/utils/schema/compatibility.ts +435 -0
  171. package/src/utils/schema/dereference.ts +98 -0
  172. package/src/utils/schema/draft.ts +341 -0
  173. package/src/utils/schema/equality.ts +97 -0
  174. package/src/utils/schema/fields.ts +191 -0
  175. package/src/utils/schema/index.ts +13 -0
  176. package/src/utils/schema/json-schema-validator.ts +577 -0
  177. package/src/utils/schema/meta-validator.ts +167 -0
  178. package/src/utils/schema/normalize.ts +1588 -0
  179. package/src/utils/schema/spill.ts +43 -0
  180. package/src/utils/schema/stamps.ts +97 -0
  181. package/src/utils/schema/types.ts +10 -0
  182. package/src/utils/schema/wire.ts +293 -0
  183. package/src/utils/schema/zod-decontaminate.ts +331 -0
  184. package/src/utils/sdk-stream-timeout.ts +43 -0
  185. package/src/utils/sse-debug.ts +289 -0
  186. package/src/utils/stream-markup-healing.ts +612 -0
  187. package/src/utils/tool-choice.ts +99 -0
  188. package/src/utils/validation.ts +1024 -0
  189. package/src/utils.ts +166 -0
  190. package/dist/api-registry.d.ts +0 -20
  191. package/dist/api-registry.d.ts.map +0 -1
  192. package/dist/api-registry.js +0 -44
  193. package/dist/api-registry.js.map +0 -1
  194. package/dist/bedrock-provider.d.ts +0 -5
  195. package/dist/bedrock-provider.d.ts.map +0 -1
  196. package/dist/bedrock-provider.js +0 -6
  197. package/dist/bedrock-provider.js.map +0 -1
  198. package/dist/cli.d.ts +0 -3
  199. package/dist/cli.d.ts.map +0 -1
  200. package/dist/cli.js +0 -130
  201. package/dist/cli.js.map +0 -1
  202. package/dist/env-api-keys.d.ts +0 -18
  203. package/dist/env-api-keys.d.ts.map +0 -1
  204. package/dist/env-api-keys.js +0 -178
  205. package/dist/env-api-keys.js.map +0 -1
  206. package/dist/image-models.d.ts +0 -10
  207. package/dist/image-models.d.ts.map +0 -1
  208. package/dist/image-models.generated.d.ts +0 -440
  209. package/dist/image-models.generated.d.ts.map +0 -1
  210. package/dist/image-models.generated.js +0 -442
  211. package/dist/image-models.generated.js.map +0 -1
  212. package/dist/image-models.js +0 -23
  213. package/dist/image-models.js.map +0 -1
  214. package/dist/images-api-registry.d.ts +0 -14
  215. package/dist/images-api-registry.d.ts.map +0 -1
  216. package/dist/images-api-registry.js +0 -22
  217. package/dist/images-api-registry.js.map +0 -1
  218. package/dist/images.d.ts +0 -4
  219. package/dist/images.d.ts.map +0 -1
  220. package/dist/images.js +0 -14
  221. package/dist/images.js.map +0 -1
  222. package/dist/index.d.ts +0 -32
  223. package/dist/index.d.ts.map +0 -1
  224. package/dist/index.js +0 -20
  225. package/dist/index.js.map +0 -1
  226. package/dist/models.d.ts +0 -18
  227. package/dist/models.d.ts.map +0 -1
  228. package/dist/models.generated.d.ts +0 -17707
  229. package/dist/models.generated.d.ts.map +0 -1
  230. package/dist/models.generated.js +0 -16561
  231. package/dist/models.generated.js.map +0 -1
  232. package/dist/models.js +0 -71
  233. package/dist/models.js.map +0 -1
  234. package/dist/oauth.d.ts +0 -2
  235. package/dist/oauth.d.ts.map +0 -1
  236. package/dist/oauth.js +0 -2
  237. package/dist/oauth.js.map +0 -1
  238. package/dist/providers/aery-error-formatting.d.ts +0 -13
  239. package/dist/providers/aery-error-formatting.d.ts.map +0 -1
  240. package/dist/providers/aery-error-formatting.js +0 -112
  241. package/dist/providers/aery-error-formatting.js.map +0 -1
  242. package/dist/providers/amazon-bedrock.d.ts +0 -38
  243. package/dist/providers/amazon-bedrock.d.ts.map +0 -1
  244. package/dist/providers/amazon-bedrock.js +0 -763
  245. package/dist/providers/amazon-bedrock.js.map +0 -1
  246. package/dist/providers/anthropic.d.ts +0 -71
  247. package/dist/providers/anthropic.d.ts.map +0 -1
  248. package/dist/providers/anthropic.js +0 -949
  249. package/dist/providers/anthropic.js.map +0 -1
  250. package/dist/providers/azure-openai-responses.d.ts +0 -15
  251. package/dist/providers/azure-openai-responses.d.ts.map +0 -1
  252. package/dist/providers/azure-openai-responses.js +0 -225
  253. package/dist/providers/azure-openai-responses.js.map +0 -1
  254. package/dist/providers/cloudflare.d.ts +0 -13
  255. package/dist/providers/cloudflare.d.ts.map +0 -1
  256. package/dist/providers/cloudflare.js +0 -26
  257. package/dist/providers/cloudflare.js.map +0 -1
  258. package/dist/providers/faux.d.ts +0 -56
  259. package/dist/providers/faux.d.ts.map +0 -1
  260. package/dist/providers/faux.js +0 -368
  261. package/dist/providers/faux.js.map +0 -1
  262. package/dist/providers/github-copilot-headers.d.ts +0 -8
  263. package/dist/providers/github-copilot-headers.d.ts.map +0 -1
  264. package/dist/providers/github-copilot-headers.js +0 -29
  265. package/dist/providers/github-copilot-headers.js.map +0 -1
  266. package/dist/providers/google-gemini-cli.d.ts +0 -74
  267. package/dist/providers/google-gemini-cli.d.ts.map +0 -1
  268. package/dist/providers/google-gemini-cli.js +0 -779
  269. package/dist/providers/google-gemini-cli.js.map +0 -1
  270. package/dist/providers/google-shared.d.ts +0 -70
  271. package/dist/providers/google-shared.d.ts.map +0 -1
  272. package/dist/providers/google-shared.js +0 -329
  273. package/dist/providers/google-shared.js.map +0 -1
  274. package/dist/providers/google-vertex.d.ts +0 -15
  275. package/dist/providers/google-vertex.d.ts.map +0 -1
  276. package/dist/providers/google-vertex.js +0 -442
  277. package/dist/providers/google-vertex.js.map +0 -1
  278. package/dist/providers/google.d.ts +0 -13
  279. package/dist/providers/google.d.ts.map +0 -1
  280. package/dist/providers/google.js +0 -400
  281. package/dist/providers/google.js.map +0 -1
  282. package/dist/providers/images/openrouter.d.ts +0 -3
  283. package/dist/providers/images/openrouter.d.ts.map +0 -1
  284. package/dist/providers/images/openrouter.js +0 -129
  285. package/dist/providers/images/openrouter.js.map +0 -1
  286. package/dist/providers/images/register-builtins.d.ts +0 -4
  287. package/dist/providers/images/register-builtins.d.ts.map +0 -1
  288. package/dist/providers/images/register-builtins.js +0 -34
  289. package/dist/providers/images/register-builtins.js.map +0 -1
  290. package/dist/providers/mistral.d.ts +0 -25
  291. package/dist/providers/mistral.d.ts.map +0 -1
  292. package/dist/providers/mistral.js +0 -535
  293. package/dist/providers/mistral.js.map +0 -1
  294. package/dist/providers/openai-codex-responses.d.ts +0 -30
  295. package/dist/providers/openai-codex-responses.d.ts.map +0 -1
  296. package/dist/providers/openai-codex-responses.js +0 -1090
  297. package/dist/providers/openai-codex-responses.js.map +0 -1
  298. package/dist/providers/openai-completions.d.ts +0 -19
  299. package/dist/providers/openai-completions.d.ts.map +0 -1
  300. package/dist/providers/openai-completions.js +0 -950
  301. package/dist/providers/openai-completions.js.map +0 -1
  302. package/dist/providers/openai-prompt-cache.d.ts +0 -3
  303. package/dist/providers/openai-prompt-cache.d.ts.map +0 -1
  304. package/dist/providers/openai-prompt-cache.js +0 -10
  305. package/dist/providers/openai-prompt-cache.js.map +0 -1
  306. package/dist/providers/openai-responses-shared.d.ts +0 -18
  307. package/dist/providers/openai-responses-shared.d.ts.map +0 -1
  308. package/dist/providers/openai-responses-shared.js +0 -492
  309. package/dist/providers/openai-responses-shared.js.map +0 -1
  310. package/dist/providers/openai-responses.d.ts +0 -13
  311. package/dist/providers/openai-responses.d.ts.map +0 -1
  312. package/dist/providers/openai-responses.js +0 -237
  313. package/dist/providers/openai-responses.js.map +0 -1
  314. package/dist/providers/register-builtins.d.ts +0 -38
  315. package/dist/providers/register-builtins.d.ts.map +0 -1
  316. package/dist/providers/register-builtins.js +0 -278
  317. package/dist/providers/register-builtins.js.map +0 -1
  318. package/dist/providers/simple-options.d.ts +0 -8
  319. package/dist/providers/simple-options.d.ts.map +0 -1
  320. package/dist/providers/simple-options.js +0 -41
  321. package/dist/providers/simple-options.js.map +0 -1
  322. package/dist/providers/transform-messages.d.ts +0 -8
  323. package/dist/providers/transform-messages.d.ts.map +0 -1
  324. package/dist/providers/transform-messages.js +0 -184
  325. package/dist/providers/transform-messages.js.map +0 -1
  326. package/dist/session-resources.d.ts +0 -4
  327. package/dist/session-resources.d.ts.map +0 -1
  328. package/dist/session-resources.js +0 -22
  329. package/dist/session-resources.js.map +0 -1
  330. package/dist/stream.d.ts +0 -8
  331. package/dist/stream.d.ts.map +0 -1
  332. package/dist/stream.js +0 -27
  333. package/dist/stream.js.map +0 -1
  334. package/dist/types.d.ts +0 -498
  335. package/dist/types.d.ts.map +0 -1
  336. package/dist/types.js +0 -2
  337. package/dist/types.js.map +0 -1
  338. package/dist/utils/diagnostics.d.ts +0 -19
  339. package/dist/utils/diagnostics.d.ts.map +0 -1
  340. package/dist/utils/diagnostics.js +0 -25
  341. package/dist/utils/diagnostics.js.map +0 -1
  342. package/dist/utils/event-stream.d.ts +0 -21
  343. package/dist/utils/event-stream.d.ts.map +0 -1
  344. package/dist/utils/event-stream.js +0 -81
  345. package/dist/utils/event-stream.js.map +0 -1
  346. package/dist/utils/hash.d.ts +0 -3
  347. package/dist/utils/hash.d.ts.map +0 -1
  348. package/dist/utils/hash.js +0 -14
  349. package/dist/utils/hash.js.map +0 -1
  350. package/dist/utils/headers.d.ts +0 -2
  351. package/dist/utils/headers.d.ts.map +0 -1
  352. package/dist/utils/headers.js +0 -8
  353. package/dist/utils/headers.js.map +0 -1
  354. package/dist/utils/json-parse.d.ts +0 -16
  355. package/dist/utils/json-parse.d.ts.map +0 -1
  356. package/dist/utils/json-parse.js +0 -113
  357. package/dist/utils/json-parse.js.map +0 -1
  358. package/dist/utils/node-http-proxy.d.ts +0 -10
  359. package/dist/utils/node-http-proxy.d.ts.map +0 -1
  360. package/dist/utils/node-http-proxy.js +0 -97
  361. package/dist/utils/node-http-proxy.js.map +0 -1
  362. package/dist/utils/oauth/anthropic.d.ts +0 -25
  363. package/dist/utils/oauth/anthropic.d.ts.map +0 -1
  364. package/dist/utils/oauth/anthropic.js +0 -335
  365. package/dist/utils/oauth/anthropic.js.map +0 -1
  366. package/dist/utils/oauth/device-code.d.ts +0 -19
  367. package/dist/utils/oauth/device-code.d.ts.map +0 -1
  368. package/dist/utils/oauth/device-code.js +0 -55
  369. package/dist/utils/oauth/device-code.js.map +0 -1
  370. package/dist/utils/oauth/github-copilot.d.ts +0 -30
  371. package/dist/utils/oauth/github-copilot.d.ts.map +0 -1
  372. package/dist/utils/oauth/github-copilot.js +0 -268
  373. package/dist/utils/oauth/github-copilot.js.map +0 -1
  374. package/dist/utils/oauth/google-antigravity.d.ts +0 -26
  375. package/dist/utils/oauth/google-antigravity.d.ts.map +0 -1
  376. package/dist/utils/oauth/google-antigravity.js +0 -377
  377. package/dist/utils/oauth/google-antigravity.js.map +0 -1
  378. package/dist/utils/oauth/google-gemini-cli.d.ts +0 -26
  379. package/dist/utils/oauth/google-gemini-cli.d.ts.map +0 -1
  380. package/dist/utils/oauth/google-gemini-cli.js +0 -482
  381. package/dist/utils/oauth/google-gemini-cli.js.map +0 -1
  382. package/dist/utils/oauth/index.d.ts +0 -63
  383. package/dist/utils/oauth/index.d.ts.map +0 -1
  384. package/dist/utils/oauth/index.js +0 -131
  385. package/dist/utils/oauth/index.js.map +0 -1
  386. package/dist/utils/oauth/oauth-page.d.ts +0 -3
  387. package/dist/utils/oauth/oauth-page.d.ts.map +0 -1
  388. package/dist/utils/oauth/oauth-page.js +0 -105
  389. package/dist/utils/oauth/oauth-page.js.map +0 -1
  390. package/dist/utils/oauth/openai-codex.d.ts +0 -34
  391. package/dist/utils/oauth/openai-codex.d.ts.map +0 -1
  392. package/dist/utils/oauth/openai-codex.js +0 -385
  393. package/dist/utils/oauth/openai-codex.js.map +0 -1
  394. package/dist/utils/oauth/pkce.d.ts +0 -13
  395. package/dist/utils/oauth/pkce.d.ts.map +0 -1
  396. package/dist/utils/oauth/pkce.js +0 -31
  397. package/dist/utils/oauth/pkce.js.map +0 -1
  398. package/dist/utils/oauth/types.d.ts +0 -64
  399. package/dist/utils/oauth/types.d.ts.map +0 -1
  400. package/dist/utils/oauth/types.js +0 -2
  401. package/dist/utils/oauth/types.js.map +0 -1
  402. package/dist/utils/overflow.d.ts +0 -56
  403. package/dist/utils/overflow.d.ts.map +0 -1
  404. package/dist/utils/overflow.js +0 -151
  405. package/dist/utils/overflow.js.map +0 -1
  406. package/dist/utils/sanitize-unicode.d.ts +0 -22
  407. package/dist/utils/sanitize-unicode.d.ts.map +0 -1
  408. package/dist/utils/sanitize-unicode.js +0 -26
  409. package/dist/utils/sanitize-unicode.js.map +0 -1
  410. package/dist/utils/typebox-helpers.d.ts +0 -17
  411. package/dist/utils/typebox-helpers.d.ts.map +0 -1
  412. package/dist/utils/typebox-helpers.js +0 -21
  413. package/dist/utils/typebox-helpers.js.map +0 -1
  414. package/dist/utils/validation.d.ts +0 -18
  415. package/dist/utils/validation.d.ts.map +0 -1
  416. package/dist/utils/validation.js +0 -281
  417. package/dist/utils/validation.js.map +0 -1
@@ -0,0 +1,4422 @@
1
+ /**
2
+ * Credential storage for API keys and OAuth tokens.
3
+ * Handles loading, saving, refreshing credentials, and usage tracking.
4
+ *
5
+ * This module defines:
6
+ * - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …)
7
+ * - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh
8
+ * - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
9
+ */
10
+ import { Database, type Statement } from "bun:sqlite";
11
+ import * as fs from "node:fs/promises";
12
+ import * as path from "node:path";
13
+ import { getAgentDbPath, logger } from "@aryee337/aery-utils";
14
+ import { getEnvApiKey } from "./stream";
15
+ import type { Provider } from "./types";
16
+ import type {
17
+ CredentialRankingStrategy,
18
+ UsageCredential,
19
+ UsageFetchContext,
20
+ UsageFetchParams,
21
+ UsageLimit,
22
+ UsageLogger,
23
+ UsageProvider,
24
+ UsageReport,
25
+ } from "./usage";
26
+ import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
27
+ import { googleGeminiCliUsageProvider } from "./usage/gemini";
28
+ import { githubCopilotUsageProvider } from "./usage/github-copilot";
29
+ import { antigravityUsageProvider } from "./usage/google-antigravity";
30
+ import { kimiUsageProvider } from "./usage/kimi";
31
+ import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
32
+ import { zaiUsageProvider } from "./usage/zai";
33
+ import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./utils/oauth";
34
+ import { loginDeepSeek } from "./utils/oauth/deepseek";
35
+ import { loginOpenAICodexDevice } from "./utils/oauth/openai-codex";
36
+ import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./utils/oauth/types";
37
+
38
+ // ─────────────────────────────────────────────────────────────────────────────
39
+ // Credential Types
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+
42
+ export type ApiKeyCredential = {
43
+ type: "api_key";
44
+ key: string;
45
+ };
46
+
47
+ export type OAuthCredential = {
48
+ type: "oauth";
49
+ } & OAuthCredentials;
50
+
51
+ export type AuthCredential = ApiKeyCredential | OAuthCredential;
52
+
53
+ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
54
+
55
+ export type AuthStorageData = Record<string, AuthCredentialEntry>;
56
+
57
+ /**
58
+ * Serialized representation of AuthStorage for passing to subagent workers.
59
+ * Contains only the essential credential data, not runtime state.
60
+ */
61
+ export interface SerializedAuthStorage {
62
+ credentials: Record<
63
+ string,
64
+ Array<{
65
+ id: number;
66
+ type: "api_key" | "oauth";
67
+ data: Record<string, unknown>;
68
+ }>
69
+ >;
70
+ runtimeOverrides?: Record<string, string>;
71
+ dbPath?: string;
72
+ }
73
+
74
+ /**
75
+ * Auth credential with database row ID for updates/deletes.
76
+ * Wraps AuthCredential with storage metadata.
77
+ */
78
+ export interface StoredAuthCredential {
79
+ id: number;
80
+ provider: string;
81
+ credential: AuthCredential;
82
+ disabledCause: string | null;
83
+ }
84
+
85
+ /**
86
+ * Per-credential health record returned by {@link AuthStorage.checkCredentials}.
87
+ *
88
+ * Use this to identify which credential in a multi-account pool is causing
89
+ * auth errors. `ok` is tri-state:
90
+ *
91
+ * - `true` — credential authenticated against the provider's auth-verifying
92
+ * probe (today: the usage endpoint). For OAuth this also exercises refresh
93
+ * when the access token was expired.
94
+ * - `false` — the probe rejected the credential (401/403/refresh failure/etc).
95
+ * `reason` carries the upstream error string.
96
+ * - `null` — no probe is configured for this provider (or the configured
97
+ * probe doesn't support this credential type). The credential's auth
98
+ * status is unverifiable from here.
99
+ */
100
+ export interface CredentialHealthResult {
101
+ /** Database row id (matches {@link StoredAuthCredential.id}). */
102
+ id: number;
103
+ provider: string;
104
+ type: AuthCredential["type"];
105
+ /** OAuth email if known on the stored credential or surfaced by the probe. */
106
+ email?: string;
107
+ /** OAuth account id / org id if known. */
108
+ accountId?: string;
109
+ /** `true` when the refresh token lives on a remote broker (sentinel was present). */
110
+ remoteRefresh?: true;
111
+ ok: boolean | null;
112
+ /** Failure / unverifiable reason; absent when `ok === true`. */
113
+ reason?: string;
114
+ /** Probe usage report (raw payload stripped) when `ok === true`. */
115
+ report?: Omit<UsageReport, "raw">;
116
+ /**
117
+ * Result of the optional end-to-end completion probe (see
118
+ * {@link CheckCredentialsOptions.completionProbe}). Absent when no probe was
119
+ * supplied. The completion probe exercises the provider's chat-completion
120
+ * endpoint with the credential's bearer bytes, which is a stricter signal
121
+ * than the usage endpoint (some providers happily 200 a `/usage` call while
122
+ * the chat endpoint 401s the same bearer).
123
+ */
124
+ completion?: CredentialCompletionResult;
125
+ }
126
+
127
+ /**
128
+ * Outcome of the end-to-end completion probe. `null` means the probe was
129
+ * skipped (no bearer bytes were available — e.g. OAuth refresh failed
130
+ * upstream of the probe).
131
+ */
132
+ export interface CredentialCompletionResult {
133
+ ok: boolean | null;
134
+ /** Failure / unverifiable reason; absent when `ok === true`. */
135
+ reason?: string;
136
+ /** Probe model id used (carried back from the caller for display). */
137
+ modelId?: string;
138
+ /** Round-trip latency in milliseconds. */
139
+ latencyMs?: number;
140
+ }
141
+
142
+ /**
143
+ * Credential payload handed to {@link CompletionProbe}. For API-key
144
+ * credentials only the bytes are exposed; for OAuth, every identity field
145
+ * carried by the refreshed credential is included so the probe can compose
146
+ * provider-specific apiKey shapes (e.g. GitHub Copilot / Google Gemini CLI
147
+ * expect a JSON blob with `token` + `projectId`, not the raw access token).
148
+ *
149
+ * `refreshToken` may be {@link REMOTE_REFRESH_SENTINEL} when the credential
150
+ * lives behind a broker; the chat endpoint never reads it, so the probe can
151
+ * forward it verbatim into the structured shape without harm.
152
+ */
153
+ export type CompletionProbeCredential =
154
+ | { type: "api_key"; apiKey: string }
155
+ | {
156
+ type: "oauth";
157
+ accessToken: string;
158
+ refreshToken?: string;
159
+ expiresAt?: number;
160
+ accountId?: string;
161
+ projectId?: string;
162
+ email?: string;
163
+ enterpriseUrl?: string;
164
+ };
165
+
166
+ /**
167
+ * Caller-supplied bearer probe. Receives the post-refresh credential for a
168
+ * single row and reports whether a real chat-completion round-trip succeeds.
169
+ * The check-credentials pipeline calls this AFTER any OAuth refresh so the
170
+ * bytes match what a live request would send.
171
+ */
172
+ export interface CompletionProbeInput {
173
+ provider: Provider;
174
+ credentialId: number;
175
+ credential: CompletionProbeCredential;
176
+ signal: AbortSignal;
177
+ }
178
+
179
+ export type CompletionProbe = (input: CompletionProbeInput) => Promise<CredentialCompletionResult>;
180
+
181
+ export interface CheckCredentialsOptions {
182
+ signal?: AbortSignal;
183
+ /** Per-credential probe timeout (ms). Defaults to the configured usage request timeout. */
184
+ timeoutMs?: number;
185
+ /** Provider → base URL override, same shape as {@link AuthStorage.fetchUsageReports}. */
186
+ baseUrlResolver?: (provider: Provider) => string | undefined;
187
+ /**
188
+ * Optional end-to-end probe. When provided, `checkCredentials` invokes it
189
+ * for every credential where a usable bearer is available (API key, or
190
+ * OAuth access token after refresh-on-expiry succeeded). The result lands
191
+ * on {@link CredentialHealthResult.completion}.
192
+ *
193
+ * The probe runs INDEPENDENTLY of whether a {@link UsageProvider} is
194
+ * configured: providers without a usage endpoint still benefit from the
195
+ * extra signal. The probe is NOT invoked when OAuth refresh fails — the
196
+ * bytes would be stale anyway and the upstream failure is already captured
197
+ * on `reason`.
198
+ */
199
+ completionProbe?: CompletionProbe;
200
+ /** Per-credential completion probe timeout (ms). Defaults to `timeoutMs`. */
201
+ completionTimeoutMs?: number;
202
+ }
203
+
204
+ // ─────────────────────────────────────────────────────────────────────────────
205
+ // Auth Broker Snapshot Types
206
+ // ─────────────────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Sentinel value placed in OAuth `refresh` fields when a credential is shared
210
+ * via {@link AuthStorage.exportSnapshot}. Refresh tokens never leave the broker;
211
+ * clients must call back to refresh.
212
+ */
213
+ export const REMOTE_REFRESH_SENTINEL = "__remote__" as const;
214
+ export type RemoteRefreshSentinel = typeof REMOTE_REFRESH_SENTINEL;
215
+
216
+ /** OAuth credential with refresh token replaced by the broker sentinel. */
217
+ export type RemoteOAuthCredential = Omit<OAuthCredential, "refresh"> & {
218
+ refresh: RemoteRefreshSentinel;
219
+ };
220
+
221
+ /** Discriminated credential payload as published by the broker. */
222
+ export type SnapshotCredential = ApiKeyCredential | RemoteOAuthCredential;
223
+
224
+ export interface AuthCredentialSnapshotEntry {
225
+ id: number;
226
+ provider: string;
227
+ credential: SnapshotCredential;
228
+ identityKey: string | null;
229
+ }
230
+
231
+ /**
232
+ * Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
233
+ * served by the auth-broker server on `GET /v1/snapshot`.
234
+ */
235
+ export interface AuthCredentialSnapshot {
236
+ generation: number;
237
+ generatedAt: number;
238
+ credentials: AuthCredentialSnapshotEntry[];
239
+ }
240
+
241
+ // ─────────────────────────────────────────────────────────────────────────────
242
+ // AuthCredentialStore interface
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Persistence abstraction consumed by {@link AuthStorage}.
247
+ *
248
+ * Concrete implementations:
249
+ * - {@link SqliteAuthCredentialStore} — local SQLite-backed store (default).
250
+ * - `RemoteAuthCredentialStore` from `./auth-broker` — client-side snapshot of
251
+ * a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
252
+ * throw because login flows route through the broker, not the client.
253
+ */
254
+ export interface AuthCredentialStore {
255
+ close(): void;
256
+ listAuthCredentials(provider?: string): StoredAuthCredential[];
257
+ updateAuthCredential(id: number, credential: AuthCredential): void;
258
+ deleteAuthCredential(id: number, disabledCause: string): void;
259
+ tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
260
+ replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
261
+ upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
262
+ deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
263
+ getCache(key: string, options?: { includeExpired?: boolean }): string | null;
264
+ setCache(key: string, value: string, expiresAtSec: number): void;
265
+ cleanExpiredCache(): void;
266
+ /**
267
+ * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
268
+ * it before the per-provider local refresh path. `RemoteAuthCredentialStore`
269
+ * implements this against the broker; SQLite stores leave it undefined.
270
+ *
271
+ * Precedence: `AuthStorageOptions.refreshOAuthCredential` > this hook > local.
272
+ *
273
+ * `signal` propagates the agent's cancel (ESC, request abort, …) all the
274
+ * way to the broker fetch so a hung connection can't strand the caller
275
+ * for `timeoutMs * (maxRetries + 1)`.
276
+ */
277
+ refreshOAuthCredential?(
278
+ provider: Provider,
279
+ credentialId: number,
280
+ credential: OAuthCredential,
281
+ signal?: AbortSignal,
282
+ ): Promise<OAuthCredentials>;
283
+ /**
284
+ * Optional async pre-read hook invoked after AuthStorage selects a stored
285
+ * credential but before it returns that credential for an outbound request.
286
+ * Remote broker stores use this to wait out imminent rotations and refresh
287
+ * their local snapshot before the caller sees a stale access token.
288
+ */
289
+ prepareForRequest?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<boolean | undefined>;
290
+ /**
291
+ * Optional store-supplied aggregate usage fetch. When present, `AuthStorage`
292
+ * routes `fetchUsageReports()` here instead of fanning out per-credential.
293
+ * `RemoteAuthCredentialStore` proxies to the broker (whose datacenter IP
294
+ * isn't rate-limited like a heavy residential client).
295
+ *
296
+ * Precedence: `AuthStorageOptions.fetchUsageReports` > this hook > local fan-out.
297
+ *
298
+ * `signal` propagates the agent's cancel down to the broker fetch.
299
+ */
300
+ fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
301
+ /**
302
+ * Optional store-supplied per-credential usage report lookup. When present,
303
+ * `AuthStorage` consults this before its own per-credential upstream fetch
304
+ * (`#getUsageReport`). `RemoteAuthCredentialStore` implements this against
305
+ * the broker's aggregate `/v1/usage` (one coalesced round-trip shared across
306
+ * all callers) so multi-credential ranking on the client never hits the
307
+ * upstream provider's rate-limited usage endpoint from the laptop IP.
308
+ *
309
+ * Returning `null` is authoritative — `AuthStorage` does NOT fall back to
310
+ * the local fetch path. The store hook owns the decision, since falling
311
+ * back would re-introduce the per-IP rate-limit problem the broker exists
312
+ * to avoid.
313
+ *
314
+ * `signal` propagates the agent's cancel down to the broker fetch.
315
+ */
316
+ getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
317
+ /**
318
+ * Optional store hook to invalidate a specific credential after the upstream
319
+ * provider returned 401 on a supposedly-fresh key. Remote stores force the
320
+ * broker to re-issue the row; local stores can leave it undefined and let
321
+ * {@link AuthStorage.invalidateCredentialMatching} fall back to `reload()`.
322
+ */
323
+ markCredentialSuspect?(credentialId: number, opts?: { signal?: AbortSignal }): Promise<void>;
324
+ /**
325
+ * Optional async write hook for upserting a single credential. When present,
326
+ * `AuthStorage.#upsertOAuthCredential` routes through this instead of the
327
+ * sync `upsertAuthCredentialForProvider`. `RemoteAuthCredentialStore` uses
328
+ * it to send the upsert to the broker via `POST /v1/credential`.
329
+ *
330
+ * Implementations MUST update the in-memory snapshot before returning so the
331
+ * post-write read path is consistent.
332
+ */
333
+ upsertAuthCredentialRemote?(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]>;
334
+ /**
335
+ * Optional async write hook for replace-all semantics (e.g. API-key login
336
+ * overwriting any previous keys for the same provider). When present,
337
+ * `AuthStorage.set` routes through this instead of the sync
338
+ * `replaceAuthCredentialsForProvider`.
339
+ */
340
+ replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
341
+ /**
342
+ * Optional async write hook for clearing every credential for a provider
343
+ * (logout). When present, `AuthStorage.remove` routes through this instead
344
+ * of the sync `deleteAuthCredentialsForProvider`.
345
+ */
346
+ deleteAuthCredentialsRemote?(provider: string, disabledCause: string): Promise<void>;
347
+ }
348
+
349
+ // ─────────────────────────────────────────────────────────────────────────────
350
+ // AuthStorage Options
351
+ // ─────────────────────────────────────────────────────────────────────────────
352
+
353
+ /**
354
+ * Event payload describing a credential that was just soft-disabled.
355
+ *
356
+ * Today the only call site is OAuth refresh failures with a definitive cause
357
+ * (`invalid_grant`, `401/403` not from a network blip, etc.) — the
358
+ * disabled_cause string is the verbatim error captured for forensics.
359
+ *
360
+ * Subscribers can use this to surface a notification, banner, or auto-launch
361
+ * a re-login flow instead of letting the credential silently disappear.
362
+ */
363
+ export interface CredentialDisabledEvent {
364
+ provider: string;
365
+ disabledCause: string;
366
+ }
367
+
368
+ export type AuthStorageOptions = {
369
+ usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
370
+ rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
371
+ usageFetch?: typeof fetch;
372
+ usageRequestTimeoutMs?: number;
373
+ usageLogger?: UsageLogger;
374
+ /**
375
+ * Resolve a config value (API key, header value, etc.) to an actual value.
376
+ * - coding-agent injects its resolveConfigValue (supports "!command" syntax via aery-engine)
377
+ * - Default: checks environment variable first, then treats as literal
378
+ */
379
+ configValueResolver?: (config: string) => Promise<string | undefined>;
380
+ /**
381
+ * Optional callback fired when AuthStorage automatically disables a
382
+ * credential because something detected it as no longer usable — today
383
+ * that's the OAuth refresh-failure path in `getApiKey`. NOT fired for
384
+ * user-initiated `remove()` (the user already knows) or dedup of
385
+ * duplicate credentials (uninteresting hygiene).
386
+ */
387
+ onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
388
+ /**
389
+ * Override OAuth refresh. When set, `AuthStorage` calls this instead of the
390
+ * per-provider local refresh function. Receives the credential id so the
391
+ * implementation can address remote credentials.
392
+ *
393
+ * Must return updated {@link OAuthCredentials} with at least `access` and
394
+ * `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when
395
+ * the actual refresh token never leaves the broker.
396
+ */
397
+ refreshOAuthCredential?: (
398
+ provider: Provider,
399
+ credentialId: number,
400
+ credential: OAuthCredential,
401
+ signal?: AbortSignal,
402
+ ) => Promise<OAuthCredentials>;
403
+ /**
404
+ * Human-readable description of the credential store backing this
405
+ * AuthStorage instance. Surfaced through {@link AuthStorage.describeCredentialSource}
406
+ * so the TUI can show where a token came from (broker URL or local SQLite path).
407
+ *
408
+ * Examples:
409
+ * - `"local ~/.aery/agent/agent.db"`
410
+ * - `"broker http://can.internal:8765"`
411
+ */
412
+ sourceLabel?: string;
413
+ /**
414
+ * Override `fetchUsageReports`. When set, `AuthStorage.fetchUsageReports`
415
+ * calls this instead of fanning out per-credential. The primary use case is
416
+ * routing through a broker that egresses from a less-throttled IP — e.g. a
417
+ * residential laptop trips Anthropic's per-IP rate limit on the usage
418
+ * endpoint and drops 2-of-5 credentials, while the VPS broker gets all 5.
419
+ *
420
+ * Implementations may return null when no usage data is available; the
421
+ * AuthStorage caller surfaces that to its own consumer unchanged.
422
+ */
423
+ fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
424
+ };
425
+
426
+ // ─────────────────────────────────────────────────────────────────────────────
427
+ // Default Config Value Resolver
428
+ // ─────────────────────────────────────────────────────────────────────────────
429
+
430
+ /**
431
+ * Default config value resolver that checks env vars and treats as literal.
432
+ * Does NOT support "!command" syntax (that requires aery-engine).
433
+ */
434
+ async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
435
+ const envValue = process.env[config];
436
+ return envValue || config;
437
+ }
438
+
439
+ // ─────────────────────────────────────────────────────────────────────────────
440
+ // Usage Providers (defaults)
441
+ // ─────────────────────────────────────────────────────────────────────────────
442
+
443
+ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
444
+ openaiCodexUsageProvider,
445
+ kimiUsageProvider,
446
+ antigravityUsageProvider,
447
+ googleGeminiCliUsageProvider,
448
+ claudeUsageProvider,
449
+ zaiUsageProvider,
450
+ githubCopilotUsageProvider,
451
+ ];
452
+
453
+ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
454
+ DEFAULT_USAGE_PROVIDERS.map(provider => [provider.id, provider]),
455
+ );
456
+
457
+ const USAGE_CACHE_PREFIX = "usage_cache:";
458
+ // 5 min stale tolerance. Anthropic / OpenAI rate-limit /usage hard at the IP
459
+ // level so we can't fetch all N credentials every cycle; with a long cache
460
+ // each credential's last-known value sticks visible while peers retry. UI
461
+ // data (5h / 7d / monthly limits) is fine being a few minutes stale.
462
+ const USAGE_REPORT_TTL_MS = 5 * 60_000;
463
+ const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
464
+ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
465
+ /**
466
+ * Per-credential cool-down after a usage fetch fails. While this window is
467
+ * active we serve the last successful value to avoid dropping the credential
468
+ * from the report; without a previous value we just return null and retry
469
+ * on the next poll.
470
+ */
471
+ const USAGE_FAILURE_BACKOFF_MS = 10_000;
472
+ // Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
473
+ // (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
474
+ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
475
+ const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
476
+ /**
477
+ * Refresh OAuth access tokens this many ms before their stated expiry. The
478
+ * skew exists so callers downstream of {@link AuthStorage} (stream providers,
479
+ * usage probes, web_search) never observe a credential that is expired or
480
+ * about to expire mid-request — there's a single rotation point and everyone
481
+ * downstream trusts the token they receive.
482
+ *
483
+ * Set to 60s: comfortably absorbs request RTT + a clock-skew window without
484
+ * triggering a refresh on every request. Provider token endpoints typically
485
+ * mint access tokens with 30-60min lifetimes, so refreshing 60s early changes
486
+ * the rotation cadence by <4%.
487
+ */
488
+ const OAUTH_REFRESH_SKEW_MS = 60_000;
489
+ /**
490
+ * Cap on the buffered credential_disabled backlog held while no handler is attached.
491
+ * In practice the backlog is 0–N where N ≈ active providers (≤ ~20). The cap exists so
492
+ * pathological detach-without-reattach loops can't grow memory unboundedly.
493
+ */
494
+ const MAX_PENDING_DISABLED_EVENTS = 32;
495
+
496
+ /**
497
+ * Classify an OAuth refresh error as a definitive credential failure (the
498
+ * refresh token is dead — re-login required) versus a transient blip
499
+ * (network/5xx — retry next sweep).
500
+ *
501
+ * Anchored at module scope so all three refresh sites — in-stream
502
+ * {@link AuthStorage.getApiKey}, the usage probe in
503
+ * {@link AuthStorage.fetchUsageReports}, and the auth-broker background
504
+ * refresher — disable rows on the same criteria. A drifting classifier
505
+ * between sites would let stale last-good usage reports surface indefinitely
506
+ * while streaming requests correctly tear the row down.
507
+ */
508
+ const OAUTH_DEFINITIVE_FAILURE_REGEX =
509
+ /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
510
+ const OAUTH_TRANSIENT_FAILURE_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
511
+ const OAUTH_HTTP_AUTH_REGEX = /\b(401|403)\b/;
512
+
513
+ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
514
+ if (OAUTH_DEFINITIVE_FAILURE_REGEX.test(errorMsg)) return true;
515
+ if (OAUTH_HTTP_AUTH_REGEX.test(errorMsg) && !OAUTH_TRANSIENT_FAILURE_REGEX.test(errorMsg)) return true;
516
+ return false;
517
+ }
518
+
519
+ type UsageCacheEntry<T> = {
520
+ value: T;
521
+ expiresAt: number;
522
+ };
523
+
524
+ interface UsageCache {
525
+ get<T>(key: string): UsageCacheEntry<T> | undefined;
526
+ getStale<T>(key: string): UsageCacheEntry<T> | undefined;
527
+ set<T>(key: string, entry: UsageCacheEntry<T>): void;
528
+ cleanup?(): void;
529
+ }
530
+
531
+ type UsageRequestDescriptor = {
532
+ provider: Provider;
533
+ credential: UsageCredential;
534
+ baseUrl?: string;
535
+ };
536
+
537
+ type AuthApiKeyOptions = {
538
+ baseUrl?: string;
539
+ modelId?: string;
540
+ /**
541
+ * Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
542
+ * `ESC` / request abort actually kills a hung broker fetch instead of
543
+ * stranding the caller for `timeoutMs * (maxRetries + 1)`.
544
+ */
545
+ signal?: AbortSignal;
546
+ };
547
+ type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
548
+
549
+ /**
550
+ * Refreshed OAuth access plus identity metadata returned by
551
+ * {@link AuthStorage.getOAuthAccess}. Callers that authenticate via a bearer
552
+ * AND need the credential's identity (Codex `chatgpt-account-id`, Google
553
+ * `projectId`, GitHub `enterpriseUrl`) consume this shape directly; the
554
+ * refresh slot is deliberately omitted because rotating refresh tokens never
555
+ * leave {@link AuthStorage}.
556
+ */
557
+ export interface OAuthAccess {
558
+ accessToken: string;
559
+ accountId?: string;
560
+ email?: string;
561
+ projectId?: string;
562
+ enterpriseUrl?: string;
563
+ }
564
+ export interface InvalidateCredentialMatchingOptions {
565
+ signal?: AbortSignal;
566
+ sessionId?: string;
567
+ }
568
+
569
+ function isAbortSignalOption(
570
+ value: InvalidateCredentialMatchingOptions | AbortSignal | undefined,
571
+ ): value is AbortSignal {
572
+ return typeof value === "object" && value !== null && "aborted" in value && "addEventListener" in value;
573
+ }
574
+
575
+ function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
576
+ return provider === "openai-codex" && typeof modelId === "string" && modelId.includes("-spark");
577
+ }
578
+
579
+ function getUsagePlanType(report: UsageReport | null): string | undefined {
580
+ const metadata = report?.metadata;
581
+ if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
582
+ const planType = (metadata as { planType?: unknown }).planType;
583
+ return typeof planType === "string" ? planType.toLowerCase() : undefined;
584
+ }
585
+
586
+ function getOpenAICodexPlanPriority(report: UsageReport | null): number {
587
+ const planType = getUsagePlanType(report);
588
+ if (!planType) return 1;
589
+ return planType.includes("pro") ? 0 : 2;
590
+ }
591
+
592
+ function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
593
+ return getUsagePlanType(report)?.includes("pro") === true;
594
+ }
595
+
596
+ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
597
+ return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
598
+ }
599
+
600
+ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
601
+ ["openai-codex", codexRankingStrategy],
602
+ ["anthropic", claudeRankingStrategy],
603
+ ]);
604
+
605
+ function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
606
+ return DEFAULT_RANKING_STRATEGIES.get(provider);
607
+ }
608
+
609
+ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
610
+ try {
611
+ const parsed = JSON.parse(raw) as { value?: T; expiresAt?: unknown };
612
+ const expiresAt = typeof parsed.expiresAt === "number" ? parsed.expiresAt : undefined;
613
+ if (!expiresAt || !Number.isFinite(expiresAt)) return undefined;
614
+ return { value: parsed.value as T, expiresAt };
615
+ } catch {
616
+ return undefined;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Race `promise` against `signal`, rejecting only this caller when the signal
622
+ * fires. The underlying promise keeps running so other awaiters on the same
623
+ * single-flight fetch aren't punished by a peer's cancel.
624
+ */
625
+ function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
626
+ if (!signal) return promise;
627
+ if (signal.aborted) return Promise.reject(new Error("usage fetch aborted"));
628
+ return new Promise<T>((resolve, reject) => {
629
+ const onAbort = (): void => {
630
+ signal.removeEventListener("abort", onAbort);
631
+ reject(new Error("usage fetch aborted"));
632
+ };
633
+ signal.addEventListener("abort", onAbort, { once: true });
634
+ promise.then(
635
+ value => {
636
+ signal.removeEventListener("abort", onAbort);
637
+ resolve(value);
638
+ },
639
+ err => {
640
+ signal.removeEventListener("abort", onAbort);
641
+ reject(err);
642
+ },
643
+ );
644
+ });
645
+ }
646
+
647
+ function raceCredentialRefreshWithSignal<T>(
648
+ promise: Promise<T>,
649
+ signal: AbortSignal | undefined,
650
+ message = "credential refresh aborted",
651
+ ): Promise<T> {
652
+ if (!signal) return promise;
653
+ if (signal.aborted) return Promise.reject(new Error(message));
654
+ const abort = Promise.withResolvers<never>();
655
+ const onAbort = (): void => abort.reject(new Error(message));
656
+ signal.addEventListener("abort", onAbort, { once: true });
657
+ return Promise.race([promise, abort.promise]).finally(() => {
658
+ signal.removeEventListener("abort", onAbort);
659
+ });
660
+ }
661
+
662
+ function authCredentialEquals(left: AuthCredential, right: AuthCredential): boolean {
663
+ if (left.type !== right.type) return false;
664
+ if (left.type === "api_key") {
665
+ return right.type === "api_key" && left.key === right.key;
666
+ }
667
+ if (right.type !== "oauth") return false;
668
+ return (
669
+ left.access === right.access &&
670
+ left.refresh === right.refresh &&
671
+ left.expires === right.expires &&
672
+ left.accountId === right.accountId &&
673
+ left.email === right.email &&
674
+ left.projectId === right.projectId &&
675
+ left.enterpriseUrl === right.enterpriseUrl
676
+ );
677
+ }
678
+
679
+ function storedCredentialArraysEqual(left: StoredCredential[], right: StoredCredential[]): boolean {
680
+ if (left.length !== right.length) return false;
681
+ for (let index = 0; index < left.length; index += 1) {
682
+ const leftEntry = left[index];
683
+ const rightEntry = right[index];
684
+ if (!leftEntry || !rightEntry) return false;
685
+ if (leftEntry.id !== rightEntry.id) return false;
686
+ if (!authCredentialEquals(leftEntry.credential, rightEntry.credential)) return false;
687
+ }
688
+ return true;
689
+ }
690
+
691
+ // ─────────────────────────────────────────────────────────────────────────────
692
+ // Usage Cache (backed by AuthCredentialStore)
693
+ // ─────────────────────────────────────────────────────────────────────────────
694
+
695
+ class AuthStorageUsageCache implements UsageCache {
696
+ constructor(private store: AuthCredentialStore) {}
697
+
698
+ get<T>(key: string): UsageCacheEntry<T> | undefined {
699
+ const raw = this.store.getCache(`${USAGE_CACHE_PREFIX}${key}`);
700
+ if (!raw) return undefined;
701
+ return parseUsageCacheEntry<T>(raw);
702
+ }
703
+
704
+ getStale<T>(key: string): UsageCacheEntry<T> | undefined {
705
+ const raw = this.store.getCache(`${USAGE_CACHE_PREFIX}${key}`, { includeExpired: true });
706
+ if (!raw) return undefined;
707
+ return parseUsageCacheEntry<T>(raw);
708
+ }
709
+
710
+ set<T>(key: string, entry: UsageCacheEntry<T>): void {
711
+ const payload = JSON.stringify({ value: entry.value, expiresAt: entry.expiresAt });
712
+ const durableExpiresAt =
713
+ entry.value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
714
+ this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
715
+ }
716
+
717
+ cleanup(): void {
718
+ this.store.cleanExpiredCache();
719
+ }
720
+ }
721
+
722
+ // ─────────────────────────────────────────────────────────────────────────────
723
+ // In-memory representation
724
+ // ─────────────────────────────────────────────────────────────────────────────
725
+
726
+ type StoredCredential = { id: number; credential: AuthCredential };
727
+
728
+ // ─────────────────────────────────────────────────────────────────────────────
729
+ // AuthStorage Class
730
+ // ─────────────────────────────────────────────────────────────────────────────
731
+
732
+ /**
733
+ * Credential storage backed by an AuthCredentialStore.
734
+ * Reads from storage on reload(), manages round-robin credential selection,
735
+ * usage limit tracking, and OAuth token refresh.
736
+ */
737
+ export class AuthStorage {
738
+ static readonly #defaultBackoffMs = 60_000; // Default backoff when no reset time available
739
+
740
+ /** Provider -> credentials cache, populated from store on reload(). */
741
+ #data: Map<string, StoredCredential[]> = new Map();
742
+ #runtimeOverrides: Map<string, string> = new Map();
743
+ #configOverrides: Map<string, string> = new Map();
744
+ /** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
745
+ #providerRoundRobinIndex: Map<string, number> = new Map();
746
+ /** Tracks the last used credential per provider for a session (used for rate-limit switching). */
747
+ #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
748
+ /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
749
+ #credentialBackoff: Map<string, Map<number, number>> = new Map();
750
+ #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
751
+ #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
752
+ #usageCache: UsageCache;
753
+ #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
754
+ #usageHeaderIngestAt: Map<string, number> = new Map();
755
+ #usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
756
+ #usageFetch: typeof fetch;
757
+ #usageRequestTimeoutMs: number;
758
+ #usageLogger?: UsageLogger;
759
+ #fallbackResolver?: (provider: string) => string | undefined;
760
+ #store: AuthCredentialStore;
761
+ #configValueResolver: (config: string) => Promise<string | undefined>;
762
+ #refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
763
+ #fetchUsageReportsOverride?: AuthStorageOptions["fetchUsageReports"];
764
+ #sourceLabel?: string;
765
+ #credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
766
+ /**
767
+ * Buffer for credential_disabled events fired while no listener is subscribed.
768
+ * Drained (in insertion order) to the first listener that triggers the empty→non-empty
769
+ * transition via {@link AuthStorage.onCredentialDisabled}. Bounded at
770
+ * {@link MAX_PENDING_DISABLED_EVENTS}; oldest entries are dropped to keep memory predictable
771
+ * if a long-lived AuthStorage somehow accumulates a backlog (provider count is naturally small,
772
+ * but a process that runs without subscribers for a long time shouldn't grow this unboundedly).
773
+ */
774
+ #pendingDisabledEvents: CredentialDisabledEvent[] = [];
775
+ #generation = 1;
776
+ #generationListeners: Set<(generation: number) => void> = new Set();
777
+ #oauthRefreshInFlight: Map<number, Promise<AuthCredentialSnapshotEntry>> = new Map();
778
+ #oauthCredentialRefreshInFlight: Map<number, Promise<OAuthCredentials>> = new Map();
779
+ #closed = false;
780
+
781
+ constructor(store: AuthCredentialStore, options: AuthStorageOptions = {}) {
782
+ this.#store = store;
783
+ this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
784
+ this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
785
+ this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
786
+ this.#usageCache = new AuthStorageUsageCache(this.#store);
787
+ this.#usageFetch = options.usageFetch ?? fetch;
788
+ this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
789
+ this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
790
+ this.#fetchUsageReportsOverride = options.fetchUsageReports;
791
+ this.#sourceLabel = options.sourceLabel;
792
+ if (options.onCredentialDisabled) {
793
+ // Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
794
+ // the unsubscribe handle is intentionally discarded.
795
+ this.onCredentialDisabled(options.onCredentialDisabled);
796
+ }
797
+ this.#usageLogger =
798
+ options.usageLogger ??
799
+ ({
800
+ debug: (message, meta) => logger.debug(message, meta),
801
+ warn: (message, meta) => logger.warn(message, meta),
802
+ } satisfies UsageLogger);
803
+ }
804
+
805
+ /**
806
+ * Create an AuthStorage instance backed by a AuthCredentialStore.
807
+ * Convenience factory for standalone use (e.g., aery-ai CLI).
808
+ * @param dbPath - Path to SQLite database
809
+ */
810
+ static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
811
+ const store = await SqliteAuthCredentialStore.open(dbPath);
812
+ return new AuthStorage(store, options);
813
+ }
814
+
815
+ /**
816
+ * Close the underlying credential store.
817
+ *
818
+ * After calling this, the instance must not be reused.
819
+ */
820
+ close(): void {
821
+ if (this.#closed) return;
822
+ this.#closed = true;
823
+ this.#store.close();
824
+ }
825
+
826
+ getGeneration(): number {
827
+ return this.#generation;
828
+ }
829
+
830
+ onGenerationChanged(listener: (generation: number) => void): () => void {
831
+ this.#generationListeners.add(listener);
832
+ return () => {
833
+ this.#generationListeners.delete(listener);
834
+ };
835
+ }
836
+
837
+ offGenerationChanged(listener: (generation: number) => void): void {
838
+ this.#generationListeners.delete(listener);
839
+ }
840
+
841
+ #bumpGeneration(reason: string): void {
842
+ this.#generation += 1;
843
+ for (const listener of [...this.#generationListeners]) {
844
+ try {
845
+ listener(this.#generation);
846
+ } catch (error) {
847
+ logger.debug("AuthStorage generation listener failed", { reason, error: String(error) });
848
+ }
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Subscribe to {@link CredentialDisabledEvent}s. Multiple subscribers are supported and
854
+ * each fires for every disable event; subscribers are invoked in registration order with
855
+ * exceptions and async rejections isolated per-listener so a misbehaving subscriber
856
+ * cannot break the disable path or starve the rest of the chain.
857
+ *
858
+ * If `credential_disabled` events were emitted while no listener was subscribed, they are
859
+ * replayed (in insertion order) to the listener that triggers the empty→non-empty
860
+ * transition. The drain is one-shot — listeners that subscribe after that no longer see
861
+ * past events.
862
+ *
863
+ * Returns an unsubscribe function. The function is idempotent: calling it more than once
864
+ * is a no-op. After every subscriber has unsubscribed, subsequent disable events buffer
865
+ * again until the next subscribe.
866
+ *
867
+ * @param listener Callback invoked with each disable event. May be sync or async.
868
+ * @returns A function that removes this listener from the subscriber set.
869
+ */
870
+ onCredentialDisabled(listener: (event: CredentialDisabledEvent) => void | Promise<void>): () => void {
871
+ const wasEmpty = this.#credentialDisabledListeners.size === 0;
872
+ this.#credentialDisabledListeners.add(listener);
873
+ if (wasEmpty && this.#pendingDisabledEvents.length > 0) {
874
+ const drained = this.#pendingDisabledEvents;
875
+ this.#pendingDisabledEvents = [];
876
+ for (const event of drained) {
877
+ this.#invokeListener(listener, event);
878
+ }
879
+ }
880
+ return () => {
881
+ this.#credentialDisabledListeners.delete(listener);
882
+ };
883
+ }
884
+
885
+ /**
886
+ * Set a runtime API key override (not persisted to disk).
887
+ * Used for CLI --api-key flag.
888
+ */
889
+ setRuntimeApiKey(provider: string, apiKey: string): void {
890
+ this.#runtimeOverrides.set(provider, apiKey);
891
+ }
892
+
893
+ /**
894
+ * Remove a runtime API key override.
895
+ */
896
+ removeRuntimeApiKey(provider: string): void {
897
+ this.#runtimeOverrides.delete(provider);
898
+ }
899
+
900
+ /**
901
+ * Register a per-provider API key sourced from user configuration
902
+ * (e.g. `models.yml` `providers.<name>.apiKey`). Higher priority than
903
+ * stored credentials and OAuth tokens — when the user pins a key in
904
+ * config, that key is what authenticates outbound requests, regardless
905
+ * of whatever the broker happens to have loaded for that provider.
906
+ *
907
+ * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
908
+ * still wins for the duration of a single invocation.
909
+ */
910
+ setConfigApiKey(provider: string, apiKey: string): void {
911
+ this.#configOverrides.set(provider, apiKey);
912
+ }
913
+
914
+ /**
915
+ * Remove a single config-sourced API key override.
916
+ */
917
+ removeConfigApiKey(provider: string): void {
918
+ this.#configOverrides.delete(provider);
919
+ }
920
+
921
+ /**
922
+ * Drop every config-sourced API key. Called by `ModelRegistry` before
923
+ * re-parsing `models.yml` so removed entries actually disappear.
924
+ */
925
+ clearConfigApiKeys(): void {
926
+ this.#configOverrides.clear();
927
+ }
928
+
929
+ /**
930
+ * Set a fallback resolver for API keys not found in storage or env vars.
931
+ * Used for custom provider keys from models.json.
932
+ */
933
+ setFallbackResolver(resolver: (provider: string) => string | undefined): void {
934
+ this.#fallbackResolver = resolver;
935
+ }
936
+
937
+ /**
938
+ * Reload credentials from storage.
939
+ */
940
+ async reload(): Promise<void> {
941
+ const records = this.#store.listAuthCredentials();
942
+ const grouped = new Map<string, StoredCredential[]>();
943
+ for (const record of records) {
944
+ const list = grouped.get(record.provider) ?? [];
945
+ list.push({ id: record.id, credential: record.credential });
946
+ grouped.set(record.provider, list);
947
+ }
948
+
949
+ const dedupedGrouped = new Map<string, StoredCredential[]>();
950
+ for (const [provider, entries] of grouped.entries()) {
951
+ const deduped = this.#pruneDuplicateStoredCredentials(provider, entries);
952
+ if (deduped.length > 0) {
953
+ dedupedGrouped.set(provider, deduped);
954
+ }
955
+ }
956
+
957
+ const removedProviders = new Set(this.#data.keys());
958
+ for (const [provider, entries] of dedupedGrouped) {
959
+ this.#setStoredCredentials(provider, entries);
960
+ removedProviders.delete(provider);
961
+ }
962
+ for (const provider of removedProviders) {
963
+ this.#setStoredCredentials(provider, []);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Gets cached credentials for a provider.
969
+ * @param provider - Provider name (e.g., "anthropic", "openai")
970
+ * @returns Array of stored credentials, empty if none exist
971
+ */
972
+ #getStoredCredentials(provider: string): StoredCredential[] {
973
+ return this.#data.get(provider) ?? [];
974
+ }
975
+
976
+ /**
977
+ * Updates in-memory credential cache for a provider.
978
+ * Removes the provider entry entirely if credentials array is empty.
979
+ * @param provider - Provider name (e.g., "anthropic", "openai")
980
+ * @param credentials - Array of stored credentials to cache
981
+ */
982
+ #setStoredCredentials(provider: string, credentials: StoredCredential[]): void {
983
+ const current = this.#data.get(provider) ?? [];
984
+ if (storedCredentialArraysEqual(current, credentials)) return;
985
+ if (credentials.length === 0) {
986
+ this.#data.delete(provider);
987
+ } else {
988
+ this.#data.set(provider, credentials);
989
+ }
990
+ this.#bumpGeneration("credentials");
991
+ }
992
+
993
+ #resolveOAuthDedupeIdentityKey(provider: string, credential: OAuthCredential): string | null {
994
+ return resolveCredentialIdentityKey(provider, credential);
995
+ }
996
+
997
+ #dedupeOAuthCredentials(provider: string, credentials: AuthCredential[]): AuthCredential[] {
998
+ const seen = new Set<string>();
999
+ const deduped: AuthCredential[] = [];
1000
+ for (let index = credentials.length - 1; index >= 0; index -= 1) {
1001
+ const credential = credentials[index];
1002
+ if (credential.type !== "oauth") {
1003
+ deduped.push(credential);
1004
+ continue;
1005
+ }
1006
+ const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
1007
+ if (!identityKey) {
1008
+ deduped.push(credential);
1009
+ continue;
1010
+ }
1011
+ if (seen.has(identityKey)) {
1012
+ continue;
1013
+ }
1014
+ seen.add(identityKey);
1015
+ deduped.push(credential);
1016
+ }
1017
+ return deduped.reverse();
1018
+ }
1019
+
1020
+ #pruneDuplicateStoredCredentials(provider: string, entries: StoredCredential[]): StoredCredential[] {
1021
+ const seen = new Set<string>();
1022
+ const kept: StoredCredential[] = [];
1023
+ const removed: StoredCredential[] = [];
1024
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
1025
+ const entry = entries[index];
1026
+ const credential = entry.credential;
1027
+ if (credential.type !== "oauth") {
1028
+ kept.push(entry);
1029
+ continue;
1030
+ }
1031
+ const identityKey = this.#resolveOAuthDedupeIdentityKey(provider, credential);
1032
+ if (!identityKey) {
1033
+ kept.push(entry);
1034
+ continue;
1035
+ }
1036
+ if (seen.has(identityKey)) {
1037
+ removed.push(entry);
1038
+ continue;
1039
+ }
1040
+ seen.add(identityKey);
1041
+ kept.push(entry);
1042
+ }
1043
+ if (removed.length > 0) {
1044
+ for (const entry of removed) {
1045
+ this.#store.deleteAuthCredential(entry.id, "deduplicated duplicate credential");
1046
+ }
1047
+ this.#resetProviderAssignments(provider);
1048
+ }
1049
+ return kept.reverse();
1050
+ }
1051
+
1052
+ /** Returns all credentials for a provider as an array */
1053
+ #getCredentialsForProvider(provider: string): AuthCredential[] {
1054
+ return this.#getStoredCredentials(provider).map(entry => entry.credential);
1055
+ }
1056
+
1057
+ /** Composite key for round-robin tracking: "anthropic:oauth" or "openai:api_key" */
1058
+ #getProviderTypeKey(provider: string, type: AuthCredential["type"]): string {
1059
+ return `${provider}:${type}`;
1060
+ }
1061
+
1062
+ /**
1063
+ * Returns next index in round-robin sequence for load distribution.
1064
+ * Increments stored counter and wraps at total.
1065
+ */
1066
+ #getNextRoundRobinIndex(providerKey: string, total: number): number {
1067
+ if (total <= 1) return 0;
1068
+ const current = this.#providerRoundRobinIndex.get(providerKey) ?? -1;
1069
+ const next = (current + 1) % total;
1070
+ this.#providerRoundRobinIndex.set(providerKey, next);
1071
+ return next;
1072
+ }
1073
+
1074
+ /**
1075
+ * FNV-1a hash for deterministic session-to-credential mapping.
1076
+ * Ensures the same session always starts with the same credential.
1077
+ */
1078
+ #getHashedIndex(sessionId: string, total: number): number {
1079
+ if (total <= 1) return 0;
1080
+ return Bun.hash.xxHash32(sessionId) % total;
1081
+ }
1082
+
1083
+ /**
1084
+ * Returns credential indices in priority order for selection.
1085
+ * With sessionId: starts from hashed index (consistent per session).
1086
+ * Without sessionId: starts from round-robin index (load balancing).
1087
+ * Order wraps around so all credentials are tried if earlier ones are blocked.
1088
+ */
1089
+ #getCredentialOrder(providerKey: string, sessionId: string | undefined, total: number): number[] {
1090
+ if (total <= 1) return [0];
1091
+ const start = sessionId
1092
+ ? this.#getHashedIndex(sessionId, total)
1093
+ : this.#getNextRoundRobinIndex(providerKey, total);
1094
+ const order: number[] = [];
1095
+ for (let i = 0; i < total; i++) {
1096
+ order.push((start + i) % total);
1097
+ }
1098
+ return order;
1099
+ }
1100
+
1101
+ /** Returns block expiry timestamp for a credential, cleaning up expired entries. */
1102
+ #getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
1103
+ const backoffMap = this.#credentialBackoff.get(providerKey);
1104
+ if (!backoffMap) return undefined;
1105
+ const blockedUntil = backoffMap.get(credentialIndex);
1106
+ if (!blockedUntil) return undefined;
1107
+ if (blockedUntil <= Date.now()) {
1108
+ backoffMap.delete(credentialIndex);
1109
+ if (backoffMap.size === 0) {
1110
+ this.#credentialBackoff.delete(providerKey);
1111
+ }
1112
+ return undefined;
1113
+ }
1114
+ return blockedUntil;
1115
+ }
1116
+
1117
+ /** Checks if a credential is temporarily blocked due to usage limits. */
1118
+ #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
1119
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
1120
+ }
1121
+
1122
+ /** Marks a credential as blocked until the specified time. */
1123
+ #markCredentialBlocked(providerKey: string, credentialIndex: number, blockedUntilMs: number): void {
1124
+ const backoffMap = this.#credentialBackoff.get(providerKey) ?? new Map<number, number>();
1125
+ const existing = backoffMap.get(credentialIndex) ?? 0;
1126
+ backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
1127
+ this.#credentialBackoff.set(providerKey, backoffMap);
1128
+ }
1129
+
1130
+ /** Records which credential was used for a session (for rate-limit switching). */
1131
+ #recordSessionCredential(
1132
+ provider: string,
1133
+ sessionId: string | undefined,
1134
+ type: AuthCredential["type"],
1135
+ index: number,
1136
+ ): void {
1137
+ if (!sessionId) return;
1138
+ const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
1139
+ sessionMap.set(sessionId, { type, index });
1140
+ this.#sessionLastCredential.set(provider, sessionMap);
1141
+ }
1142
+
1143
+ /** Retrieves the last credential used by a session. */
1144
+ #getSessionCredential(
1145
+ provider: string,
1146
+ sessionId: string | undefined,
1147
+ ): { type: AuthCredential["type"]; index: number } | undefined {
1148
+ if (!sessionId) return undefined;
1149
+ return this.#sessionLastCredential.get(provider)?.get(sessionId);
1150
+ }
1151
+
1152
+ /** Clears the last credential used by a session for a provider. */
1153
+ #clearSessionCredential(provider: string, sessionId: string | undefined): void {
1154
+ if (!sessionId) return;
1155
+ const sessionMap = this.#sessionLastCredential.get(provider);
1156
+ if (!sessionMap) return;
1157
+ sessionMap.delete(sessionId);
1158
+ if (sessionMap.size === 0) {
1159
+ this.#sessionLastCredential.delete(provider);
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Selects a credential of the specified type for a provider.
1165
+ * Returns both the credential and its index in the original array (for updates/removal).
1166
+ * Uses deterministic hashing for session stickiness and skips blocked credentials when possible.
1167
+ */
1168
+ #selectCredentialByType<T extends AuthCredential["type"]>(
1169
+ provider: string,
1170
+ type: T,
1171
+ sessionId?: string,
1172
+ ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
1173
+ const credentials = this.#getCredentialsForProvider(provider)
1174
+ .map((credential, index) => ({ credential, index }))
1175
+ .filter(
1176
+ (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
1177
+ entry.credential.type === type,
1178
+ );
1179
+
1180
+ if (credentials.length === 0) return undefined;
1181
+ if (credentials.length === 1) return credentials[0];
1182
+
1183
+ const providerKey = this.#getProviderTypeKey(provider, type);
1184
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1185
+ const fallback = credentials[order[0]];
1186
+
1187
+ for (const idx of order) {
1188
+ const candidate = credentials[idx];
1189
+ if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
1190
+ return candidate;
1191
+ }
1192
+ }
1193
+
1194
+ return fallback;
1195
+ }
1196
+
1197
+ /**
1198
+ * Clears round-robin and session assignment state for a provider.
1199
+ * Called when credentials are added/removed to prevent stale index references.
1200
+ */
1201
+ #resetProviderAssignments(provider: string): void {
1202
+ for (const key of this.#providerRoundRobinIndex.keys()) {
1203
+ if (key.startsWith(`${provider}:`)) {
1204
+ this.#providerRoundRobinIndex.delete(key);
1205
+ }
1206
+ }
1207
+ this.#sessionLastCredential.delete(provider);
1208
+ for (const key of this.#credentialBackoff.keys()) {
1209
+ if (key.startsWith(`${provider}:`)) {
1210
+ this.#credentialBackoff.delete(key);
1211
+ }
1212
+ }
1213
+ }
1214
+
1215
+ /** Updates credential at index in-place (used for OAuth token refresh) */
1216
+ #replaceCredentialAt(provider: string, index: number, credential: AuthCredential): void {
1217
+ const entries = this.#getStoredCredentials(provider);
1218
+ if (index < 0 || index >= entries.length) return;
1219
+ const target = entries[index];
1220
+ this.#store.updateAuthCredential(target.id, credential);
1221
+ const updated = [...entries];
1222
+ updated[index] = { id: target.id, credential };
1223
+ this.#setStoredCredentials(provider, updated);
1224
+ }
1225
+
1226
+ /**
1227
+ * CAS-style disable used when OAuth refresh definitively fails: only disables
1228
+ * persisted `data` still matches the credential we attempted to refresh.
1229
+ * Returns `false` when a peer rotated the row between our pre-check and the
1230
+ * disable, so the caller can reload and retry instead of clobbering the
1231
+ * freshly-rotated credential.
1232
+ */
1233
+ #tryDisableCredentialAtIfMatches(
1234
+ provider: string,
1235
+ index: number,
1236
+ expectedCredential: AuthCredential,
1237
+ disabledCause: string,
1238
+ ): boolean {
1239
+ const entries = this.#getStoredCredentials(provider);
1240
+ if (index < 0 || index >= entries.length) return false;
1241
+ const target = entries[index];
1242
+ const serialized = serializeCredential(provider, expectedCredential);
1243
+ if (!serialized) return false;
1244
+ const disabled = this.#store.tryDisableAuthCredentialIfMatches(target.id, serialized.data, disabledCause);
1245
+ if (!disabled) return false;
1246
+ const updated = entries.filter((_value, idx) => idx !== index);
1247
+ this.#setStoredCredentials(provider, updated);
1248
+ this.#resetProviderAssignments(provider);
1249
+ this.#emitCredentialDisabled({ provider, disabledCause });
1250
+ return true;
1251
+ }
1252
+
1253
+ #emitCredentialDisabled(event: CredentialDisabledEvent): void {
1254
+ if (this.#credentialDisabledListeners.size === 0) {
1255
+ // No subscribers — buffer for later replay. Cap the backlog so a process that runs
1256
+ // without subscribers for a long time can't grow memory unboundedly; drop oldest
1257
+ // under pressure.
1258
+ if (this.#pendingDisabledEvents.length >= MAX_PENDING_DISABLED_EVENTS) {
1259
+ this.#pendingDisabledEvents.shift();
1260
+ }
1261
+ this.#pendingDisabledEvents.push(event);
1262
+ return;
1263
+ }
1264
+ // Snapshot before iteration so a listener that subscribes/unsubscribes during fan-out
1265
+ // can't observe a partially-mutated set or receive an event it just registered for.
1266
+ const listeners = [...this.#credentialDisabledListeners];
1267
+ for (const listener of listeners) {
1268
+ this.#invokeListener(listener, event);
1269
+ }
1270
+ }
1271
+
1272
+ #invokeListener(
1273
+ listener: (event: CredentialDisabledEvent) => void | Promise<void>,
1274
+ event: CredentialDisabledEvent,
1275
+ ): void {
1276
+ const logListenerError = (error: unknown): void => {
1277
+ logger.warn("onCredentialDisabled listener threw", { provider: event.provider, error: String(error) });
1278
+ };
1279
+ try {
1280
+ const result = listener(event);
1281
+ if (result && typeof (result as PromiseLike<void>).then === "function") {
1282
+ (result as Promise<void>).catch(logListenerError);
1283
+ }
1284
+ } catch (error) {
1285
+ logListenerError(error);
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Get credential for a provider (first entry if multiple).
1291
+ */
1292
+ get(provider: string): AuthCredential | undefined {
1293
+ return this.#getCredentialsForProvider(provider)[0];
1294
+ }
1295
+
1296
+ /**
1297
+ * Set credential for a provider.
1298
+ */
1299
+ async set(provider: string, credential: AuthCredentialEntry): Promise<void> {
1300
+ const normalized = Array.isArray(credential) ? credential : [credential];
1301
+ const deduped = this.#dedupeOAuthCredentials(provider, normalized);
1302
+ const stored = this.#store.replaceAuthCredentialsRemote
1303
+ ? await this.#store.replaceAuthCredentialsRemote(provider, deduped)
1304
+ : this.#store.replaceAuthCredentialsForProvider(provider, deduped);
1305
+ this.#setStoredCredentials(
1306
+ provider,
1307
+ stored.map(record => ({ id: record.id, credential: record.credential })),
1308
+ );
1309
+ this.#resetProviderAssignments(provider);
1310
+ }
1311
+
1312
+ async #upsertOAuthCredential(provider: string, credential: OAuthCredential): Promise<void> {
1313
+ const stored = this.#store.upsertAuthCredentialRemote
1314
+ ? await this.#store.upsertAuthCredentialRemote(provider, credential)
1315
+ : this.#store.upsertAuthCredentialForProvider(provider, credential);
1316
+ this.#setStoredCredentials(
1317
+ provider,
1318
+ stored.map(record => ({ id: record.id, credential: record.credential })),
1319
+ );
1320
+ this.#resetProviderAssignments(provider);
1321
+ }
1322
+
1323
+ /**
1324
+ * Remove credential for a provider.
1325
+ */
1326
+ async remove(provider: string): Promise<void> {
1327
+ if (this.#store.deleteAuthCredentialsRemote) {
1328
+ await this.#store.deleteAuthCredentialsRemote(provider, "deleted by user");
1329
+ } else {
1330
+ this.#store.deleteAuthCredentialsForProvider(provider, "deleted by user");
1331
+ }
1332
+ this.#setStoredCredentials(provider, []);
1333
+ this.#resetProviderAssignments(provider);
1334
+ }
1335
+
1336
+ /**
1337
+ * List all providers with credentials.
1338
+ */
1339
+ list(): string[] {
1340
+ return [...this.#data.keys()];
1341
+ }
1342
+
1343
+ /**
1344
+ * Check if credentials exist for a provider in storage.
1345
+ */
1346
+ has(provider: string): boolean {
1347
+ return this.#getCredentialsForProvider(provider).length > 0;
1348
+ }
1349
+
1350
+ /**
1351
+ * Check if any form of auth is configured for a provider.
1352
+ * Unlike getApiKey(), this doesn't refresh OAuth tokens.
1353
+ */
1354
+ hasAuth(provider: string): boolean {
1355
+ if (this.#runtimeOverrides.has(provider)) return true;
1356
+ if (this.#configOverrides.has(provider)) return true;
1357
+ if (this.#getCredentialsForProvider(provider).length > 0) return true;
1358
+ if (getEnvApiKey(provider)) return true;
1359
+ if (this.#fallbackResolver?.(provider)) return true;
1360
+ return false;
1361
+ }
1362
+
1363
+ /**
1364
+ * True iff a dedicated, non-env credential source is configured for this
1365
+ * provider — i.e. anything in the cascade EXCEPT `getEnvApiKey(provider)`.
1366
+ *
1367
+ * Mirrors `hasAuth` minus the env-fallback leg. Useful for callers that
1368
+ * need to distinguish "the user explicitly configured this provider"
1369
+ * from "an env var happens to alias this provider via the cross-provider
1370
+ * fallback map" (see e.g. `xai-oauth → XAI_OAUTH_TOKEN || XAI_API_KEY` in
1371
+ * `stream.ts`). Without that distinction, an `XAI_API_KEY`-only setup
1372
+ * silently satisfies xai-oauth and routes around `providers.xai.baseUrl`.
1373
+ */
1374
+ hasNonEnvCredential(provider: string): boolean {
1375
+ if (this.#runtimeOverrides.has(provider)) return true;
1376
+ if (this.#configOverrides.has(provider)) return true;
1377
+ if (this.#getCredentialsForProvider(provider).length > 0) return true;
1378
+ if (this.#fallbackResolver?.(provider)) return true;
1379
+ return false;
1380
+ }
1381
+
1382
+ /**
1383
+ * Check if OAuth credentials are configured for a provider.
1384
+ */
1385
+ hasOAuth(provider: string): boolean {
1386
+ return this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth");
1387
+ }
1388
+
1389
+ /**
1390
+ * Get OAuth credentials for a provider.
1391
+ */
1392
+ getOAuthCredential(provider: string): OAuthCredential | undefined {
1393
+ return this.#getCredentialsForProvider(provider).find(
1394
+ (credential): credential is OAuthCredential => credential.type === "oauth",
1395
+ );
1396
+ }
1397
+
1398
+ #resolveActiveOAuthCredential(provider: string, sessionId?: string): OAuthCredential | undefined {
1399
+ const allCredentials = this.#getCredentialsForProvider(provider);
1400
+ const oauthCredentials = allCredentials.filter((c): c is OAuthCredential => c.type === "oauth");
1401
+ if (oauthCredentials.length === 0) return undefined;
1402
+
1403
+ // Runtime / config overrides bypass OAuth account_uuid attribution — the
1404
+ // caller is authenticating with an explicit key, not the broker's OAuth.
1405
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) return undefined;
1406
+
1407
+ // Prefer the session-sticky credential when available.
1408
+ const sessionPref = this.#getSessionCredential(provider, sessionId);
1409
+ // If the session has been routed to a stored API key, do not inject OAuth account_uuid.
1410
+ if (sessionPref !== undefined && sessionPref.type !== "oauth") return undefined;
1411
+
1412
+ // When no session-sticky credential is recorded yet (first call before any getApiKey,
1413
+ // or all stored credentials are unavailable), the request falls through to the env-key
1414
+ // or fallback-resolver path in getApiKey() — neither is OAuth-authenticated, so
1415
+ // account_uuid injection would misattribute traffic. Only apply this guard when
1416
+ // sessionPref is absent; a recorded OAuth sticky (sessionPref.type === "oauth") must
1417
+ // NOT be blocked even if an env key also happens to exist.
1418
+ if (!sessionPref && (getEnvApiKey(provider) || this.#fallbackResolver?.(provider))) return undefined;
1419
+ // Resolve the sticky index against the full credential list — the index is
1420
+ // recorded against the unfiltered provider array (by #recordSessionCredential /
1421
+ // #tryOAuthCredential), not the OAuth-only subset, so dereferencing it into the
1422
+ // filtered array would be off-by-N when any non-OAuth credential precedes the
1423
+ // OAuth ones (e.g. [api_key, oauth_A, oauth_B] stored order).
1424
+ const stickyCredential = sessionPref?.type === "oauth" ? allCredentials[sessionPref.index] : undefined;
1425
+ return stickyCredential?.type === "oauth" ? stickyCredential : oauthCredentials[0];
1426
+ }
1427
+
1428
+ /**
1429
+ * Get the OAuth `accountId` for a provider, preferring the credential that is
1430
+ * session-sticky for `sessionId` when multiple OAuth credentials are configured.
1431
+ * Falls back to the first OAuth credential when no session preference exists (e.g.
1432
+ * first call before any `getApiKey` has been issued, or single-credential setups).
1433
+ * Returns `undefined` when no OAuth credential carries an `accountId`.
1434
+ */
1435
+ getOAuthAccountId(provider: string, sessionId?: string): string | undefined {
1436
+ const preferred = this.#resolveActiveOAuthCredential(provider, sessionId);
1437
+ const accountId = preferred?.accountId;
1438
+ return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
1439
+ }
1440
+
1441
+ /**
1442
+ * Get all credentials.
1443
+ */
1444
+ getAll(): AuthStorageData {
1445
+ const result: AuthStorageData = {};
1446
+ for (const [provider, entries] of this.#data.entries()) {
1447
+ const credentials = entries.map(entry => entry.credential);
1448
+ if (credentials.length === 1) {
1449
+ result[provider] = credentials[0];
1450
+ } else if (credentials.length > 1) {
1451
+ result[provider] = credentials;
1452
+ }
1453
+ }
1454
+ return result;
1455
+ }
1456
+
1457
+ /**
1458
+ * Login to an OAuth provider.
1459
+ */
1460
+ async login(
1461
+ provider: OAuthProviderId,
1462
+ ctrl: OAuthController & {
1463
+ /** onAuth is required by auth-storage but optional in OAuthController */
1464
+ onAuth: (info: { url: string; instructions?: string }) => void;
1465
+ /** onPrompt is required for some providers (github-copilot, openai-codex) */
1466
+ onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
1467
+ },
1468
+ ): Promise<void> {
1469
+ let credentials: OAuthCredentials;
1470
+ const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
1471
+ const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
1472
+ await this.set(provider, newCredential);
1473
+ };
1474
+ const manualCodeInput = () => ctrl.onPrompt({ message: "Paste the authorization code (or full redirect URL):" });
1475
+ switch (provider) {
1476
+ case "anthropic": {
1477
+ const { loginAnthropic } = await import("./utils/oauth/anthropic");
1478
+ credentials = await loginAnthropic({
1479
+ ...ctrl,
1480
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1481
+ });
1482
+ break;
1483
+ }
1484
+ case "xai-oauth": {
1485
+ const { loginXAIOAuth } = await import("./utils/oauth/xai-oauth");
1486
+ credentials = await loginXAIOAuth({
1487
+ ...ctrl,
1488
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1489
+ });
1490
+ break;
1491
+ }
1492
+ case "alibaba-coding-plan": {
1493
+ const { loginAlibabaCodingPlan } = await import("./utils/oauth/alibaba-coding-plan");
1494
+ const apiKey = await loginAlibabaCodingPlan(ctrl);
1495
+ await saveApiKeyCredential(apiKey);
1496
+ return;
1497
+ }
1498
+ case "github-copilot": {
1499
+ const { loginGitHubCopilot } = await import("./utils/oauth/github-copilot");
1500
+ credentials = await loginGitHubCopilot({
1501
+ onAuth: (url, instructions) => ctrl.onAuth({ url, instructions }),
1502
+ onPrompt: ctrl.onPrompt,
1503
+ onProgress: ctrl.onProgress,
1504
+ signal: ctrl.signal,
1505
+ });
1506
+ break;
1507
+ }
1508
+ case "google-gemini-cli": {
1509
+ const { loginGeminiCli } = await import("./utils/oauth/google-gemini-cli");
1510
+ credentials = await loginGeminiCli({
1511
+ ...ctrl,
1512
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1513
+ });
1514
+ break;
1515
+ }
1516
+ case "google-antigravity": {
1517
+ const { loginAntigravity } = await import("./utils/oauth/google-antigravity");
1518
+ credentials = await loginAntigravity({
1519
+ ...ctrl,
1520
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1521
+ });
1522
+ break;
1523
+ }
1524
+ case "openai-codex": {
1525
+ const { loginOpenAICodex } = await import("./utils/oauth/openai-codex");
1526
+ credentials = await loginOpenAICodex({
1527
+ ...ctrl,
1528
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1529
+ });
1530
+ break;
1531
+ }
1532
+ case "openai-codex-device": {
1533
+ // Device/headless flow — stores credentials under "openai-codex" so the
1534
+ // provider can pick them up without a separate provider configuration.
1535
+ const deviceCredentials = await loginOpenAICodexDevice(ctrl);
1536
+ const newCredential: OAuthCredential = { type: "oauth", ...deviceCredentials };
1537
+ await this.#upsertOAuthCredential("openai-codex", newCredential);
1538
+ return;
1539
+ }
1540
+ case "gitlab-duo": {
1541
+ const { loginGitLabDuo } = await import("./utils/oauth/gitlab-duo");
1542
+ credentials = await loginGitLabDuo({
1543
+ ...ctrl,
1544
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1545
+ });
1546
+ break;
1547
+ }
1548
+ case "kimi-code": {
1549
+ const { loginKimi } = await import("./utils/oauth/kimi");
1550
+ credentials = await loginKimi(ctrl);
1551
+ break;
1552
+ }
1553
+ case "kilo": {
1554
+ const { loginKilo } = await import("./utils/oauth/kilo");
1555
+ credentials = await loginKilo(ctrl);
1556
+ break;
1557
+ }
1558
+ case "cursor": {
1559
+ const { loginCursor } = await import("./utils/oauth/cursor");
1560
+ credentials = await loginCursor(
1561
+ url => ctrl.onAuth({ url }),
1562
+ ctrl.onProgress ? () => ctrl.onProgress?.("Waiting for browser authentication...") : undefined,
1563
+ );
1564
+ break;
1565
+ }
1566
+ case "perplexity": {
1567
+ const { loginPerplexity } = await import("./utils/oauth/perplexity");
1568
+ credentials = await loginPerplexity(ctrl);
1569
+ break;
1570
+ }
1571
+ case "huggingface": {
1572
+ const { loginHuggingface } = await import("./utils/oauth/huggingface");
1573
+ const apiKey = await loginHuggingface(ctrl);
1574
+ await saveApiKeyCredential(apiKey);
1575
+ return;
1576
+ }
1577
+ case "opencode-zen":
1578
+ case "opencode-go": {
1579
+ const { loginOpenCode } = await import("./utils/oauth/opencode");
1580
+ const apiKey = await loginOpenCode(ctrl);
1581
+ await saveApiKeyCredential(apiKey);
1582
+ return;
1583
+ }
1584
+ case "lm-studio": {
1585
+ const { loginLmStudio } = await import("./utils/oauth/lm-studio");
1586
+ const apiKey = await loginLmStudio(ctrl);
1587
+ await saveApiKeyCredential(apiKey);
1588
+ return;
1589
+ }
1590
+ case "ollama": {
1591
+ const { loginOllama } = await import("./utils/oauth/ollama");
1592
+ const apiKey = await loginOllama(ctrl);
1593
+ if (!apiKey) {
1594
+ return;
1595
+ }
1596
+ await saveApiKeyCredential(apiKey);
1597
+ return;
1598
+ }
1599
+ case "ollama-cloud": {
1600
+ const { loginOllamaCloud } = await import("./utils/oauth/ollama-cloud");
1601
+ const apiKey = await loginOllamaCloud(ctrl);
1602
+ await saveApiKeyCredential(apiKey);
1603
+ return;
1604
+ }
1605
+ case "cerebras": {
1606
+ const { loginCerebras } = await import("./utils/oauth/cerebras");
1607
+ const apiKey = await loginCerebras(ctrl);
1608
+ await saveApiKeyCredential(apiKey);
1609
+ return;
1610
+ }
1611
+ case "deepseek": {
1612
+ const apiKey = await loginDeepSeek(ctrl);
1613
+ await saveApiKeyCredential(apiKey);
1614
+ return;
1615
+ }
1616
+ case "fireworks": {
1617
+ const { loginFireworks } = await import("./utils/oauth/fireworks");
1618
+ const apiKey = await loginFireworks(ctrl);
1619
+ await saveApiKeyCredential(apiKey);
1620
+ return;
1621
+ }
1622
+ case "firepass": {
1623
+ const { loginFirepass } = await import("./utils/oauth/firepass");
1624
+ const apiKey = await loginFirepass(ctrl);
1625
+ await saveApiKeyCredential(apiKey);
1626
+ return;
1627
+ }
1628
+ case "wafer-pass": {
1629
+ const { loginWaferPass } = await import("./utils/oauth/wafer");
1630
+ const apiKey = await loginWaferPass(ctrl);
1631
+ await saveApiKeyCredential(apiKey);
1632
+ return;
1633
+ }
1634
+ case "wafer-serverless": {
1635
+ const { loginWaferServerless } = await import("./utils/oauth/wafer");
1636
+ const apiKey = await loginWaferServerless(ctrl);
1637
+ await saveApiKeyCredential(apiKey);
1638
+ return;
1639
+ }
1640
+ case "zai": {
1641
+ const { loginZai } = await import("./utils/oauth/zai");
1642
+ const apiKey = await loginZai(ctrl);
1643
+ await saveApiKeyCredential(apiKey);
1644
+ return;
1645
+ }
1646
+ case "zhipu-coding-plan": {
1647
+ const { loginZhipuCodingPlan } = await import("./utils/oauth/zhipu");
1648
+ const apiKey = await loginZhipuCodingPlan(ctrl);
1649
+ await saveApiKeyCredential(apiKey);
1650
+ return;
1651
+ }
1652
+ case "qianfan": {
1653
+ const { loginQianfan } = await import("./utils/oauth/qianfan");
1654
+ const apiKey = await loginQianfan(ctrl);
1655
+ await saveApiKeyCredential(apiKey);
1656
+ return;
1657
+ }
1658
+ case "minimax-code": {
1659
+ const { loginMiniMaxCode } = await import("./utils/oauth/minimax-code");
1660
+ const apiKey = await loginMiniMaxCode(ctrl);
1661
+ await saveApiKeyCredential(apiKey);
1662
+ return;
1663
+ }
1664
+ case "minimax-code-cn": {
1665
+ const { loginMiniMaxCodeCn } = await import("./utils/oauth/minimax-code");
1666
+ const apiKey = await loginMiniMaxCodeCn(ctrl);
1667
+ await saveApiKeyCredential(apiKey);
1668
+ return;
1669
+ }
1670
+ case "synthetic": {
1671
+ const { loginSynthetic } = await import("./utils/oauth/synthetic");
1672
+ const apiKey = await loginSynthetic(ctrl);
1673
+ await saveApiKeyCredential(apiKey);
1674
+ return;
1675
+ }
1676
+ case "tavily": {
1677
+ const { loginTavily } = await import("./utils/oauth/tavily");
1678
+ const apiKey = await loginTavily(ctrl);
1679
+ await saveApiKeyCredential(apiKey);
1680
+ return;
1681
+ }
1682
+ case "venice": {
1683
+ const { loginVenice } = await import("./utils/oauth/venice");
1684
+ const apiKey = await loginVenice(ctrl);
1685
+ await saveApiKeyCredential(apiKey);
1686
+ return;
1687
+ }
1688
+ case "litellm": {
1689
+ const { loginLiteLLM } = await import("./utils/oauth/litellm");
1690
+ const apiKey = await loginLiteLLM(ctrl);
1691
+ await saveApiKeyCredential(apiKey);
1692
+ return;
1693
+ }
1694
+ case "moonshot": {
1695
+ const { loginMoonshot } = await import("./utils/oauth/moonshot");
1696
+ const apiKey = await loginMoonshot(ctrl);
1697
+ await saveApiKeyCredential(apiKey);
1698
+ return;
1699
+ }
1700
+ case "kagi": {
1701
+ const { loginKagi } = await import("./utils/oauth/kagi");
1702
+ const apiKey = await loginKagi(ctrl);
1703
+ await saveApiKeyCredential(apiKey);
1704
+ return;
1705
+ }
1706
+ case "nanogpt": {
1707
+ const { loginNanoGPT } = await import("./utils/oauth/nanogpt");
1708
+ const apiKey = await loginNanoGPT(ctrl);
1709
+ await saveApiKeyCredential(apiKey);
1710
+ return;
1711
+ }
1712
+ case "openrouter": {
1713
+ const { loginOpenRouter } = await import("./utils/oauth/openrouter");
1714
+ const apiKey = await loginOpenRouter(ctrl);
1715
+ await saveApiKeyCredential(apiKey);
1716
+ return;
1717
+ }
1718
+ case "together": {
1719
+ const { loginTogether } = await import("./utils/oauth/together");
1720
+ const apiKey = await loginTogether(ctrl);
1721
+ await saveApiKeyCredential(apiKey);
1722
+ return;
1723
+ }
1724
+ case "cloudflare-ai-gateway": {
1725
+ const { loginCloudflareAiGateway } = await import("./utils/oauth/cloudflare-ai-gateway");
1726
+ const apiKey = await loginCloudflareAiGateway(ctrl);
1727
+ await saveApiKeyCredential(apiKey);
1728
+ return;
1729
+ }
1730
+ case "vercel-ai-gateway": {
1731
+ const { loginVercelAiGateway } = await import("./utils/oauth/vercel-ai-gateway");
1732
+ const apiKey = await loginVercelAiGateway(ctrl);
1733
+ await saveApiKeyCredential(apiKey);
1734
+ return;
1735
+ }
1736
+ case "vllm": {
1737
+ const { loginVllm } = await import("./utils/oauth/vllm");
1738
+ const apiKey = await loginVllm(ctrl);
1739
+ await saveApiKeyCredential(apiKey);
1740
+ return;
1741
+ }
1742
+ case "parallel": {
1743
+ const { loginParallel } = await import("./utils/oauth/parallel");
1744
+ const apiKey = await loginParallel(ctrl);
1745
+ await saveApiKeyCredential(apiKey);
1746
+ return;
1747
+ }
1748
+ case "qwen-portal": {
1749
+ const { loginQwenPortal } = await import("./utils/oauth/qwen-portal");
1750
+ const apiKey = await loginQwenPortal(ctrl);
1751
+ await saveApiKeyCredential(apiKey);
1752
+ return;
1753
+ }
1754
+ case "nvidia": {
1755
+ const { loginNvidia } = await import("./utils/oauth/nvidia");
1756
+ const apiKey = await loginNvidia(ctrl);
1757
+ await saveApiKeyCredential(apiKey);
1758
+ return;
1759
+ }
1760
+ case "xiaomi": {
1761
+ const { loginXiaomi } = await import("./utils/oauth/xiaomi");
1762
+ const apiKey = await loginXiaomi(ctrl);
1763
+ await saveApiKeyCredential(apiKey);
1764
+ return;
1765
+ }
1766
+ case "zenmux": {
1767
+ const { loginZenMux } = await import("./utils/oauth/zenmux");
1768
+ const apiKey = await loginZenMux(ctrl);
1769
+ await saveApiKeyCredential(apiKey);
1770
+ return;
1771
+ }
1772
+ default: {
1773
+ const customProvider = getOAuthProvider(provider);
1774
+ if (!customProvider) {
1775
+ throw new Error(`Unknown OAuth provider: ${provider}`);
1776
+ }
1777
+ const customLoginResult = await customProvider.login({
1778
+ onAuth: info => ctrl.onAuth(info),
1779
+ onProgress: ctrl.onProgress,
1780
+ onPrompt: ctrl.onPrompt,
1781
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1782
+ signal: ctrl.signal,
1783
+ });
1784
+ if (typeof customLoginResult === "string") {
1785
+ await saveApiKeyCredential(customLoginResult);
1786
+ return;
1787
+ }
1788
+ credentials = customLoginResult;
1789
+ break;
1790
+ }
1791
+ }
1792
+ const newCredential: OAuthCredential = { type: "oauth", ...credentials };
1793
+ await this.#upsertOAuthCredential(provider, newCredential);
1794
+ }
1795
+
1796
+ /**
1797
+ * Logout from a provider.
1798
+ */
1799
+ async logout(provider: string): Promise<void> {
1800
+ await this.remove(provider);
1801
+ }
1802
+
1803
+ // ─────────────────────────────────────────────────────────────────────────────
1804
+ // Usage API Integration
1805
+ // Queries provider usage endpoints to detect rate limits before they occur.
1806
+ // ─────────────────────────────────────────────────────────────────────────────
1807
+
1808
+ #buildUsageCredential(credential: OAuthCredential): UsageCredential {
1809
+ return {
1810
+ type: "oauth",
1811
+ accessToken: credential.access,
1812
+ refreshToken: credential.refresh,
1813
+ expiresAt: credential.expires,
1814
+ accountId: credential.accountId,
1815
+ projectId: credential.projectId,
1816
+ email: credential.email,
1817
+ enterpriseUrl: credential.enterpriseUrl,
1818
+ };
1819
+ }
1820
+
1821
+ #buildUsageCacheIdentity(credential: UsageCredential): string {
1822
+ const parts: string[] = [credential.type];
1823
+ const accountId = credential.accountId?.trim();
1824
+ if (accountId) parts.push(`account:${accountId}`);
1825
+ const email = credential.email?.trim().toLowerCase();
1826
+ if (email) parts.push(`email:${email}`);
1827
+ const projectId = credential.projectId?.trim();
1828
+ if (projectId) parts.push(`project:${projectId}`);
1829
+ const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase();
1830
+ if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
1831
+ // Only fall back to a secret-derived key when a stable account identifier is unavailable.
1832
+ // Including the token hash when accountId/email are present causes cache misses on
1833
+ // every OAuth refresh — usage data is per-account, not per-token.
1834
+ const hasStableIdentifier = Boolean(accountId || email);
1835
+ if (!hasStableIdentifier) {
1836
+ const secret = credential.apiKey?.trim() || credential.refreshToken?.trim() || credential.accessToken?.trim();
1837
+ if (secret) {
1838
+ parts.push(`secret:${Bun.hash(secret).toString(16)}`);
1839
+ } else {
1840
+ parts.push("anonymous");
1841
+ }
1842
+ }
1843
+ return parts.join("|");
1844
+ }
1845
+
1846
+ #normalizeUsageBaseUrl(baseUrl?: string): string {
1847
+ return baseUrl?.trim().replace(/\/+$/, "") ?? "";
1848
+ }
1849
+
1850
+ #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
1851
+ const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
1852
+ const identity = this.#buildUsageCacheIdentity(request.credential);
1853
+ return `report:${request.provider}:${baseUrl}:${identity}`;
1854
+ }
1855
+
1856
+ #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
1857
+ const snapshot = requests
1858
+ .map(
1859
+ request =>
1860
+ `${request.provider}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`,
1861
+ )
1862
+ .sort()
1863
+ .join("\n");
1864
+ return `reports:${Bun.hash(snapshot).toString(16)}`;
1865
+ }
1866
+
1867
+ #buildUsageRequest(provider: Provider, credential: UsageCredential, baseUrl?: string): UsageRequestDescriptor {
1868
+ return { provider, credential, baseUrl };
1869
+ }
1870
+
1871
+ #buildUsageRequestForOauth(
1872
+ provider: Provider,
1873
+ credential: OAuthCredential,
1874
+ baseUrl?: string,
1875
+ ): UsageRequestDescriptor {
1876
+ return this.#buildUsageRequest(provider, this.#buildUsageCredential(credential), baseUrl);
1877
+ }
1878
+
1879
+ #buildRefreshableOauthCredential(credential: UsageCredential): OAuthCredential | null {
1880
+ if (!credential.accessToken || !credential.refreshToken || credential.expiresAt === undefined) {
1881
+ return null;
1882
+ }
1883
+ return {
1884
+ type: "oauth",
1885
+ access: credential.accessToken,
1886
+ refresh: credential.refreshToken,
1887
+ expires: credential.expiresAt,
1888
+ accountId: credential.accountId,
1889
+ projectId: credential.projectId,
1890
+ email: credential.email,
1891
+ enterpriseUrl: credential.enterpriseUrl,
1892
+ };
1893
+ }
1894
+
1895
+ /**
1896
+ * Translate a refreshed {@link UsageCredential} into the public
1897
+ * {@link CompletionProbeCredential} shape. Returns `null` when the
1898
+ * credential lacks any usable bearer bytes (e.g. an API-key row with an
1899
+ * empty key, or an OAuth row that never had an `access` token written).
1900
+ */
1901
+ #buildCompletionProbeCredential(credential: UsageCredential): CompletionProbeCredential | null {
1902
+ if (credential.type === "api_key") {
1903
+ return credential.apiKey ? { type: "api_key", apiKey: credential.apiKey } : null;
1904
+ }
1905
+ if (!credential.accessToken) return null;
1906
+ return {
1907
+ type: "oauth",
1908
+ accessToken: credential.accessToken,
1909
+ refreshToken: credential.refreshToken,
1910
+ expiresAt: credential.expiresAt,
1911
+ accountId: credential.accountId,
1912
+ projectId: credential.projectId,
1913
+ email: credential.email,
1914
+ enterpriseUrl: credential.enterpriseUrl,
1915
+ };
1916
+ }
1917
+
1918
+ #mergeRefreshedUsageCredential(credential: UsageCredential, refreshed: OAuthCredentials): UsageCredential {
1919
+ return {
1920
+ ...credential,
1921
+ accessToken: refreshed.access,
1922
+ refreshToken: refreshed.refresh,
1923
+ expiresAt: refreshed.expires,
1924
+ accountId: refreshed.accountId ?? credential.accountId,
1925
+ projectId: refreshed.projectId ?? credential.projectId,
1926
+ email: refreshed.email ?? credential.email,
1927
+ enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
1928
+ };
1929
+ }
1930
+
1931
+ /**
1932
+ * Find the stored credential id matching a {@link UsageCredential} so the
1933
+ * refresh override can address the row. Mirrors the matching logic in
1934
+ * {@link AuthStorage.#persistRefreshedUsageCredential}.
1935
+ */
1936
+ #findStoredCredentialIdForUsageCredential(provider: Provider, previous: UsageCredential): number | undefined {
1937
+ const entries = this.#getStoredCredentials(provider);
1938
+ const match = entries.find(entry => {
1939
+ if (entry.credential.type !== "oauth") return false;
1940
+ if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
1941
+ if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
1942
+ return (
1943
+ entry.credential.accountId === previous.accountId &&
1944
+ entry.credential.email === previous.email &&
1945
+ entry.credential.projectId === previous.projectId
1946
+ );
1947
+ });
1948
+ return match?.id;
1949
+ }
1950
+
1951
+ #persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
1952
+ const entries = this.#getStoredCredentials(provider);
1953
+ const index = entries.findIndex(entry => {
1954
+ if (entry.credential.type !== "oauth") return false;
1955
+ if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
1956
+ if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
1957
+ return (
1958
+ entry.credential.accountId === previous.accountId &&
1959
+ entry.credential.email === previous.email &&
1960
+ entry.credential.projectId === previous.projectId
1961
+ );
1962
+ });
1963
+ if (index === -1) return;
1964
+ const existing = entries[index]!.credential;
1965
+ if (existing.type !== "oauth") return;
1966
+ this.#replaceCredentialAt(provider, index, {
1967
+ type: "oauth",
1968
+ access: next.accessToken ?? existing.access,
1969
+ refresh: next.refreshToken ?? existing.refresh,
1970
+ expires: next.expiresAt ?? existing.expires,
1971
+ accountId: next.accountId,
1972
+ projectId: next.projectId,
1973
+ email: next.email,
1974
+ enterpriseUrl: next.enterpriseUrl,
1975
+ });
1976
+ }
1977
+
1978
+ async #fetchUsageUncached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
1979
+ const resolver = this.#usageProviderResolver;
1980
+ if (!resolver) return null;
1981
+
1982
+ const providerImpl = resolver(request.provider);
1983
+ if (!providerImpl) return null;
1984
+
1985
+ const timeoutSignal =
1986
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
1987
+ ? AbortSignal.timeout(timeoutMs)
1988
+ : undefined;
1989
+ let params: UsageRequestDescriptor & { signal?: AbortSignal } = { ...request, signal: timeoutSignal };
1990
+
1991
+ if (
1992
+ request.credential.type === "oauth" &&
1993
+ request.credential.expiresAt !== undefined &&
1994
+ Date.now() + OAUTH_REFRESH_SKEW_MS >= request.credential.expiresAt
1995
+ ) {
1996
+ const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
1997
+ if (refreshableCredential) {
1998
+ try {
1999
+ const refreshableCredentialId = this.#findStoredCredentialIdForUsageCredential(
2000
+ request.provider,
2001
+ request.credential,
2002
+ );
2003
+ const refreshed = await this.#refreshOAuthCredential(
2004
+ request.provider,
2005
+ refreshableCredential,
2006
+ refreshableCredentialId,
2007
+ timeoutSignal,
2008
+ );
2009
+ const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
2010
+ this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
2011
+ params = {
2012
+ ...params,
2013
+ credential: refreshedCredential,
2014
+ };
2015
+ } catch (error) {
2016
+ const errorMsg = String(error);
2017
+ // Definitive failure (invalid_grant / 401 not from a network blip) means
2018
+ // the refresh token itself is dead — probing with the original credential
2019
+ // will 401, the catch below will return null, and #fetchUsageCached's
2020
+ // last-good fallback will surface yesterday's report indefinitely
2021
+ // (including its already-elapsed `resetsAt`). CAS-disable the row and
2022
+ // clear the cache so the credential drops out of the report instead of
2023
+ // freezing in place until the user notices and re-logs in.
2024
+ if (isDefinitiveOAuthFailure(errorMsg)) {
2025
+ const credentialId = this.#findStoredCredentialIdForUsageCredential(
2026
+ request.provider,
2027
+ request.credential,
2028
+ );
2029
+ if (credentialId !== undefined) {
2030
+ const entries = this.#getStoredCredentials(request.provider);
2031
+ const index = entries.findIndex(entry => entry.id === credentialId);
2032
+ if (index !== -1) {
2033
+ const disabled = this.#tryDisableCredentialAtIfMatches(
2034
+ request.provider,
2035
+ index,
2036
+ refreshableCredential,
2037
+ `oauth refresh failed during usage probe: ${errorMsg}`,
2038
+ );
2039
+ if (disabled) {
2040
+ this.#usageLogger?.warn(
2041
+ "Usage credential refresh failed definitively; credential disabled",
2042
+ { provider: request.provider, credentialId, error: errorMsg },
2043
+ );
2044
+ // Neutralize last-good for this cache key: write a null
2045
+ // entry with an immediately-elapsed expiry so a future
2046
+ // getStale lookup (e.g. on re-login under the same
2047
+ // account identity) can't replay the stale report.
2048
+ this.#usageCache.set(this.#buildUsageReportCacheKey(request), {
2049
+ value: null,
2050
+ expiresAt: 0,
2051
+ });
2052
+ return null;
2053
+ }
2054
+ }
2055
+ }
2056
+ }
2057
+ this.#usageLogger?.debug("Usage credential refresh failed, using original credential", {
2058
+ provider: request.provider,
2059
+ error: errorMsg,
2060
+ });
2061
+ }
2062
+ }
2063
+ }
2064
+
2065
+ if (providerImpl.supports && !providerImpl.supports(params)) return null;
2066
+
2067
+ try {
2068
+ return await providerImpl.fetchUsage(params, {
2069
+ fetch: this.#usageFetch,
2070
+ logger: this.#usageLogger,
2071
+ });
2072
+ } catch (error) {
2073
+ logger.debug("AuthStorage usage fetch failed", {
2074
+ provider: request.provider,
2075
+ error: String(error),
2076
+ });
2077
+ return null;
2078
+ }
2079
+ }
2080
+
2081
+ async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
2082
+ const cacheKey = this.#buildUsageReportCacheKey(request);
2083
+ const now = Date.now();
2084
+ const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
2085
+ // Fresh cache hit: return whatever's there (success or null fallback).
2086
+ if (cached && cached.expiresAt > now) {
2087
+ return cached.value;
2088
+ }
2089
+
2090
+ const inFlight = this.#usageRequestInFlight.get(cacheKey);
2091
+ if (inFlight) return inFlight;
2092
+
2093
+ const promise = (async () => {
2094
+ const report = await this.#fetchUsageUncached(request, timeoutMs);
2095
+ const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
2096
+ if (report !== null) {
2097
+ // Success: stagger per-credential cache expiry so all accounts don't
2098
+ // refresh in the same window — Anthropic / OpenAI rate-limit `/usage`
2099
+ // per source IP regardless of account, and synchronized 5-credential
2100
+ // fan-out trips 429s every cycle. With ±25% jitter on TTL the refresh
2101
+ // times decorrelate within a few cycles.
2102
+ this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
2103
+ return report;
2104
+ }
2105
+ // Failure: cache the LAST GOOD value (if any) with a short jittered TTL
2106
+ // so the credential cools down briefly without dropping out of the
2107
+ // report. If we never had a good value, return null this cycle and
2108
+ // don't write — let the next poll retry.
2109
+ const lastGood = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
2110
+ if (lastGood !== null) {
2111
+ const backoffJitter = USAGE_FAILURE_BACKOFF_MS * (Math.random() * 0.5 - 0.25);
2112
+ const coolDown = Date.now() + USAGE_FAILURE_BACKOFF_MS + backoffJitter;
2113
+ this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
2114
+ }
2115
+ return lastGood;
2116
+ })().finally(() => {
2117
+ this.#usageRequestInFlight.delete(cacheKey);
2118
+ });
2119
+
2120
+ this.#usageRequestInFlight.set(cacheKey, promise);
2121
+ return promise;
2122
+ }
2123
+
2124
+ ingestUsageHeaders(
2125
+ provider: Provider,
2126
+ headers: Record<string, string>,
2127
+ options?: { sessionId?: string; baseUrl?: string },
2128
+ ): boolean {
2129
+ if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false;
2130
+
2131
+ const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
2132
+ if (!credential) return false;
2133
+
2134
+ const cacheKey = this.#buildUsageReportCacheKey(
2135
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2136
+ );
2137
+ const now = Date.now();
2138
+ const last = this.#usageHeaderIngestAt.get(cacheKey);
2139
+ if (last !== undefined && now - last < USAGE_HEADER_INGEST_INTERVAL_MS) return false;
2140
+
2141
+ const report = this.#usageProviderResolver?.(provider)?.parseRateLimitHeaders?.(headers, now);
2142
+ if (!report) return false;
2143
+
2144
+ const prior = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value;
2145
+ let merged = report;
2146
+ if (prior && Array.isArray(prior.limits)) {
2147
+ const headerLimitsById = new Map(report.limits.map(limit => [limit.id, limit]));
2148
+ const limits: UsageLimit[] = [];
2149
+ for (const limit of prior.limits) {
2150
+ const replacement = headerLimitsById.get(limit.id);
2151
+ if (replacement) {
2152
+ limits.push(replacement);
2153
+ headerLimitsById.delete(limit.id);
2154
+ } else {
2155
+ limits.push(limit);
2156
+ }
2157
+ }
2158
+ for (const limit of headerLimitsById.values()) {
2159
+ limits.push(limit);
2160
+ }
2161
+ merged = {
2162
+ ...prior,
2163
+ fetchedAt: now,
2164
+ limits,
2165
+ metadata: {
2166
+ ...(prior.metadata ?? {}),
2167
+ headersUpdatedAt: now,
2168
+ },
2169
+ };
2170
+ }
2171
+
2172
+ this.#usageCache.set(cacheKey, { value: merged, expiresAt: now + USAGE_REPORT_TTL_MS });
2173
+ this.#usageHeaderIngestAt.set(cacheKey, now);
2174
+ return true;
2175
+ }
2176
+
2177
+ #collectUsageRequests(options?: {
2178
+ baseUrlResolver?: (provider: Provider) => string | undefined;
2179
+ }): UsageRequestDescriptor[] {
2180
+ const resolver = this.#usageProviderResolver;
2181
+ if (!resolver) return [];
2182
+
2183
+ const requests: UsageRequestDescriptor[] = [];
2184
+ const providers = new Set<string>([
2185
+ ...this.#data.keys(),
2186
+ ...DEFAULT_USAGE_PROVIDERS.map(provider => provider.id),
2187
+ ]);
2188
+
2189
+ for (const providerId of providers) {
2190
+ const provider = providerId as Provider;
2191
+ const providerImpl = resolver(provider);
2192
+ if (!providerImpl) continue;
2193
+ const baseUrl = options?.baseUrlResolver?.(provider);
2194
+ let entries = this.#getStoredCredentials(providerId);
2195
+ if (entries.length > 0) {
2196
+ const dedupedEntries = this.#pruneDuplicateStoredCredentials(providerId, entries);
2197
+ if (dedupedEntries.length !== entries.length) {
2198
+ this.#setStoredCredentials(providerId, dedupedEntries);
2199
+ }
2200
+ entries = dedupedEntries;
2201
+ }
2202
+
2203
+ if (entries.length === 0) {
2204
+ const runtimeKey = this.#runtimeOverrides.get(providerId);
2205
+ const envKey = getEnvApiKey(providerId);
2206
+ const apiKey = runtimeKey ?? envKey;
2207
+ if (!apiKey) continue;
2208
+ const request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl);
2209
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
2210
+ requests.push(request);
2211
+ continue;
2212
+ }
2213
+
2214
+ for (const entry of entries) {
2215
+ const credential = entry.credential;
2216
+ const request =
2217
+ credential.type === "api_key"
2218
+ ? this.#buildUsageRequest(provider, { type: "api_key", apiKey: credential.key }, baseUrl)
2219
+ : this.#buildUsageRequestForOauth(provider, credential, baseUrl);
2220
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
2221
+ requests.push(request);
2222
+ }
2223
+ }
2224
+
2225
+ return requests;
2226
+ }
2227
+
2228
+ #getUsageReportMetadataValue(report: UsageReport, key: string): string | undefined {
2229
+ const metadata = report.metadata;
2230
+ if (!metadata || typeof metadata !== "object") return undefined;
2231
+ const value = metadata[key];
2232
+ return typeof value === "string" ? value.trim() : undefined;
2233
+ }
2234
+
2235
+ #getUsageReportScopeAccountId(report: UsageReport): string | undefined {
2236
+ const ids = new Set<string>();
2237
+ for (const limit of report.limits) {
2238
+ const accountId = limit.scope.accountId?.trim();
2239
+ if (accountId) ids.add(accountId);
2240
+ }
2241
+ if (ids.size === 1) return [...ids][0];
2242
+ return undefined;
2243
+ }
2244
+
2245
+ #getUsageReportIdentifiers(report: UsageReport): string[] {
2246
+ const identifiers: string[] = [];
2247
+ const email = this.#getUsageReportMetadataValue(report, "email");
2248
+ if (email) identifiers.push(`email:${email.toLowerCase()}`);
2249
+ if (report.provider === "openai-codex" || report.provider === "anthropic") {
2250
+ return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
2251
+ }
2252
+ const accountId = this.#getUsageReportMetadataValue(report, "accountId");
2253
+ if (accountId) identifiers.push(`account:${accountId}`);
2254
+ const account = this.#getUsageReportMetadataValue(report, "account");
2255
+ if (account) identifiers.push(`account:${account}`);
2256
+ const user = this.#getUsageReportMetadataValue(report, "user");
2257
+ if (user) identifiers.push(`account:${user}`);
2258
+ const username = this.#getUsageReportMetadataValue(report, "username");
2259
+ if (username) identifiers.push(`account:${username}`);
2260
+ const scopeAccountId = this.#getUsageReportScopeAccountId(report);
2261
+ if (scopeAccountId) identifiers.push(`account:${scopeAccountId}`);
2262
+ return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
2263
+ }
2264
+
2265
+ #mergeUsageReportGroup(reports: UsageReport[]): UsageReport {
2266
+ if (reports.length === 1) return reports[0];
2267
+ const sorted = [...reports].sort((a, b) => {
2268
+ const limitDiff = b.limits.length - a.limits.length;
2269
+ if (limitDiff !== 0) return limitDiff;
2270
+ return (b.fetchedAt ?? 0) - (a.fetchedAt ?? 0);
2271
+ });
2272
+ const base = sorted[0];
2273
+ const mergedLimits = [...base.limits];
2274
+ const limitIds = new Set(mergedLimits.map(limit => limit.id));
2275
+ const mergedMetadata: Record<string, unknown> = { ...(base.metadata ?? {}) };
2276
+ let fetchedAt = base.fetchedAt;
2277
+
2278
+ for (const report of sorted.slice(1)) {
2279
+ fetchedAt = Math.max(fetchedAt, report.fetchedAt);
2280
+ for (const limit of report.limits) {
2281
+ if (!limitIds.has(limit.id)) {
2282
+ limitIds.add(limit.id);
2283
+ mergedLimits.push(limit);
2284
+ }
2285
+ }
2286
+ if (report.metadata) {
2287
+ for (const [key, value] of Object.entries(report.metadata)) {
2288
+ if (mergedMetadata[key] === undefined) {
2289
+ mergedMetadata[key] = value;
2290
+ }
2291
+ }
2292
+ }
2293
+ }
2294
+
2295
+ return {
2296
+ ...base,
2297
+ fetchedAt,
2298
+ limits: mergedLimits,
2299
+ metadata: Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
2300
+ };
2301
+ }
2302
+
2303
+ #dedupeUsageReports(reports: UsageReport[]): UsageReport[] {
2304
+ const groups: UsageReport[][] = [];
2305
+ const idToGroup = new Map<string, number>();
2306
+
2307
+ for (const report of reports) {
2308
+ const identifiers = this.#getUsageReportIdentifiers(report);
2309
+ let groupIndex: number | undefined;
2310
+ for (const identifier of identifiers) {
2311
+ const existing = idToGroup.get(identifier);
2312
+ if (existing !== undefined) {
2313
+ groupIndex = existing;
2314
+ break;
2315
+ }
2316
+ }
2317
+ if (groupIndex === undefined) {
2318
+ groupIndex = groups.length;
2319
+ groups.push([]);
2320
+ }
2321
+ groups[groupIndex].push(report);
2322
+ for (const identifier of identifiers) {
2323
+ idToGroup.set(identifier, groupIndex);
2324
+ }
2325
+ }
2326
+
2327
+ const deduped = groups.map(group => this.#mergeUsageReportGroup(group));
2328
+ if (deduped.length !== reports.length) {
2329
+ this.#usageLogger?.debug("Usage reports deduped", {
2330
+ before: reports.length,
2331
+ after: deduped.length,
2332
+ });
2333
+ }
2334
+ return deduped;
2335
+ }
2336
+
2337
+ #isUsageLimitExhausted(limit: UsageLimit): boolean {
2338
+ if (limit.status === "exhausted") return true;
2339
+ const amount = limit.amount;
2340
+ if (amount.usedFraction !== undefined && amount.usedFraction >= 1) return true;
2341
+ if (amount.remainingFraction !== undefined && amount.remainingFraction <= 0) return true;
2342
+ if (amount.used !== undefined && amount.limit !== undefined && amount.used >= amount.limit) return true;
2343
+ if (amount.remaining !== undefined && amount.remaining <= 0) return true;
2344
+ if (amount.unit === "percent" && amount.used !== undefined && amount.used >= 100) return true;
2345
+ return false;
2346
+ }
2347
+
2348
+ /** Returns true if usage indicates rate limit has been reached. */
2349
+ #isUsageLimitReached(report: UsageReport): boolean {
2350
+ return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
2351
+ }
2352
+
2353
+ /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
2354
+ #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
2355
+ const candidates: number[] = [];
2356
+ for (const limit of report.limits) {
2357
+ if (!this.#isUsageLimitExhausted(limit)) continue;
2358
+ const window = limit.window;
2359
+ if (window?.resetsAt && window.resetsAt > nowMs) {
2360
+ candidates.push(window.resetsAt);
2361
+ }
2362
+ }
2363
+ if (candidates.length === 0) return undefined;
2364
+ return Math.min(...candidates);
2365
+ }
2366
+
2367
+ async #getUsageReport(
2368
+ provider: Provider,
2369
+ credential: OAuthCredential,
2370
+ options?: { baseUrl?: string; timeoutMs?: number; signal?: AbortSignal },
2371
+ ): Promise<UsageReport | null> {
2372
+ // Store-level hook (e.g. `RemoteAuthCredentialStore`) is authoritative
2373
+ // when present: the broker already aggregates usage from a less-throttled
2374
+ // IP, and falling back to the local per-credential fetch would defeat the
2375
+ // whole point of routing through it.
2376
+ const storeHook = this.#store.getUsageReport?.bind(this.#store);
2377
+ if (storeHook) {
2378
+ return storeHook(provider, credential, options?.signal);
2379
+ }
2380
+ return this.#fetchUsageCached(
2381
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2382
+ options?.timeoutMs ?? this.#usageRequestTimeoutMs,
2383
+ );
2384
+ }
2385
+
2386
+ async fetchUsageReports(options?: {
2387
+ baseUrlResolver?: (provider: Provider) => string | undefined;
2388
+ /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
2389
+ signal?: AbortSignal;
2390
+ }): Promise<UsageReport[] | null> {
2391
+ // Caller override > store-level hook > local per-credential fan-out.
2392
+ // `RemoteAuthCredentialStore` implements the store hook so a gateway
2393
+ // backed by a broker automatically routes usage to the broker without
2394
+ // needing the caller to wire it explicitly.
2395
+ const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
2396
+ if (override) {
2397
+ // Reuse the in-flight map so concurrent callers (widget poll + format
2398
+ // dispatch + credential selection) coalesce into one upstream call.
2399
+ // Each caller's `signal` only cancels THAT caller's await; the
2400
+ // shared upstream fetch runs to completion so peers aren't punished.
2401
+ const OVERRIDE_KEY = "__override__";
2402
+ let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
2403
+ if (!shared) {
2404
+ // Don't forward the caller signal into the shared fetch — first caller's
2405
+ // abort would otherwise cancel the upstream for every peer.
2406
+ shared = override().finally(() => {
2407
+ this.#usageReportsInFlight.delete(OVERRIDE_KEY);
2408
+ });
2409
+ this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
2410
+ }
2411
+ return raceUsageWithSignal(shared, options?.signal);
2412
+ }
2413
+ if (!this.#usageProviderResolver) return null;
2414
+
2415
+ const requests = this.#collectUsageRequests(options);
2416
+ if (requests.length === 0) return [];
2417
+
2418
+ this.#usageLogger?.debug("Usage fetch requested", {
2419
+ providers: [...new Set(requests.map(request => request.provider))].sort(),
2420
+ });
2421
+
2422
+ // Per-credential caching with jitter lives in #fetchUsageCached, so we
2423
+ // don't store the aggregated result here — doing so locks the widget to
2424
+ // a single decorrelation snapshot for 30s, defeating the jitter (some
2425
+ // accounts can be missing from one fetch and present in the next; the
2426
+ // aggregate cache freezes whichever set landed first).
2427
+ const cacheKey = this.#buildUsageReportsCacheKey(requests);
2428
+
2429
+ const inFlight = this.#usageReportsInFlight.get(cacheKey);
2430
+ if (inFlight) return inFlight;
2431
+
2432
+ const promise = (async () => {
2433
+ for (const request of requests) {
2434
+ this.#usageLogger?.debug("Usage fetch queued", {
2435
+ provider: request.provider,
2436
+ credentialType: request.credential.type,
2437
+ baseUrl: request.baseUrl,
2438
+ accountId: request.credential.accountId,
2439
+ email: request.credential.email,
2440
+ });
2441
+ }
2442
+
2443
+ const results = await Promise.all(
2444
+ requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
2445
+ );
2446
+ const reports = results.filter((report): report is UsageReport => report !== null);
2447
+ const deduped = this.#dedupeUsageReports(reports);
2448
+ // no outer cache write — see comment above.
2449
+ const resolved = deduped;
2450
+ this.#usageLogger?.debug("Usage fetch resolved", {
2451
+ reports: resolved.map(report => {
2452
+ const accountLabel =
2453
+ this.#getUsageReportMetadataValue(report, "email") ??
2454
+ this.#getUsageReportMetadataValue(report, "accountId") ??
2455
+ this.#getUsageReportMetadataValue(report, "account") ??
2456
+ this.#getUsageReportMetadataValue(report, "user") ??
2457
+ this.#getUsageReportMetadataValue(report, "username") ??
2458
+ this.#getUsageReportScopeAccountId(report);
2459
+ return {
2460
+ provider: report.provider,
2461
+ limits: report.limits.length,
2462
+ account: accountLabel,
2463
+ };
2464
+ }),
2465
+ });
2466
+ return resolved;
2467
+ })().finally(() => {
2468
+ this.#usageReportsInFlight.delete(cacheKey);
2469
+ });
2470
+
2471
+ this.#usageReportsInFlight.set(cacheKey, promise);
2472
+ return promise;
2473
+ }
2474
+
2475
+ /**
2476
+ * Probe each stored credential against its provider's auth-verifying usage
2477
+ * endpoint and report per-credential auth health.
2478
+ *
2479
+ * Surfaces the identity of failing credentials so callers running a
2480
+ * multi-account pool (e.g. a broker-backed auth-gateway) can tell which
2481
+ * row is producing 401s. The probe mirrors the per-credential fan-out
2482
+ * inside {@link AuthStorage.fetchUsageReports} (OAuth refresh-on-expiry,
2483
+ * then `UsageProvider.fetchUsage`) but does NOT swallow errors — every
2484
+ * credential gets either `ok: true`, `ok: false` with `reason`, or
2485
+ * `ok: null` when no probe is configured for the provider.
2486
+ *
2487
+ * Iterates sequentially to avoid synchronized N-account fan-out that
2488
+ * upstream `/usage` rate limiters (per source IP) treat as a burst.
2489
+ *
2490
+ * Only inspects active rows from {@link AuthCredentialStore.listAuthCredentials};
2491
+ * soft-disabled rows are already known-bad and don't need a network probe.
2492
+ * Environment-variable API keys are not enumerated — the caller's intent
2493
+ * here is "which of my stored credentials is broken".
2494
+ *
2495
+ * Pass {@link CheckCredentialsOptions.completionProbe} to additionally
2496
+ * exercise each credential against the provider's chat-completion endpoint
2497
+ * (strict mode). The result lands on
2498
+ * {@link CredentialHealthResult.completion}; the usage `ok` field is
2499
+ * unchanged so callers can tell the two signals apart.
2500
+ */
2501
+ async checkCredentials(options?: CheckCredentialsOptions): Promise<CredentialHealthResult[]> {
2502
+ options?.signal?.throwIfAborted();
2503
+ const stored = this.#store.listAuthCredentials();
2504
+ const resolver = this.#usageProviderResolver;
2505
+ const timeoutMs = options?.timeoutMs ?? this.#usageRequestTimeoutMs;
2506
+ const completionProbe = options?.completionProbe;
2507
+ const completionTimeoutMs = options?.completionTimeoutMs ?? timeoutMs;
2508
+ const ctx: UsageFetchContext = { fetch: this.#usageFetch, logger: this.#usageLogger };
2509
+
2510
+ const results: CredentialHealthResult[] = [];
2511
+ for (const row of stored) {
2512
+ options?.signal?.throwIfAborted();
2513
+ const base: CredentialHealthResult = {
2514
+ id: row.id,
2515
+ provider: row.provider,
2516
+ type: row.credential.type,
2517
+ ok: null,
2518
+ };
2519
+ if (row.credential.type === "oauth") {
2520
+ if (row.credential.email) base.email = row.credential.email;
2521
+ if (row.credential.accountId) base.accountId = row.credential.accountId;
2522
+ if (row.credential.refresh === REMOTE_REFRESH_SENTINEL) base.remoteRefresh = true;
2523
+ }
2524
+
2525
+ const baseUrl = options?.baseUrlResolver?.(row.provider as Provider);
2526
+ const cred = row.credential;
2527
+ const initialRequest: UsageRequestDescriptor =
2528
+ cred.type === "api_key"
2529
+ ? this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey: cred.key }, baseUrl)
2530
+ : this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl);
2531
+
2532
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
2533
+ const probeSignal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
2534
+ let params: UsageFetchParams & { signal: AbortSignal } = { ...initialRequest, signal: probeSignal };
2535
+ let refreshError: string | undefined;
2536
+
2537
+ // Refresh expired OAuth before probing — without this an expired access
2538
+ // token reports as `false` when the credential is actually healthy
2539
+ // (broker would happily refresh it on the next real request). The
2540
+ // refreshed bytes feed BOTH the usage probe and the optional
2541
+ // completion probe; we do it up-front so it runs even when no
2542
+ // `UsageProvider` is registered for this provider.
2543
+ if (
2544
+ cred.type === "oauth" &&
2545
+ initialRequest.credential.type === "oauth" &&
2546
+ initialRequest.credential.expiresAt !== undefined &&
2547
+ Date.now() >= initialRequest.credential.expiresAt
2548
+ ) {
2549
+ const refreshable = this.#buildRefreshableOauthCredential(initialRequest.credential);
2550
+ if (refreshable) {
2551
+ try {
2552
+ const refreshed = await this.#refreshOAuthCredential(
2553
+ row.provider as Provider,
2554
+ refreshable,
2555
+ row.id,
2556
+ probeSignal,
2557
+ );
2558
+ const refreshedCredential = this.#mergeRefreshedUsageCredential(initialRequest.credential, refreshed);
2559
+ this.#persistRefreshedUsageCredential(
2560
+ row.provider as Provider,
2561
+ initialRequest.credential,
2562
+ refreshedCredential,
2563
+ );
2564
+ params = { ...params, credential: refreshedCredential };
2565
+ } catch (error) {
2566
+ refreshError = `oauth refresh failed: ${error instanceof Error ? error.message : String(error)}`;
2567
+ }
2568
+ }
2569
+ }
2570
+
2571
+ if (refreshError) {
2572
+ base.ok = false;
2573
+ base.reason = refreshError;
2574
+ // Refresh failed → the access token is unusable. Skip both probes;
2575
+ // they would only re-surface the same upstream failure.
2576
+ results.push(base);
2577
+ continue;
2578
+ }
2579
+
2580
+ const providerImpl = resolver?.(row.provider as Provider);
2581
+ if (!providerImpl) {
2582
+ base.reason = `no usage probe configured for provider ${row.provider}`;
2583
+ } else if (providerImpl.supports && !providerImpl.supports(initialRequest)) {
2584
+ base.reason = `usage probe does not support ${cred.type} credentials for ${row.provider}`;
2585
+ } else {
2586
+ try {
2587
+ const report = await providerImpl.fetchUsage(params, ctx);
2588
+ if (report === null) {
2589
+ base.reason = "usage probe returned no data for this credential";
2590
+ } else {
2591
+ base.ok = true;
2592
+ const accountId = this.#getUsageReportMetadataValue(report, "accountId");
2593
+ const email = this.#getUsageReportMetadataValue(report, "email");
2594
+ if (accountId) base.accountId = accountId;
2595
+ if (email) base.email = email;
2596
+ const { raw: _raw, ...trimmed } = report;
2597
+ base.report = trimmed;
2598
+ }
2599
+ } catch (error) {
2600
+ base.ok = false;
2601
+ base.reason = error instanceof Error ? error.message : String(error);
2602
+ }
2603
+ }
2604
+
2605
+ if (completionProbe) {
2606
+ const probeCred = this.#buildCompletionProbeCredential(params.credential);
2607
+ if (!probeCred) {
2608
+ base.completion = {
2609
+ ok: null,
2610
+ reason: `no bearer bytes available for ${row.credential.type} credential`,
2611
+ };
2612
+ } else {
2613
+ const completionTimeoutSignal = AbortSignal.timeout(completionTimeoutMs);
2614
+ const completionSignal = options?.signal
2615
+ ? AbortSignal.any([options.signal, completionTimeoutSignal])
2616
+ : completionTimeoutSignal;
2617
+ try {
2618
+ base.completion = await completionProbe({
2619
+ provider: row.provider as Provider,
2620
+ credentialId: row.id,
2621
+ credential: probeCred,
2622
+ signal: completionSignal,
2623
+ });
2624
+ } catch (error) {
2625
+ base.completion = {
2626
+ ok: false,
2627
+ reason: error instanceof Error ? error.message : String(error),
2628
+ };
2629
+ }
2630
+ }
2631
+ }
2632
+
2633
+ results.push(base);
2634
+ }
2635
+
2636
+ return results;
2637
+ }
2638
+
2639
+ /**
2640
+ * Marks the current session's credential as temporarily blocked due to usage limits.
2641
+ * Uses usage reports to determine accurate reset time when available.
2642
+ * Returns true if a credential was blocked, enabling automatic fallback to the next credential.
2643
+ */
2644
+ async markUsageLimitReached(
2645
+ provider: string,
2646
+ sessionId: string | undefined,
2647
+ options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
2648
+ ): Promise<boolean> {
2649
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
2650
+ if (!sessionCredential) return false;
2651
+
2652
+ const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
2653
+ const now = Date.now();
2654
+ let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
2655
+
2656
+ if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
2657
+ const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
2658
+ if (credential?.type === "oauth") {
2659
+ const report = await this.#getUsageReport(provider, credential, options);
2660
+ if (report && this.#isUsageLimitReached(report)) {
2661
+ const resetAtMs = this.#getUsageResetAtMs(report, Date.now());
2662
+ if (resetAtMs && resetAtMs > blockedUntil) {
2663
+ blockedUntil = resetAtMs;
2664
+ }
2665
+ }
2666
+ }
2667
+ }
2668
+
2669
+ this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
2670
+
2671
+ const remainingCredentials = this.#getCredentialsForProvider(provider)
2672
+ .map((credential, index) => ({ credential, index }))
2673
+ .filter(
2674
+ (entry): entry is { credential: AuthCredential; index: number } =>
2675
+ entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
2676
+ );
2677
+
2678
+ return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
2679
+ }
2680
+
2681
+ #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
2682
+ if (!window) return undefined;
2683
+ if (typeof window.resetsAt === "number" && Number.isFinite(window.resetsAt)) {
2684
+ return window.resetsAt;
2685
+ }
2686
+ return undefined;
2687
+ }
2688
+
2689
+ #normalizeUsageFraction(limit: UsageLimit | undefined): number {
2690
+ const usedFraction = limit?.amount.usedFraction;
2691
+ if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) {
2692
+ return 0.5;
2693
+ }
2694
+ return Math.min(Math.max(usedFraction, 0), 1);
2695
+ }
2696
+
2697
+ /** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
2698
+ #computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
2699
+ const usedFraction = this.#normalizeUsageFraction(limit);
2700
+ const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
2701
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
2702
+ return usedFraction;
2703
+ }
2704
+ const resetAt = this.#resolveWindowResetAt(limit?.window);
2705
+ if (!Number.isFinite(resetAt)) {
2706
+ return usedFraction;
2707
+ }
2708
+ const remainingWindowMs = (resetAt as number) - nowMs;
2709
+ const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
2710
+ const elapsedMs = durationMs - clampedRemainingWindowMs;
2711
+ if (elapsedMs <= 0) {
2712
+ return usedFraction;
2713
+ }
2714
+ const elapsedHours = elapsedMs / (60 * 60 * 1000);
2715
+ if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
2716
+ return usedFraction;
2717
+ }
2718
+ return usedFraction / elapsedHours;
2719
+ }
2720
+
2721
+ async #rankOAuthSelections(args: {
2722
+ providerKey: string;
2723
+ provider: string;
2724
+ order: number[];
2725
+ credentials: Array<{ credential: OAuthCredential; index: number }>;
2726
+ options?: AuthApiKeyOptions;
2727
+ strategy: CredentialRankingStrategy;
2728
+ }): Promise<
2729
+ Array<{
2730
+ selection: { credential: OAuthCredential; index: number };
2731
+ usage: UsageReport | null;
2732
+ usageChecked: boolean;
2733
+ }>
2734
+ > {
2735
+ const nowMs = Date.now();
2736
+ const { strategy } = args;
2737
+ const ranked: Array<{
2738
+ selection: { credential: OAuthCredential; index: number };
2739
+ usage: UsageReport | null;
2740
+ usageChecked: boolean;
2741
+ blocked: boolean;
2742
+ blockedUntil?: number;
2743
+ hasPriorityBoost: boolean;
2744
+ secondaryUsed: number;
2745
+ secondaryDrainRate: number;
2746
+ primaryUsed: number;
2747
+ primaryDrainRate: number;
2748
+ orderPos: number;
2749
+ }> = [];
2750
+ // Pre-fetch usage reports in parallel for non-blocked credentials.
2751
+ // Wrap with a timeout so slow/429'd fetches don't indefinitely block
2752
+ // credential selection — better to pick a credential without usage data
2753
+ // than to hang the agent waiting for rate-limited usage endpoints.
2754
+ const usageTimeout = Math.max(5000, this.#usageRequestTimeoutMs * 1.5);
2755
+ const usagePromise = Promise.all(
2756
+ args.order.map(async idx => {
2757
+ const selection = args.credentials[idx];
2758
+ if (!selection) return null;
2759
+ const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
2760
+ if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
2761
+ const usage = await this.#getUsageReport(args.provider, selection.credential, {
2762
+ ...args.options,
2763
+ timeoutMs: this.#usageRequestTimeoutMs,
2764
+ });
2765
+ return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
2766
+ }),
2767
+ );
2768
+ const timeoutSignal = Promise.withResolvers<null>();
2769
+ // `Bun.sleep` keeps the event loop alive even after Promise.race resolves,
2770
+ // which leaks a 7.5–15s timer per credential-selection call. Use an unref'd
2771
+ // timer so the timeout doesn't pin the process and clear it on the happy
2772
+ // path so memory drops immediately.
2773
+ const timer = setTimeout(() => timeoutSignal.resolve(null), usageTimeout);
2774
+ timer.unref?.();
2775
+ const usageResults = await Promise.race([usagePromise, timeoutSignal.promise]).then(result => {
2776
+ clearTimeout(timer);
2777
+ return (
2778
+ result ??
2779
+ args.order.map(idx => {
2780
+ const selection = args.credentials[idx];
2781
+ return selection ? { selection, usage: null, usageChecked: false, blockedUntil: undefined } : null;
2782
+ })
2783
+ );
2784
+ });
2785
+
2786
+ for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
2787
+ const result = usageResults[orderPos];
2788
+ if (!result) continue;
2789
+ const { selection, usage, usageChecked } = result;
2790
+ let { blockedUntil } = result;
2791
+ let blocked = blockedUntil !== undefined;
2792
+ if (!blocked && usage && this.#isUsageLimitReached(usage)) {
2793
+ const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
2794
+ blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
2795
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
2796
+ blocked = true;
2797
+ }
2798
+ const windows = usage ? strategy.findWindowLimits(usage) : undefined;
2799
+ const primary = windows?.primary;
2800
+ const secondary = windows?.secondary;
2801
+ const secondaryTarget = secondary ?? primary;
2802
+ ranked.push({
2803
+ selection,
2804
+ usage,
2805
+ usageChecked,
2806
+ blocked,
2807
+ blockedUntil,
2808
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
2809
+ secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
2810
+ secondaryDrainRate: this.#computeWindowDrainRate(
2811
+ secondaryTarget,
2812
+ nowMs,
2813
+ strategy.windowDefaults.secondaryMs,
2814
+ ),
2815
+ primaryUsed: this.#normalizeUsageFraction(primary),
2816
+ primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
2817
+ orderPos,
2818
+ });
2819
+ }
2820
+ ranked.sort((left, right) => {
2821
+ if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
2822
+ if (left.blocked && right.blocked) {
2823
+ const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
2824
+ const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
2825
+ if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
2826
+ return left.orderPos - right.orderPos;
2827
+ }
2828
+ if (requiresOpenAICodexProModel(args.provider, args.options?.modelId)) {
2829
+ const leftPlanPriority = getOpenAICodexPlanPriority(left.usage);
2830
+ const rightPlanPriority = getOpenAICodexPlanPriority(right.usage);
2831
+ if (leftPlanPriority !== rightPlanPriority) return leftPlanPriority - rightPlanPriority;
2832
+ }
2833
+ if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
2834
+ if (left.secondaryDrainRate !== right.secondaryDrainRate)
2835
+ return left.secondaryDrainRate - right.secondaryDrainRate;
2836
+ if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
2837
+ if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
2838
+ if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
2839
+ return left.orderPos - right.orderPos;
2840
+ });
2841
+ return ranked.map(candidate => ({
2842
+ selection: candidate.selection,
2843
+ usage: candidate.usage,
2844
+ usageChecked: candidate.usageChecked,
2845
+ }));
2846
+ }
2847
+
2848
+ /**
2849
+ * Resolves an OAuth credential, trying credentials in priority order.
2850
+ * Skips blocked credentials and checks usage limits for providers with usage data.
2851
+ * Falls back to earliest-unblocking credential if all are blocked.
2852
+ *
2853
+ * Returns both the API key bytes for outbound requests AND the refreshed
2854
+ * {@link OAuthCredential} so callers needing identity metadata (account id,
2855
+ * project id, etc.) do not have to dereference the snapshot themselves.
2856
+ */
2857
+ async #resolveOAuthSelection(
2858
+ provider: string,
2859
+ sessionId?: string,
2860
+ options?: AuthApiKeyOptions,
2861
+ ): Promise<OAuthResolutionResult | undefined> {
2862
+ const credentials = this.#getCredentialsForProvider(provider)
2863
+ .map((credential, index) => ({ credential, index }))
2864
+ .filter((entry): entry is { credential: OAuthCredential; index: number } => entry.credential.type === "oauth");
2865
+
2866
+ if (credentials.length === 0) return undefined;
2867
+
2868
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
2869
+ const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
2870
+ const strategy = this.#rankingStrategyResolver?.(provider);
2871
+ const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
2872
+ const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
2873
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
2874
+ const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
2875
+ // Skip ranking only when the session already has a working preferred credential — re-ranking
2876
+ // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
2877
+ // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
2878
+ // with the most headroom proactively and fall back intelligently when rate-limited.
2879
+ const sessionPreferredIsAvailable =
2880
+ sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
2881
+ const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
2882
+ const candidates = shouldRank
2883
+ ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy: strategy! })
2884
+ : order
2885
+ .map(idx => credentials[idx])
2886
+ .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
2887
+ .map(selection => ({ selection, usage: null, usageChecked: false }));
2888
+
2889
+ if (sessionPreferredIndex !== undefined && !requiresProModel) {
2890
+ const sessionPreferredCandidate = candidates.findIndex(
2891
+ candidate =>
2892
+ !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
2893
+ candidate.selection.index === sessionPreferredIndex,
2894
+ );
2895
+ if (sessionPreferredCandidate > 0) {
2896
+ const [preferred] = candidates.splice(sessionPreferredCandidate, 1);
2897
+ candidates.unshift(preferred);
2898
+ }
2899
+ }
2900
+ await Promise.all(
2901
+ candidates.map(async candidate => {
2902
+ if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
2903
+ const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
2904
+ if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
2905
+ candidate.selection.credential = latestCredential;
2906
+ return;
2907
+ }
2908
+ try {
2909
+ const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
2910
+ const refreshedCredentials = await this.#refreshOAuthCredential(
2911
+ provider,
2912
+ candidate.selection.credential,
2913
+ credentialId,
2914
+ options?.signal,
2915
+ );
2916
+ const updated: OAuthCredential = {
2917
+ ...candidate.selection.credential,
2918
+ ...refreshedCredentials,
2919
+ type: "oauth",
2920
+ };
2921
+ candidate.selection.credential = updated;
2922
+ this.#replaceCredentialAt(provider, candidate.selection.index, updated);
2923
+ } catch {}
2924
+ }),
2925
+ );
2926
+
2927
+ // Skip the Pro-plan filter when no candidate is confirmed Pro, so users with only
2928
+ // non-Pro accounts can still attempt Spark requests (e.g. trial/grandfathered access).
2929
+ const enforceProRequirement =
2930
+ requiresProModel && candidates.some(candidate => hasOpenAICodexProPlan(candidate.usage));
2931
+
2932
+ const fallback = candidates[0];
2933
+
2934
+ for (const candidate of candidates) {
2935
+ const resolved = await this.#tryOAuthCredential(
2936
+ provider,
2937
+ candidate.selection,
2938
+ providerKey,
2939
+ sessionId,
2940
+ options,
2941
+ {
2942
+ checkUsage,
2943
+ allowBlocked: false,
2944
+ prefetchedUsage: candidate.usage,
2945
+ usagePrechecked: candidate.usageChecked,
2946
+ enforceProRequirement,
2947
+ },
2948
+ );
2949
+ if (resolved) return resolved;
2950
+ }
2951
+
2952
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
2953
+ return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
2954
+ checkUsage,
2955
+ allowBlocked: true,
2956
+ prefetchedUsage: fallback.usage,
2957
+ usagePrechecked: fallback.usageChecked,
2958
+ enforceProRequirement,
2959
+ });
2960
+ }
2961
+
2962
+ return undefined;
2963
+ }
2964
+
2965
+ async #refreshOAuthCredential(
2966
+ provider: Provider,
2967
+ credential: OAuthCredential,
2968
+ credentialId: number | undefined,
2969
+ signal?: AbortSignal,
2970
+ ): Promise<OAuthCredentials> {
2971
+ if (credentialId !== undefined) {
2972
+ const existing = this.#oauthCredentialRefreshInFlight.get(credentialId);
2973
+ if (existing) return raceCredentialRefreshWithSignal(existing, signal);
2974
+ }
2975
+ if (Date.now() + OAUTH_REFRESH_SKEW_MS < credential.expires) return credential;
2976
+ if (credentialId === undefined) {
2977
+ return this.#refreshOAuthCredentialUnshared(provider, credential, undefined, signal);
2978
+ }
2979
+ const promise = this.#refreshOAuthCredentialUnshared(provider, credential, credentialId).finally(() => {
2980
+ this.#oauthCredentialRefreshInFlight.delete(credentialId);
2981
+ });
2982
+ this.#oauthCredentialRefreshInFlight.set(credentialId, promise);
2983
+ return raceCredentialRefreshWithSignal(promise, signal);
2984
+ }
2985
+
2986
+ async #refreshOAuthCredentialUnshared(
2987
+ provider: Provider,
2988
+ credential: OAuthCredential,
2989
+ credentialId: number | undefined,
2990
+ signal?: AbortSignal,
2991
+ ): Promise<OAuthCredentials> {
2992
+ let refreshPromise: Promise<OAuthCredentials>;
2993
+ // Caller override > store-level hook > local per-provider refresh.
2994
+ // `RemoteAuthCredentialStore` exposes the hook so a broker-backed gateway
2995
+ // routes refresh through the broker without explicit wiring.
2996
+ const storeRefresh = this.#store.refreshOAuthCredential?.bind(this.#store);
2997
+ const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
2998
+ if (overrideRefresh && credentialId !== undefined) {
2999
+ refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
3000
+ } else {
3001
+ const customProvider = getOAuthProvider(provider);
3002
+ if (customProvider) {
3003
+ if (!customProvider.refreshToken) {
3004
+ throw new Error(`OAuth provider "${provider}" does not support token refresh`);
3005
+ }
3006
+ refreshPromise = customProvider.refreshToken(credential);
3007
+ } else {
3008
+ refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
3009
+ }
3010
+ }
3011
+ // Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
3012
+ // Caller-driven abort jumps the gun on the timeout — the agent's ESC must
3013
+ // take priority over the floor timeout.
3014
+ let timeout: NodeJS.Timeout | undefined;
3015
+ let onAbort: (() => void) | undefined;
3016
+ const cancellation = Promise.withResolvers<never>();
3017
+ timeout = setTimeout(
3018
+ () => cancellation.reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
3019
+ DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
3020
+ );
3021
+ if (signal) {
3022
+ if (signal.aborted) {
3023
+ cancellation.reject(new Error("OAuth token refresh aborted by caller"));
3024
+ } else {
3025
+ onAbort = () => cancellation.reject(new Error("OAuth token refresh aborted by caller"));
3026
+ signal.addEventListener("abort", onAbort, { once: true });
3027
+ }
3028
+ }
3029
+ try {
3030
+ return await Promise.race([refreshPromise, cancellation.promise]);
3031
+ } finally {
3032
+ if (timeout) clearTimeout(timeout);
3033
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
3034
+ }
3035
+ }
3036
+
3037
+ async #prepareOAuthCredentialForRequest(
3038
+ provider: string,
3039
+ selection: { credential: OAuthCredential; index: number },
3040
+ options: AuthApiKeyOptions | undefined,
3041
+ ): Promise<boolean> {
3042
+ const prepare = this.#store.prepareForRequest?.bind(this.#store);
3043
+ if (!prepare) return true;
3044
+ const stored = this.#getStoredCredentials(provider);
3045
+ const selected = stored[selection.index];
3046
+ if (selected?.credential.type !== "oauth") return false;
3047
+
3048
+ const prepared = await prepare(selected.id, { signal: options?.signal });
3049
+ if (!prepared) return true;
3050
+ const latestRows = this.#store.listAuthCredentials(provider);
3051
+ this.#setStoredCredentials(
3052
+ provider,
3053
+ latestRows.map(row => ({ id: row.id, credential: row.credential })),
3054
+ );
3055
+ const latestIndex = latestRows.findIndex(row => row.id === selected.id);
3056
+ if (latestIndex === -1) return false;
3057
+ const latest = latestRows[latestIndex];
3058
+ if (latest?.credential.type !== "oauth") return false;
3059
+ selection.index = latestIndex;
3060
+ selection.credential = latest.credential;
3061
+ return true;
3062
+ }
3063
+
3064
+ /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
3065
+ async #tryOAuthCredential(
3066
+ provider: Provider,
3067
+ selection: { credential: OAuthCredential; index: number },
3068
+ providerKey: string,
3069
+ sessionId: string | undefined,
3070
+ options: AuthApiKeyOptions | undefined,
3071
+ usageOptions: {
3072
+ checkUsage: boolean;
3073
+ allowBlocked: boolean;
3074
+ prefetchedUsage?: UsageReport | null;
3075
+ usagePrechecked?: boolean;
3076
+ enforceProRequirement?: boolean;
3077
+ },
3078
+ ): Promise<OAuthResolutionResult | undefined> {
3079
+ const {
3080
+ checkUsage,
3081
+ allowBlocked,
3082
+ prefetchedUsage = null,
3083
+ usagePrechecked = false,
3084
+ enforceProRequirement,
3085
+ } = usageOptions;
3086
+ if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
3087
+ return undefined;
3088
+ }
3089
+
3090
+ if (!(await this.#prepareOAuthCredentialForRequest(provider, selection, options))) {
3091
+ return undefined;
3092
+ }
3093
+
3094
+ const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
3095
+ const applyProFilter = enforceProRequirement ?? requiresProModel;
3096
+ let usage: UsageReport | null = null;
3097
+ let usageChecked = false;
3098
+
3099
+ if ((checkUsage && !allowBlocked) || requiresProModel) {
3100
+ if (usagePrechecked) {
3101
+ usage = prefetchedUsage;
3102
+ usageChecked = true;
3103
+ } else {
3104
+ usage = await this.#getUsageReport(provider, selection.credential, {
3105
+ ...options,
3106
+ timeoutMs: this.#usageRequestTimeoutMs,
3107
+ });
3108
+ usageChecked = true;
3109
+ }
3110
+ if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3111
+ return undefined;
3112
+ }
3113
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
3114
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
3115
+ this.#markCredentialBlocked(
3116
+ providerKey,
3117
+ selection.index,
3118
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
3119
+ );
3120
+ return undefined;
3121
+ }
3122
+ }
3123
+
3124
+ try {
3125
+ let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
3126
+ const customProvider = getOAuthProvider(provider);
3127
+ if (customProvider) {
3128
+ const refreshedCredentials = await this.#refreshOAuthCredential(
3129
+ provider,
3130
+ selection.credential,
3131
+ this.#getStoredCredentials(provider)[selection.index]?.id,
3132
+ options?.signal,
3133
+ );
3134
+ const apiKey = customProvider.getApiKey
3135
+ ? customProvider.getApiKey(refreshedCredentials)
3136
+ : refreshedCredentials.access;
3137
+ result = { newCredentials: refreshedCredentials, apiKey };
3138
+ } else {
3139
+ // Refresh first through the broker-aware single-flighted machinery
3140
+ // so transient failures surface as network errors (5-min temp block)
3141
+ // instead of `getOAuthApiKey`'s "expired" precondition error, which
3142
+ // the definitive-failure regex below would otherwise classify as
3143
+ // auth failure and soft-disable a still-valid credential.
3144
+ const refreshedCredentials = await this.#refreshOAuthCredential(
3145
+ provider,
3146
+ selection.credential,
3147
+ this.#getStoredCredentials(provider)[selection.index]?.id,
3148
+ options?.signal,
3149
+ );
3150
+ const oauthCreds: Record<string, OAuthCredentials> = {
3151
+ [provider]: refreshedCredentials,
3152
+ };
3153
+ result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds);
3154
+ }
3155
+ if (!result) return undefined;
3156
+ const updated: OAuthCredential = {
3157
+ type: "oauth",
3158
+ access: result.newCredentials.access,
3159
+ refresh: result.newCredentials.refresh,
3160
+ expires: result.newCredentials.expires,
3161
+ accountId: result.newCredentials.accountId ?? selection.credential.accountId,
3162
+ email: result.newCredentials.email ?? selection.credential.email,
3163
+ projectId: result.newCredentials.projectId ?? selection.credential.projectId,
3164
+ enterpriseUrl: result.newCredentials.enterpriseUrl ?? selection.credential.enterpriseUrl,
3165
+ };
3166
+ this.#replaceCredentialAt(provider, selection.index, updated);
3167
+ if ((checkUsage && !allowBlocked) || requiresProModel) {
3168
+ const sameAccount = selection.credential.accountId === updated.accountId;
3169
+ if (!usageChecked || !sameAccount) {
3170
+ usage = await this.#getUsageReport(provider, updated, {
3171
+ ...options,
3172
+ timeoutMs: this.#usageRequestTimeoutMs,
3173
+ });
3174
+ usageChecked = true;
3175
+ }
3176
+ if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3177
+ return undefined;
3178
+ }
3179
+ if (checkUsage && !allowBlocked && usage && this.#isUsageLimitReached(usage)) {
3180
+ const resetAtMs = this.#getUsageResetAtMs(usage, Date.now());
3181
+ this.#markCredentialBlocked(
3182
+ providerKey,
3183
+ selection.index,
3184
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
3185
+ );
3186
+ return undefined;
3187
+ }
3188
+ }
3189
+ this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
3190
+ return { apiKey: result.apiKey, credential: updated };
3191
+ } catch (error) {
3192
+ const errorMsg = String(error);
3193
+ // Only remove credentials for definitive auth failures
3194
+ // Keep credentials for transient errors (network, 5xx) and block temporarily
3195
+ const isDefinitiveFailure = isDefinitiveOAuthFailure(errorMsg);
3196
+
3197
+ logger.warn("OAuth token refresh failed", {
3198
+ provider,
3199
+ index: selection.index,
3200
+ error: errorMsg,
3201
+ isDefinitiveFailure,
3202
+ });
3203
+
3204
+ if (isDefinitiveFailure) {
3205
+ // The credential at this index may have been rotated by another process between
3206
+ // our in-memory snapshot and the refresh attempt: Anthropic rotates refresh
3207
+ // tokens on every use, so the peer's success leaves our stored token invalid.
3208
+ // Re-read the row from disk before marking it disabled — if the persisted
3209
+ // refresh token has changed, the peer rotation succeeded and we should pick
3210
+ // up the new credential instead of soft-deleting the row that the peer just
3211
+ // updated.
3212
+ const credentialId = this.#getStoredCredentials(provider)[selection.index]?.id;
3213
+ if (credentialId !== undefined) {
3214
+ const latestRow = this.#store.listAuthCredentials(provider).find(row => row.id === credentialId);
3215
+ const latestCredential = latestRow?.credential;
3216
+ if (latestCredential?.type === "oauth" && latestCredential.refresh !== selection.credential.refresh) {
3217
+ logger.debug("OAuth refresh race detected; another process rotated token first", {
3218
+ provider,
3219
+ index: selection.index,
3220
+ credentialId,
3221
+ });
3222
+ await this.reload();
3223
+ return this.#resolveOAuthSelection(provider, sessionId, options);
3224
+ }
3225
+ }
3226
+ // Permanently disable invalid credentials with an explicit cause for inspection/debugging.
3227
+ // Use a CAS-style disable conditioned on the row still containing the stale credential
3228
+ // we tried to refresh, so a peer rotation that lands between the pre-check above and
3229
+ // this disable doesn't soft-delete the freshly-rotated row.
3230
+ const disabled = this.#tryDisableCredentialAtIfMatches(
3231
+ provider,
3232
+ selection.index,
3233
+ selection.credential,
3234
+ `oauth refresh failed: ${errorMsg}`,
3235
+ );
3236
+ if (!disabled) {
3237
+ logger.debug("OAuth refresh disable lost CAS; reloading after peer rotation", {
3238
+ provider,
3239
+ index: selection.index,
3240
+ });
3241
+ await this.reload();
3242
+ return this.#resolveOAuthSelection(provider, sessionId, options);
3243
+ }
3244
+ if (this.#getCredentialsForProvider(provider).some(credential => credential.type === "oauth")) {
3245
+ return this.#resolveOAuthSelection(provider, sessionId, options);
3246
+ }
3247
+ } else {
3248
+ // Block temporarily for transient failures (5 minutes)
3249
+ this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
3250
+ }
3251
+ }
3252
+
3253
+ return undefined;
3254
+ }
3255
+
3256
+ /**
3257
+ * Peek at API key for a provider without refreshing OAuth tokens.
3258
+ * Used for model discovery where we only need to know if credentials exist
3259
+ * and get a best-effort token. For GitHub Copilot we preserve enterprise
3260
+ * routing metadata so discovery can hit the correct host.
3261
+ */
3262
+ async peekApiKey(provider: string): Promise<string | undefined> {
3263
+ const runtimeKey = this.#runtimeOverrides.get(provider);
3264
+ if (runtimeKey) {
3265
+ return runtimeKey;
3266
+ }
3267
+
3268
+ const configKey = this.#configOverrides.get(provider);
3269
+ if (configKey) {
3270
+ return configKey;
3271
+ }
3272
+
3273
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key");
3274
+ if (apiKeySelection) {
3275
+ return this.#configValueResolver(apiKeySelection.credential.key);
3276
+ }
3277
+
3278
+ // Return current OAuth access token only if it is not already expired.
3279
+ const oauthSelection = this.#selectCredentialByType(provider, "oauth");
3280
+ if (oauthSelection) {
3281
+ const expiresAt = oauthSelection.credential.expires;
3282
+ if (Number.isFinite(expiresAt) && expiresAt > Date.now()) {
3283
+ if (provider === "github-copilot") {
3284
+ return JSON.stringify({
3285
+ token: oauthSelection.credential.access,
3286
+ enterpriseUrl: oauthSelection.credential.enterpriseUrl,
3287
+ });
3288
+ }
3289
+ return oauthSelection.credential.access;
3290
+ }
3291
+ }
3292
+
3293
+ const envKey = getEnvApiKey(provider);
3294
+ if (envKey) return envKey;
3295
+
3296
+ return this.#fallbackResolver?.(provider) ?? undefined;
3297
+ }
3298
+
3299
+ /**
3300
+ * Get API key for a provider.
3301
+ * Priority:
3302
+ * 1. Runtime override (CLI --api-key)
3303
+ * 2. Config override (models.yml `providers.<name>.apiKey`)
3304
+ * 3. API key from storage
3305
+ * 4. OAuth token from storage (auto-refreshed)
3306
+ * 5. Environment variable
3307
+ * 6. Fallback resolver (models.yml custom providers, last-resort)
3308
+ */
3309
+ async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
3310
+ // Runtime override takes highest priority
3311
+ const runtimeKey = this.#runtimeOverrides.get(provider);
3312
+ if (runtimeKey) {
3313
+ return runtimeKey;
3314
+ }
3315
+
3316
+ // Config override: explicit apiKey pinned in models.yml beats the broker's
3317
+ // OAuth credentials. The user redirected a provider at a custom baseUrl
3318
+ // (e.g. an auth-gateway) and supplied the bearer for that endpoint —
3319
+ // honor it instead of forwarding an upstream OAuth token that the proxy
3320
+ const configKey = this.#configOverrides.get(provider);
3321
+ if (configKey) {
3322
+ if (configKey === "auth-json") {
3323
+ logger.warn(
3324
+ 'AuthStorage.getApiKey: config override for provider is the deprecated "auth-json" sentinel. This will be sent as the literal API key and authentication will fail. Regenerate the provider with a real key in models.yml.',
3325
+ { provider },
3326
+ );
3327
+ }
3328
+ return configKey;
3329
+ }
3330
+
3331
+ const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
3332
+ if (apiKeySelection) {
3333
+ this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3334
+ return this.#configValueResolver(apiKeySelection.credential.key);
3335
+ }
3336
+
3337
+ const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
3338
+ if (oauthResolved) {
3339
+ return oauthResolved.apiKey;
3340
+ }
3341
+
3342
+ // Fall back to environment variable or custom resolver. If we reach here after
3343
+ // an OAuth miss, the session sticky (if any) is stale — the request will
3344
+ // authenticate via env/fallback, not OAuth, so clear the sticky now so that
3345
+ // getOAuthAccountId() correctly suppresses account_uuid for this session.
3346
+ if (sessionId) this.#sessionLastCredential.get(provider)?.delete(sessionId);
3347
+ const envKey = getEnvApiKey(provider);
3348
+ if (envKey) return envKey;
3349
+
3350
+ // Fall back to custom resolver (e.g., models.json custom providers)
3351
+ return this.#fallbackResolver?.(provider) ?? undefined;
3352
+ }
3353
+
3354
+ /**
3355
+ * Resolve the OAuth credential for `provider`, refreshing through the same
3356
+ * pipeline as {@link AuthStorage.getApiKey} but returning the refreshed
3357
+ * {@link OAuthAccess} (raw access token + identity metadata) instead of
3358
+ * the API-key bytes.
3359
+ *
3360
+ * Use this when the caller needs to inject identity headers alongside the
3361
+ * bearer (Codex `chatgpt-account-id`, Google `project`, GitHub
3362
+ * `enterpriseUrl`). For pure "give me the bytes for `Authorization`"
3363
+ * scenarios, prefer {@link AuthStorage.getApiKey}.
3364
+ *
3365
+ * Returns `undefined` when no OAuth credential is available, the
3366
+ * credential fails to refresh, or runtime/config overrides have replaced
3367
+ * OAuth with an explicit API key.
3368
+ */
3369
+ async getOAuthAccess(
3370
+ provider: string,
3371
+ sessionId?: string,
3372
+ options?: AuthApiKeyOptions,
3373
+ ): Promise<OAuthAccess | undefined> {
3374
+ // Runtime / config overrides intentionally short-circuit OAuth: when the
3375
+ // user has pinned an API key, they expect the OAuth identity to be
3376
+ // suppressed (same contract as `getOAuthAccountId`).
3377
+ if (this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
3378
+ return undefined;
3379
+ }
3380
+ const resolved = await this.#resolveOAuthSelection(provider, sessionId, options);
3381
+ if (!resolved) return undefined;
3382
+ const { credential } = resolved;
3383
+ return {
3384
+ accessToken: credential.access,
3385
+ accountId: credential.accountId,
3386
+ email: credential.email,
3387
+ projectId: credential.projectId,
3388
+ enterpriseUrl: credential.enterpriseUrl,
3389
+ };
3390
+ }
3391
+
3392
+ #extractStructuredApiKeyToken(apiKey: string): string | undefined {
3393
+ if (!apiKey.startsWith("{")) return undefined;
3394
+ try {
3395
+ const parsed = JSON.parse(apiKey) as { token?: unknown };
3396
+ return typeof parsed.token === "string" ? parsed.token : undefined;
3397
+ } catch {
3398
+ return undefined;
3399
+ }
3400
+ }
3401
+
3402
+ async #credentialMatchesApiKey(credential: AuthCredential, apiKey: string): Promise<boolean> {
3403
+ if (credential.type === "api_key") {
3404
+ return (await this.#configValueResolver(credential.key)) === apiKey;
3405
+ }
3406
+ if (credential.access === apiKey) return true;
3407
+ return this.#extractStructuredApiKeyToken(apiKey) === credential.access;
3408
+ }
3409
+
3410
+ async invalidateCredentialMatching(
3411
+ provider: string,
3412
+ apiKey: string,
3413
+ options?: InvalidateCredentialMatchingOptions,
3414
+ ): Promise<boolean>;
3415
+ async invalidateCredentialMatching(provider: string, apiKey: string, signal?: AbortSignal): Promise<boolean>;
3416
+ async invalidateCredentialMatching(
3417
+ provider: string,
3418
+ apiKey: string,
3419
+ optionsOrSignal?: InvalidateCredentialMatchingOptions | AbortSignal,
3420
+ ): Promise<boolean> {
3421
+ const signal = isAbortSignalOption(optionsOrSignal) ? optionsOrSignal : optionsOrSignal?.signal;
3422
+ const sessionId = isAbortSignalOption(optionsOrSignal) ? undefined : optionsOrSignal?.sessionId;
3423
+ const stored = this.#getStoredCredentials(provider);
3424
+ let matched: { id: number; type: AuthCredential["type"]; index: number } | undefined;
3425
+ for (let index = 0; index < stored.length; index++) {
3426
+ const entry = stored[index];
3427
+ if (entry && (await this.#credentialMatchesApiKey(entry.credential, apiKey))) {
3428
+ matched = { id: entry.id, type: entry.credential.type, index };
3429
+ break;
3430
+ }
3431
+ }
3432
+
3433
+ if (!matched) {
3434
+ await this.reload();
3435
+ return false;
3436
+ }
3437
+
3438
+ this.#clearSessionCredential(provider, sessionId);
3439
+ this.#markCredentialBlocked(
3440
+ this.#getProviderTypeKey(provider, matched.type),
3441
+ matched.index,
3442
+ Date.now() + AuthStorage.#defaultBackoffMs,
3443
+ );
3444
+
3445
+ const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
3446
+ if (markSuspect) {
3447
+ await markSuspect(matched.id, { signal });
3448
+ } else {
3449
+ await this.reload();
3450
+ }
3451
+
3452
+ const latestRows = this.#store.listAuthCredentials(provider);
3453
+ this.#setStoredCredentials(
3454
+ provider,
3455
+ latestRows.map(row => ({ id: row.id, credential: row.credential })),
3456
+ );
3457
+ return true;
3458
+ }
3459
+
3460
+ // ─── Auth Broker integration ────────────────────────────────────────────
3461
+
3462
+ /**
3463
+ * Build a redacted snapshot of all loaded credentials for the auth-broker
3464
+ * wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
3465
+ * so clients never see the actual refresh token.
3466
+ *
3467
+ * Callers must {@link AuthStorage.reload} first when serving a stale snapshot
3468
+ * (the broker server's HTTP handler does this).
3469
+ */
3470
+ exportSnapshot(): AuthCredentialSnapshot {
3471
+ const entries: AuthCredentialSnapshotEntry[] = [];
3472
+ for (const [provider, stored] of this.#data) {
3473
+ for (const entry of stored) {
3474
+ const credential = entry.credential;
3475
+ const redacted: SnapshotCredential =
3476
+ credential.type === "api_key" ? credential : { ...credential, refresh: REMOTE_REFRESH_SENTINEL };
3477
+ entries.push({
3478
+ id: entry.id,
3479
+ provider,
3480
+ credential: redacted,
3481
+ identityKey: resolveCredentialIdentityKey(provider, credential),
3482
+ });
3483
+ }
3484
+ }
3485
+ return { generation: this.#generation, generatedAt: Date.now(), credentials: entries };
3486
+ }
3487
+
3488
+ /**
3489
+ * Refresh the OAuth credential with the given id through a per-credential
3490
+ * single-flight. Concurrent callers for the same row await the same upstream
3491
+ * refresh attempt, which is required for providers that rotate refresh tokens
3492
+ * on every successful refresh.
3493
+ */
3494
+ async refreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3495
+ const existing = this.#oauthRefreshInFlight.get(id);
3496
+ if (existing) return raceCredentialRefreshWithSignal(existing, signal);
3497
+
3498
+ const promise = (async () => {
3499
+ this.#bumpGeneration("credential-refresh-start");
3500
+ try {
3501
+ return await this.#forceRefreshCredentialByIdUnshared(id, signal);
3502
+ } catch (error) {
3503
+ this.#bumpGeneration("credential-refresh-failure");
3504
+ throw error;
3505
+ } finally {
3506
+ this.#oauthRefreshInFlight.delete(id);
3507
+ }
3508
+ })();
3509
+ this.#oauthRefreshInFlight.set(id, promise);
3510
+ return raceCredentialRefreshWithSignal(promise, signal);
3511
+ }
3512
+
3513
+ /**
3514
+ * Force-refresh the OAuth credential with the given id, bypassing the
3515
+ * not-yet-expired guard. Used by the auth-broker server to honour
3516
+ * `POST /v1/credential/:id/refresh`.
3517
+ *
3518
+ * Returns the redacted snapshot entry for the refreshed row.
3519
+ * Throws when no OAuth credential with that id is loaded.
3520
+ */
3521
+ async forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3522
+ return this.refreshCredentialById(id, signal);
3523
+ }
3524
+
3525
+ async #forceRefreshCredentialByIdUnshared(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
3526
+ for (const [provider, entries] of this.#data) {
3527
+ const index = entries.findIndex(entry => entry.id === id);
3528
+ if (index === -1) continue;
3529
+ const target = entries[index];
3530
+ if (target.credential.type !== "oauth") {
3531
+ throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
3532
+ }
3533
+ // Pass a clone with expires=0 so the cached not-yet-expired short-circuit
3534
+ // in #refreshOAuthCredential doesn't suppress the requested refresh.
3535
+ const stale: OAuthCredential = { ...target.credential, expires: 0 };
3536
+ const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
3537
+ const updated: OAuthCredential = {
3538
+ type: "oauth",
3539
+ access: refreshed.access,
3540
+ refresh: refreshed.refresh,
3541
+ expires: refreshed.expires,
3542
+ accountId: refreshed.accountId ?? target.credential.accountId,
3543
+ email: refreshed.email ?? target.credential.email,
3544
+ projectId: refreshed.projectId ?? target.credential.projectId,
3545
+ enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
3546
+ };
3547
+ this.#replaceCredentialAt(provider, index, updated);
3548
+ return {
3549
+ id,
3550
+ provider,
3551
+ credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
3552
+ identityKey: resolveCredentialIdentityKey(provider, updated),
3553
+ };
3554
+ }
3555
+ throw new Error(`No credential with id=${id}`);
3556
+ }
3557
+
3558
+ /**
3559
+ * Disable the credential with the given id and emit a
3560
+ * {@link CredentialDisabledEvent}. Used by the auth-broker server to honour
3561
+ * `POST /v1/credential/:id/disable`. Returns `false` when no such row exists.
3562
+ */
3563
+ disableCredentialById(id: number, disabledCause: string): boolean {
3564
+ for (const [provider, entries] of this.#data) {
3565
+ const index = entries.findIndex(entry => entry.id === id);
3566
+ if (index === -1) continue;
3567
+ this.#store.deleteAuthCredential(id, disabledCause);
3568
+ const next = entries.filter((_value, idx) => idx !== index);
3569
+ this.#setStoredCredentials(provider, next);
3570
+ this.#resetProviderAssignments(provider);
3571
+ this.#emitCredentialDisabled({ provider, disabledCause });
3572
+ return true;
3573
+ }
3574
+ return false;
3575
+ }
3576
+
3577
+ /**
3578
+ * Upsert a credential into the underlying store, refresh the in-memory
3579
+ * snapshot, and return the redacted snapshot entries for the provider.
3580
+ *
3581
+ * Used by the auth-broker server to honour `POST /v1/credential`. The
3582
+ * persistence layer (`SqliteAuthCredentialStore.upsertAuthCredentialForProvider`)
3583
+ * does identity-key matching, so re-uploading the same email/account replaces
3584
+ * the existing row instead of inserting a duplicate.
3585
+ */
3586
+ upsertCredential(provider: string, credential: AuthCredential): AuthCredentialSnapshotEntry[] {
3587
+ const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
3588
+ this.#setStoredCredentials(
3589
+ provider,
3590
+ stored.map(entry => ({ id: entry.id, credential: entry.credential })),
3591
+ );
3592
+ this.#resetProviderAssignments(provider);
3593
+ return stored.map(entry => {
3594
+ const persisted = entry.credential;
3595
+ const redacted: SnapshotCredential =
3596
+ persisted.type === "api_key" ? persisted : { ...persisted, refresh: REMOTE_REFRESH_SENTINEL };
3597
+ return {
3598
+ id: entry.id,
3599
+ provider: entry.provider,
3600
+ credential: redacted,
3601
+ identityKey: resolveCredentialIdentityKey(provider, persisted),
3602
+ };
3603
+ });
3604
+ }
3605
+
3606
+ /**
3607
+ * Describe where the active credential for a provider came from.
3608
+ *
3609
+ * Surfaces four layers, highest precedence first:
3610
+ * 1. Runtime override (`--api-key`).
3611
+ * 2. Config override (`models.yml` `providers.<name>.apiKey`).
3612
+ * 3. Stored credential (the one this session is currently sticky to, or the
3613
+ * one round-robin would pick next when no session id is supplied).
3614
+ * 4. Env var / fallback resolver — when no stored credential exists.
3615
+ *
3616
+ * The string is purely informational; consumers must not parse it.
3617
+ */
3618
+ describeCredentialSource(provider: string, sessionId?: string): string | undefined {
3619
+ if (this.#runtimeOverrides.has(provider)) {
3620
+ return "runtime override (--api-key)";
3621
+ }
3622
+ if (this.#configOverrides.has(provider)) {
3623
+ return "config override (models.yml)";
3624
+ }
3625
+
3626
+ const baseLabel = this.#sourceLabel ?? "local store";
3627
+ const stored = this.#getStoredCredentials(provider);
3628
+ if (stored.length === 0) {
3629
+ if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
3630
+ if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
3631
+ return undefined;
3632
+ }
3633
+
3634
+ const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
3635
+ // Same selection logic as #selectCredentialByType for "no session" lookups: prefer
3636
+ // the type with stored credentials, lean OAuth before api_key. We don't run the
3637
+ // full round-robin here because describing the source shouldn't advance the index.
3638
+ const preferredType: AuthCredential["type"] =
3639
+ session?.type ?? (stored.some(entry => entry.credential.type === "oauth") ? "oauth" : "api_key");
3640
+ const typed = stored
3641
+ .map((entry, index) => ({ entry, index }))
3642
+ .filter(({ entry }) => entry.credential.type === preferredType);
3643
+ if (typed.length === 0) return baseLabel;
3644
+ const index = session?.index ?? typed[0].index;
3645
+ const chosen = stored[index] ?? typed[0].entry;
3646
+ const credential = chosen.credential;
3647
+ const identity =
3648
+ credential.type === "oauth"
3649
+ ? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
3650
+ : `cred ${chosen.id}`;
3651
+ return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
3652
+ }
3653
+ }
3654
+
3655
+ // ─────────────────────────────────────────────────────────────────────────────
3656
+ // SqliteAuthCredentialStore
3657
+ // ─────────────────────────────────────────────────────────────────────────────
3658
+
3659
+ /** Row shape for auth_credentials table queries */
3660
+ type AuthRow = {
3661
+ id: number;
3662
+ provider: string;
3663
+ credential_type: string;
3664
+ data: string;
3665
+ disabled_cause: string | null;
3666
+ identity_key: string | null;
3667
+ };
3668
+
3669
+ type SerializedCredentialRecord = {
3670
+ credentialType: AuthCredential["type"];
3671
+ data: string;
3672
+ identityKey: string | null;
3673
+ };
3674
+
3675
+ const AUTH_SCHEMA_VERSION = 4;
3676
+ const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
3677
+
3678
+ function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
3679
+ const normalized = accountId?.trim();
3680
+ return normalized && normalized.length > 0 ? normalized : null;
3681
+ }
3682
+
3683
+ function normalizeStoredEmail(email: string | null | undefined): string | null {
3684
+ const normalized = email?.trim().toLowerCase();
3685
+ return normalized && normalized.length > 0 ? normalized : null;
3686
+ }
3687
+
3688
+ function normalizeStoredIdentityKey(identityKey: string | null | undefined): string | null {
3689
+ const normalized = identityKey?.trim();
3690
+ return normalized && normalized.length > 0 ? normalized : null;
3691
+ }
3692
+
3693
+ function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
3694
+ if (credential.type === "api_key") {
3695
+ return {
3696
+ credentialType: "api_key",
3697
+ data: JSON.stringify({ key: credential.key }),
3698
+ identityKey: null,
3699
+ };
3700
+ }
3701
+ if (credential.type === "oauth") {
3702
+ const { type: _type, ...rest } = credential;
3703
+ return {
3704
+ credentialType: "oauth",
3705
+ data: JSON.stringify(rest),
3706
+ identityKey: resolveCredentialIdentityKey(provider, credential),
3707
+ };
3708
+ }
3709
+ return null;
3710
+ }
3711
+
3712
+ function deserializeCredential(row: AuthRow): AuthCredential | null {
3713
+ let parsed: unknown;
3714
+ try {
3715
+ parsed = JSON.parse(row.data);
3716
+ } catch {
3717
+ return null;
3718
+ }
3719
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3720
+ return null;
3721
+ }
3722
+ if (row.credential_type === "api_key") {
3723
+ const data = parsed as Record<string, unknown>;
3724
+ if (typeof data.key === "string") {
3725
+ return { type: "api_key", key: data.key };
3726
+ }
3727
+ }
3728
+ if (row.credential_type === "oauth") {
3729
+ return { type: "oauth", ...(parsed as Record<string, unknown>) } as AuthCredential;
3730
+ }
3731
+ return null;
3732
+ }
3733
+
3734
+ function normalizeDisabledCause(disabledCause: string): string {
3735
+ const normalized = disabledCause.trim();
3736
+ return normalized.length > 0 ? normalized : "disabled";
3737
+ }
3738
+
3739
+ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): StoredAuthCredential {
3740
+ return { id: row.id, provider: row.provider, credential, disabledCause: row.disabled_cause };
3741
+ }
3742
+
3743
+ function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
3744
+ const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
3745
+ if ((provider === "openai-codex" || provider === "anthropic") && emailIdentifier) return emailIdentifier;
3746
+ const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
3747
+ if (accountIdentifier) return accountIdentifier;
3748
+ if (emailIdentifier) return emailIdentifier;
3749
+ return null;
3750
+ }
3751
+
3752
+ function resolveCredentialIdentityKey(provider: string, credential: AuthCredential): string | null {
3753
+ if (credential.type === "api_key") return null;
3754
+ return resolveProviderCredentialIdentityKey(provider, extractOAuthCredentialIdentifiers(credential));
3755
+ }
3756
+
3757
+ function resolveRowCredentialIdentityKey(provider: string, row: AuthRow): string | null {
3758
+ const identityKey = normalizeStoredIdentityKey(row.identity_key);
3759
+ if (identityKey) return identityKey;
3760
+ const credential = deserializeCredential(row);
3761
+ return credential?.type === "oauth" ? resolveCredentialIdentityKey(provider, credential) : null;
3762
+ }
3763
+
3764
+ function matchesReplacementCredential(
3765
+ provider: string,
3766
+ existing: AuthCredential | null,
3767
+ existingIdentityKey: string | null,
3768
+ incoming: AuthCredential,
3769
+ ): boolean {
3770
+ if (!existing || existing.type !== incoming.type) return false;
3771
+ if (incoming.type === "api_key") {
3772
+ return existing.type === "api_key" && existing.key === incoming.key;
3773
+ }
3774
+ const incomingIdentityKey = resolveCredentialIdentityKey(provider, incoming);
3775
+ return incomingIdentityKey !== null && incomingIdentityKey === existingIdentityKey;
3776
+ }
3777
+
3778
+ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[] {
3779
+ const identifiers = new Set<string>();
3780
+ const accountId = normalizeStoredAccountId(credential.accountId);
3781
+ if (accountId) identifiers.add(`account:${accountId}`);
3782
+ const email = normalizeStoredEmail(credential.email);
3783
+ if (email) identifiers.add(`email:${email}`);
3784
+ const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
3785
+ for (const identifier of accessIdentifiers) {
3786
+ identifiers.add(identifier);
3787
+ }
3788
+ const refreshIdentifiers = extractOAuthTokenIdentifiers(credential.refresh) ?? [];
3789
+ for (const identifier of refreshIdentifiers) {
3790
+ identifiers.add(identifier);
3791
+ }
3792
+ return [...identifiers];
3793
+ }
3794
+
3795
+ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | undefined {
3796
+ if (!token) return undefined;
3797
+ const parts = token.split(".");
3798
+ if (parts.length !== 3) return undefined;
3799
+ try {
3800
+ const payload = JSON.parse(
3801
+ new TextDecoder("utf-8").decode(Uint8Array.fromBase64(parts[1], { alphabet: "base64url" })),
3802
+ ) as Record<string, unknown>;
3803
+ const identifiers = new Set<string>();
3804
+ const directEmail = normalizeStoredEmail(typeof payload.email === "string" ? payload.email : undefined);
3805
+ if (directEmail) identifiers.add(`email:${directEmail}`);
3806
+ const openAiProfile = payload["https://api.openai.com/profile"];
3807
+ if (typeof openAiProfile === "object" && openAiProfile !== null && !Array.isArray(openAiProfile)) {
3808
+ const claimEmail = normalizeStoredEmail(
3809
+ (openAiProfile as Record<string, unknown>).email as string | undefined,
3810
+ );
3811
+ if (claimEmail) identifiers.add(`email:${claimEmail}`);
3812
+ }
3813
+ const openAiAuth = payload["https://api.openai.com/auth"];
3814
+ const authClaims =
3815
+ typeof openAiAuth === "object" && openAiAuth !== null && !Array.isArray(openAiAuth)
3816
+ ? (openAiAuth as Record<string, unknown>)
3817
+ : undefined;
3818
+ const accountId = normalizeStoredAccountId(
3819
+ typeof payload.account_id === "string"
3820
+ ? payload.account_id
3821
+ : typeof payload.accountId === "string"
3822
+ ? payload.accountId
3823
+ : typeof payload.user_id === "string"
3824
+ ? payload.user_id
3825
+ : typeof payload.sub === "string"
3826
+ ? payload.sub
3827
+ : typeof authClaims?.chatgpt_account_id === "string"
3828
+ ? authClaims.chatgpt_account_id
3829
+ : undefined,
3830
+ );
3831
+ if (accountId) identifiers.add(`account:${accountId}`);
3832
+ return identifiers.size > 0 ? [...identifiers] : undefined;
3833
+ } catch {
3834
+ return undefined;
3835
+ }
3836
+ }
3837
+ /**
3838
+ * Default SQLite-backed implementation of {@link AuthCredentialStore}.
3839
+ *
3840
+ * Used by the aery-ai CLI and as the default store for `AuthStorage.create()`.
3841
+ * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
3842
+ * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
3843
+ * without going through `AuthStorage`.
3844
+ */
3845
+ export class SqliteAuthCredentialStore implements AuthCredentialStore {
3846
+ #db: Database;
3847
+ #listActiveStmt: Statement;
3848
+ #listActiveByProviderStmt: Statement;
3849
+ #listDisabledByProviderStmt: Statement;
3850
+ #insertStmt: Statement;
3851
+ #updateStmt: Statement;
3852
+ #deleteStmt: Statement;
3853
+ #deleteIfMatchesStmt: Statement;
3854
+ #deleteByProviderStmt: Statement;
3855
+ #hardDeleteStmt: Statement;
3856
+ #getCacheStmt: Statement;
3857
+ #getCacheIncludingExpiredStmt: Statement;
3858
+ #upsertCacheStmt: Statement;
3859
+ #deleteExpiredCacheStmt: Statement;
3860
+ #closed = false;
3861
+
3862
+ constructor(db: Database) {
3863
+ this.#db = db;
3864
+ this.#initializeSchema();
3865
+
3866
+ this.#listActiveStmt = this.#db.prepare(
3867
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
3868
+ );
3869
+ this.#listActiveByProviderStmt = this.#db.prepare(
3870
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NULL ORDER BY id ASC",
3871
+ );
3872
+ this.#listDisabledByProviderStmt = this.#db.prepare(
3873
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE provider = ? AND disabled_cause IS NOT NULL ORDER BY id ASC",
3874
+ );
3875
+ this.#insertStmt = this.#db.prepare(
3876
+ `INSERT INTO auth_credentials (provider, credential_type, data, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH}, ${SQLITE_NOW_EPOCH}) RETURNING id`,
3877
+ );
3878
+ this.#updateStmt = this.#db.prepare(
3879
+ `UPDATE auth_credentials SET credential_type = ?, data = ?, identity_key = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
3880
+ );
3881
+ this.#deleteStmt = this.#db.prepare(
3882
+ `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ?`,
3883
+ );
3884
+ this.#deleteIfMatchesStmt = this.#db.prepare(
3885
+ `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE id = ? AND data = ? AND disabled_cause IS NULL`,
3886
+ );
3887
+ this.#deleteByProviderStmt = this.#db.prepare(
3888
+ `UPDATE auth_credentials SET disabled_cause = ?, updated_at = ${SQLITE_NOW_EPOCH} WHERE provider = ? AND disabled_cause IS NULL`,
3889
+ );
3890
+ this.#hardDeleteStmt = this.#db.prepare("DELETE FROM auth_credentials WHERE id = ?");
3891
+ this.#getCacheStmt = this.#db.prepare(
3892
+ `SELECT value FROM cache WHERE key = ? AND expires_at > ${SQLITE_NOW_EPOCH}`,
3893
+ );
3894
+ this.#getCacheIncludingExpiredStmt = this.#db.prepare("SELECT value FROM cache WHERE key = ?");
3895
+ this.#upsertCacheStmt = this.#db.prepare(
3896
+ "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
3897
+ );
3898
+ this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
3899
+ }
3900
+
3901
+ static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
3902
+ const dir = path.dirname(dbPath);
3903
+ const dirExists = await fs
3904
+ .stat(dir)
3905
+ .then(s => s.isDirectory())
3906
+ .catch(() => false);
3907
+ if (!dirExists) {
3908
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
3909
+ }
3910
+
3911
+ const db = new Database(dbPath);
3912
+ try {
3913
+ await fs.chmod(dbPath, 0o600);
3914
+ } catch {
3915
+ // Ignore chmod failures (e.g., Windows)
3916
+ }
3917
+
3918
+ return new SqliteAuthCredentialStore(db);
3919
+ }
3920
+
3921
+ #initializeSchema(): void {
3922
+ this.#db.run(`
3923
+ PRAGMA journal_mode=WAL;
3924
+ PRAGMA synchronous=NORMAL;
3925
+ PRAGMA busy_timeout=5000;
3926
+ CREATE TABLE IF NOT EXISTS auth_schema_version (
3927
+ id INTEGER PRIMARY KEY CHECK (id = 1),
3928
+ version INTEGER NOT NULL
3929
+ );
3930
+ CREATE TABLE IF NOT EXISTS cache (
3931
+ key TEXT PRIMARY KEY,
3932
+ value TEXT NOT NULL,
3933
+ expires_at INTEGER NOT NULL
3934
+ );
3935
+ CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
3936
+ `);
3937
+
3938
+ if (!this.#authCredentialsTableExists()) {
3939
+ this.#createAuthCredentialsTable();
3940
+ this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
3941
+ return;
3942
+ }
3943
+
3944
+ const schemaVersion = this.#readAuthSchemaVersion() ?? this.#inferAuthSchemaVersion();
3945
+ const shouldWriteSchemaVersion = schemaVersion <= AUTH_SCHEMA_VERSION;
3946
+ if (schemaVersion > AUTH_SCHEMA_VERSION) {
3947
+ logger.warn("SqliteAuthCredentialStore schema version mismatch", {
3948
+ current: schemaVersion,
3949
+ expected: AUTH_SCHEMA_VERSION,
3950
+ });
3951
+ } else if (schemaVersion < AUTH_SCHEMA_VERSION) {
3952
+ this.#migrateAuthSchema(schemaVersion);
3953
+ }
3954
+
3955
+ this.#createAuthCredentialIndexes();
3956
+ this.#backfillCredentialIdentityKeys();
3957
+ if (shouldWriteSchemaVersion) {
3958
+ this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
3959
+ }
3960
+ }
3961
+
3962
+ #authCredentialsTableExists(): boolean {
3963
+ const row = this.#db
3964
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
3965
+ .get() as { present?: number } | undefined;
3966
+ return row?.present === 1;
3967
+ }
3968
+
3969
+ #readAuthSchemaVersion(): number | null {
3970
+ const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
3971
+ | { version?: number }
3972
+ | undefined;
3973
+ return typeof row?.version === "number" ? row.version : null;
3974
+ }
3975
+
3976
+ #writeAuthSchemaVersion(version: number): void {
3977
+ this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
3978
+ }
3979
+
3980
+ #inferAuthSchemaVersion(): number {
3981
+ const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
3982
+ const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
3983
+ const hasIdentityKey = cols.some(column => column.name === "identity_key");
3984
+ const hasAccountId = cols.some(column => column.name === "account_id");
3985
+ const hasEmail = cols.some(column => column.name === "email");
3986
+ if (hasIdentityKey) return 3;
3987
+ if (hasAccountId || hasEmail) return 2;
3988
+ if (hasDisabledCause) return 1;
3989
+ return 0;
3990
+ }
3991
+
3992
+ #createAuthCredentialsTable(): void {
3993
+ this.#db.run(`
3994
+ CREATE TABLE IF NOT EXISTS auth_credentials (
3995
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3996
+ provider TEXT NOT NULL,
3997
+ credential_type TEXT NOT NULL,
3998
+ data TEXT NOT NULL,
3999
+ disabled_cause TEXT DEFAULT NULL,
4000
+ identity_key TEXT DEFAULT NULL,
4001
+ created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
4002
+ updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
4003
+ );
4004
+ `);
4005
+ this.#createAuthCredentialIndexes();
4006
+ }
4007
+
4008
+ #createAuthCredentialIndexes(): void {
4009
+ this.#db.run(`
4010
+ CREATE INDEX IF NOT EXISTS idx_auth_provider ON auth_credentials(provider);
4011
+ CREATE INDEX IF NOT EXISTS idx_auth_provider_identity ON auth_credentials(provider, identity_key) WHERE identity_key IS NOT NULL;
4012
+ `);
4013
+ }
4014
+
4015
+ #migrateAuthSchema(fromVersion: number): void {
4016
+ if (fromVersion < 1) {
4017
+ this.#migrateAuthSchemaV0ToV1();
4018
+ }
4019
+ if (fromVersion < 3) {
4020
+ this.#migrateAuthSchemaV1OrV2ToV3();
4021
+ }
4022
+ if (fromVersion < 4) {
4023
+ this.#migrateAuthSchemaV3ToV4();
4024
+ }
4025
+ }
4026
+
4027
+ #migrateAuthSchemaV0ToV1(): void {
4028
+ const migrate = this.#db.transaction(() => {
4029
+ const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
4030
+ const hasDisabled = v0Cols.some(col => col.name === "disabled");
4031
+
4032
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
4033
+ this.#db.run(`
4034
+ CREATE TABLE auth_credentials (
4035
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4036
+ provider TEXT NOT NULL,
4037
+ credential_type TEXT NOT NULL,
4038
+ data TEXT NOT NULL,
4039
+ disabled_cause TEXT DEFAULT NULL,
4040
+ created_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH}),
4041
+ updated_at INTEGER NOT NULL DEFAULT (${SQLITE_NOW_EPOCH})
4042
+ );
4043
+ `);
4044
+ this.#db.run(`
4045
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, created_at, updated_at)
4046
+ SELECT
4047
+ id,
4048
+ provider,
4049
+ credential_type,
4050
+ data,
4051
+ ${hasDisabled ? "CASE WHEN disabled = 1 THEN 'disabled' ELSE NULL END" : "NULL"},
4052
+ created_at,
4053
+ updated_at
4054
+ FROM auth_credentials_v0
4055
+ `);
4056
+ this.#db.run("DROP TABLE auth_credentials_v0");
4057
+ });
4058
+ migrate();
4059
+ }
4060
+
4061
+ #migrateAuthSchemaV1OrV2ToV3(): void {
4062
+ const migrate = this.#db.transaction(() => {
4063
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_legacy");
4064
+ this.#createAuthCredentialsTable();
4065
+ this.#db.run(`
4066
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
4067
+ SELECT
4068
+ id,
4069
+ provider,
4070
+ credential_type,
4071
+ data,
4072
+ disabled_cause,
4073
+ NULL,
4074
+ created_at,
4075
+ updated_at
4076
+ FROM auth_credentials_legacy
4077
+ `);
4078
+ this.#db.run("DROP TABLE auth_credentials_legacy");
4079
+ });
4080
+ migrate();
4081
+ }
4082
+
4083
+ #migrateAuthSchemaV3ToV4(): void {
4084
+ const migrate = this.#db.transaction(() => {
4085
+ this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v3");
4086
+ this.#createAuthCredentialsTable();
4087
+ this.#db.run(`
4088
+ INSERT INTO auth_credentials (id, provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at)
4089
+ SELECT
4090
+ id,
4091
+ provider,
4092
+ credential_type,
4093
+ data,
4094
+ disabled_cause,
4095
+ identity_key,
4096
+ created_at,
4097
+ updated_at
4098
+ FROM auth_credentials_v3
4099
+ `);
4100
+ this.#db.run("DROP TABLE auth_credentials_v3");
4101
+ });
4102
+ migrate();
4103
+ }
4104
+
4105
+ #backfillCredentialIdentityKeys(): void {
4106
+ const rows = this.#db
4107
+ .prepare(
4108
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
4109
+ )
4110
+ .all() as AuthRow[];
4111
+ if (rows.length === 0) return;
4112
+
4113
+ const updateIdentity = this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4114
+ for (const row of rows) {
4115
+ const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4116
+ updateIdentity.run(identityKey, row.id);
4117
+ }
4118
+ }
4119
+
4120
+ // ─── AuthCredentialStore interface ──────────────────────────────────────
4121
+
4122
+ listAuthCredentials(provider?: string): StoredAuthCredential[] {
4123
+ const rows =
4124
+ (provider
4125
+ ? (this.#listActiveByProviderStmt.all(provider) as AuthRow[])
4126
+ : (this.#listActiveStmt.all() as AuthRow[])) ?? [];
4127
+
4128
+ const results: StoredAuthCredential[] = [];
4129
+ for (const row of rows) {
4130
+ const credential = deserializeCredential(row);
4131
+ if (!credential) continue;
4132
+ results.push(toStoredAuthCredential(row, credential));
4133
+ }
4134
+ return results;
4135
+ }
4136
+
4137
+ replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[] {
4138
+ const replace = this.#db.transaction((providerName: string, items: AuthCredential[]) => {
4139
+ const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
4140
+ const existing = existingRows.map(row => ({
4141
+ id: row.id,
4142
+ credential: deserializeCredential(row),
4143
+ identityKey: resolveRowCredentialIdentityKey(providerName, row),
4144
+ }));
4145
+
4146
+ const result: StoredAuthCredential[] = [];
4147
+ const matchedExistingIds = new Set<number>();
4148
+
4149
+ for (const credential of items) {
4150
+ const serialized = serializeCredential(providerName, credential);
4151
+ if (!serialized) continue;
4152
+ const match = existing.find(
4153
+ entry =>
4154
+ !matchedExistingIds.has(entry.id) &&
4155
+ matchesReplacementCredential(providerName, entry.credential, entry.identityKey, credential),
4156
+ );
4157
+ if (match) {
4158
+ matchedExistingIds.add(match.id);
4159
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, match.id);
4160
+ result.push({ id: match.id, provider: providerName, credential, disabledCause: null });
4161
+ } else {
4162
+ const row = this.#insertStmt.get(
4163
+ providerName,
4164
+ serialized.credentialType,
4165
+ serialized.data,
4166
+ serialized.identityKey,
4167
+ ) as { id?: number } | undefined;
4168
+ if (row?.id) {
4169
+ result.push({ id: row.id, provider: providerName, credential, disabledCause: null });
4170
+ }
4171
+ }
4172
+ }
4173
+
4174
+ for (const row of existing) {
4175
+ if (!matchedExistingIds.has(row.id)) {
4176
+ this.#deleteStmt.run("replaced by newer credential", row.id);
4177
+ }
4178
+ }
4179
+
4180
+ return result;
4181
+ });
4182
+
4183
+ const result = replace(provider, credentials);
4184
+ this.#purgeSupersededDisabledRows(provider, result);
4185
+ return result;
4186
+ }
4187
+
4188
+ upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[] {
4189
+ const upsert = this.#db.transaction((providerName: string, item: AuthCredential) => {
4190
+ const serialized = serializeCredential(providerName, item);
4191
+ if (!serialized) return this.listAuthCredentials(providerName);
4192
+ const existingRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
4193
+ const existing = existingRows.map(row => ({
4194
+ id: row.id,
4195
+ credential: deserializeCredential(row),
4196
+ identityKey: resolveRowCredentialIdentityKey(providerName, row),
4197
+ }));
4198
+
4199
+ let targetId: number | null = null;
4200
+ for (const row of existing) {
4201
+ if (!matchesReplacementCredential(providerName, row.credential, row.identityKey, item)) continue;
4202
+ if (targetId === null) {
4203
+ targetId = row.id;
4204
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, row.id);
4205
+ continue;
4206
+ }
4207
+ this.#deleteStmt.run("replaced by newer credential", row.id);
4208
+ }
4209
+
4210
+ if (targetId === null) {
4211
+ const row = this.#insertStmt.get(
4212
+ providerName,
4213
+ serialized.credentialType,
4214
+ serialized.data,
4215
+ serialized.identityKey,
4216
+ ) as { id?: number } | undefined;
4217
+ targetId = row?.id ?? null;
4218
+ }
4219
+
4220
+ const activeRows = this.#listActiveByProviderStmt.all(providerName) as AuthRow[];
4221
+ const result: StoredAuthCredential[] = [];
4222
+ for (const row of activeRows) {
4223
+ const activeCredential = deserializeCredential(row);
4224
+ if (!activeCredential) continue;
4225
+ result.push(toStoredAuthCredential(row, activeCredential));
4226
+ }
4227
+ return result;
4228
+ });
4229
+
4230
+ const result = upsert(provider, credential);
4231
+ this.#purgeSupersededDisabledRows(provider, result);
4232
+ return result;
4233
+ }
4234
+
4235
+ /**
4236
+ * Hard-deletes disabled rows for a provider when an active row with the same identity exists.
4237
+ * This prevents unbounded accumulation of soft-deleted credentials while preserving
4238
+ * disabled rows that have no active replacement (safety net for recovery).
4239
+ */
4240
+ #purgeSupersededDisabledRows(provider: string, activeRows: StoredAuthCredential[]): void {
4241
+ try {
4242
+ const activeIdentityKeys = new Set<string>();
4243
+ for (const row of activeRows) {
4244
+ const identityKey = resolveCredentialIdentityKey(provider, row.credential);
4245
+ if (identityKey) activeIdentityKeys.add(identityKey);
4246
+ }
4247
+ if (activeIdentityKeys.size === 0) return;
4248
+
4249
+ const disabledRows = this.#listDisabledByProviderStmt.all(provider) as AuthRow[];
4250
+ for (const row of disabledRows) {
4251
+ const identityKey = resolveRowCredentialIdentityKey(provider, row);
4252
+ if (identityKey && activeIdentityKeys.has(identityKey)) {
4253
+ this.#hardDeleteStmt.run(row.id);
4254
+ }
4255
+ }
4256
+ } catch {
4257
+ // Best-effort cleanup; don't let it break the main operation
4258
+ }
4259
+ }
4260
+
4261
+ updateAuthCredential(id: number, credential: AuthCredential): void {
4262
+ try {
4263
+ const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
4264
+ | { provider?: string }
4265
+ | undefined;
4266
+ const provider = providerRow?.provider ?? "";
4267
+ const serialized = serializeCredential(provider, credential);
4268
+ if (!serialized) return;
4269
+ this.#updateStmt.run(serialized.credentialType, serialized.data, serialized.identityKey, id);
4270
+ if (provider) {
4271
+ this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
4272
+ }
4273
+ } catch {
4274
+ // Ignore update failures
4275
+ }
4276
+ }
4277
+
4278
+ deleteAuthCredential(id: number, disabledCause: string): void {
4279
+ try {
4280
+ this.#deleteStmt.run(normalizeDisabledCause(disabledCause), id);
4281
+ } catch {
4282
+ // Ignore delete failures
4283
+ }
4284
+ }
4285
+
4286
+ /**
4287
+ * CAS-style disable: only soft-deletes the row when its `data` column still
4288
+ * matches `expectedData` and the row has not already been disabled. Used by
4289
+ * the OAuth refresh-failure path to avoid clobbering a peer that rotated the
4290
+ * row between our pre-check and the disable.
4291
+ */
4292
+ tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean {
4293
+ try {
4294
+ const result = this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
4295
+ changes: number;
4296
+ };
4297
+ return result.changes === 1;
4298
+ } catch {
4299
+ return false;
4300
+ }
4301
+ }
4302
+
4303
+ deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
4304
+ try {
4305
+ this.#deleteByProviderStmt.run(normalizeDisabledCause(disabledCause), provider);
4306
+ } catch {
4307
+ // Ignore delete failures
4308
+ }
4309
+ }
4310
+
4311
+ getCache(key: string, options?: { includeExpired?: boolean }): string | null {
4312
+ try {
4313
+ const stmt = options?.includeExpired === true ? this.#getCacheIncludingExpiredStmt : this.#getCacheStmt;
4314
+ const row = stmt.get(key) as { value?: string } | undefined;
4315
+ return row?.value ?? null;
4316
+ } catch {
4317
+ return null;
4318
+ }
4319
+ }
4320
+
4321
+ setCache(key: string, value: string, expiresAtSec: number): void {
4322
+ try {
4323
+ this.#upsertCacheStmt.run(key, value, expiresAtSec);
4324
+ } catch {
4325
+ // Ignore cache set failures
4326
+ }
4327
+ }
4328
+
4329
+ cleanExpiredCache(): void {
4330
+ try {
4331
+ this.#deleteExpiredCacheStmt.run();
4332
+ } catch {
4333
+ // Ignore cleanup errors
4334
+ }
4335
+ }
4336
+
4337
+ // ─── Convenience methods for CLI ────────────────────────────────────────
4338
+
4339
+ /**
4340
+ * Save OAuth credentials for a provider.
4341
+ * Preserves unrelated identities and replaces only the matching credential.
4342
+ */
4343
+ saveOAuth(provider: string, credentials: OAuthCredentials): void {
4344
+ const credential: AuthCredential = { type: "oauth", ...credentials };
4345
+ this.upsertAuthCredentialForProvider(provider, credential);
4346
+ }
4347
+
4348
+ /**
4349
+ * Get OAuth credentials for a provider.
4350
+ */
4351
+ getOAuth(provider: string): OAuthCredentials | null {
4352
+ const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
4353
+ for (const row of rows) {
4354
+ const credential = deserializeCredential(row);
4355
+ if (credential && credential.type === "oauth") {
4356
+ const { type: _type, ...oauth } = credential;
4357
+ return oauth as OAuthCredentials;
4358
+ }
4359
+ }
4360
+ return null;
4361
+ }
4362
+
4363
+ /**
4364
+ * Save API key for a provider (replaces existing).
4365
+ */
4366
+ saveApiKey(provider: string, apiKey: string): void {
4367
+ const credential: AuthCredential = { type: "api_key", key: apiKey };
4368
+ this.replaceAuthCredentialsForProvider(provider, [credential]);
4369
+ }
4370
+
4371
+ /**
4372
+ * Get API key for a provider.
4373
+ */
4374
+ getApiKey(provider: string): string | null {
4375
+ const rows = this.#listActiveByProviderStmt.all(provider) as AuthRow[];
4376
+ for (const row of rows) {
4377
+ const credential = deserializeCredential(row);
4378
+ if (credential && credential.type === "api_key") {
4379
+ return credential.key;
4380
+ }
4381
+ }
4382
+ return null;
4383
+ }
4384
+
4385
+ /**
4386
+ * List all providers with credentials.
4387
+ */
4388
+ listProviders(): string[] {
4389
+ const rows = this.#listActiveStmt.all() as AuthRow[];
4390
+ const providers = new Set<string>();
4391
+ for (const row of rows) {
4392
+ providers.add(row.provider);
4393
+ }
4394
+ return Array.from(providers);
4395
+ }
4396
+
4397
+ /**
4398
+ * Delete all credentials for a provider.
4399
+ */
4400
+ deleteProvider(provider: string): void {
4401
+ this.deleteAuthCredentialsForProvider(provider, "deleted by user");
4402
+ }
4403
+
4404
+ close(): void {
4405
+ if (this.#closed) return;
4406
+ this.#closed = true;
4407
+ this.#listActiveStmt.finalize();
4408
+ this.#listActiveByProviderStmt.finalize();
4409
+ this.#listDisabledByProviderStmt.finalize();
4410
+ this.#insertStmt.finalize();
4411
+ this.#updateStmt.finalize();
4412
+ this.#deleteStmt.finalize();
4413
+ this.#deleteIfMatchesStmt.finalize();
4414
+ this.#deleteByProviderStmt.finalize();
4415
+ this.#hardDeleteStmt.finalize();
4416
+ this.#getCacheStmt.finalize();
4417
+ this.#getCacheIncludingExpiredStmt.finalize();
4418
+ this.#upsertCacheStmt.finalize();
4419
+ this.#deleteExpiredCacheStmt.finalize();
4420
+ this.#db.close();
4421
+ }
4422
+ }