@prometheus-ai/ai 0.5.0

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