@prometheus-ai/ai 0.5.4 → 0.5.8

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 (377) hide show
  1. package/dist/types/auth-broker/remote-store.d.ts +2 -1
  2. package/dist/types/auth-broker/wire-schemas.d.ts +4 -1
  3. package/dist/types/auth-gateway/server.d.ts +19 -0
  4. package/dist/types/auth-gateway/types.d.ts +9 -3
  5. package/dist/types/auth-retry.d.ts +119 -0
  6. package/dist/types/auth-storage.d.ts +217 -8
  7. package/dist/types/errors.d.ts +24 -0
  8. package/dist/types/index.d.ts +5 -9
  9. package/dist/types/provider-details.d.ts +1 -1
  10. package/dist/types/providers/amazon-bedrock.d.ts +12 -6
  11. package/dist/types/providers/anthropic-client.d.ts +10 -3
  12. package/dist/types/providers/anthropic-messages-server-schema.d.ts +2 -2
  13. package/dist/types/providers/anthropic-messages-server.d.ts +3 -3
  14. package/dist/types/providers/anthropic-wire.d.ts +3 -3
  15. package/dist/types/providers/anthropic.d.ts +41 -34
  16. package/dist/types/providers/aws-credentials.d.ts +8 -0
  17. package/dist/types/providers/azure-openai-responses.d.ts +1 -0
  18. package/dist/types/providers/google-gemini-cli.d.ts +22 -1
  19. package/dist/types/providers/google-shared.d.ts +22 -0
  20. package/dist/types/providers/google-types.d.ts +13 -1
  21. package/dist/types/providers/mock.d.ts +8 -3
  22. package/dist/types/providers/ollama.d.ts +6 -0
  23. package/dist/types/providers/openai-chat-server-schema.d.ts +6 -3
  24. package/dist/types/providers/openai-chat-server.d.ts +3 -3
  25. package/dist/types/providers/openai-chat-wire.d.ts +644 -0
  26. package/dist/types/providers/openai-codex/request-transformer.d.ts +8 -0
  27. package/dist/types/providers/openai-codex/response-handler.d.ts +9 -0
  28. package/dist/types/providers/openai-codex-responses.d.ts +31 -2
  29. package/dist/types/providers/openai-completions-compat.d.ts +2 -25
  30. package/dist/types/providers/openai-completions.d.ts +2 -10
  31. package/dist/types/providers/openai-responses-server-schema.d.ts +4 -4
  32. package/dist/types/providers/openai-responses-server.d.ts +2 -2
  33. package/dist/types/providers/openai-responses-shared.d.ts +49 -9
  34. package/dist/types/providers/openai-responses-wire.d.ts +6065 -0
  35. package/dist/types/providers/openai-responses.d.ts +13 -4
  36. package/dist/types/providers/prometheus-native-client.d.ts +9 -0
  37. package/dist/types/providers/prometheus-native-server.d.ts +4 -3
  38. package/dist/types/providers/transform-messages.d.ts +1 -2
  39. package/dist/types/rate-limit-utils.d.ts +3 -2
  40. package/dist/types/registry/aimlapi.d.ts +4 -0
  41. package/dist/types/registry/alibaba-coding-plan.d.ts +7 -0
  42. package/dist/types/registry/amazon-bedrock.d.ts +5 -0
  43. package/dist/types/registry/anthropic.d.ts +10 -0
  44. package/dist/types/{utils/oauth → registry}/api-key-login.d.ts +8 -2
  45. package/dist/types/{utils/oauth → registry}/api-key-validation.d.ts +15 -0
  46. package/dist/types/registry/cerebras.d.ts +7 -0
  47. package/dist/types/registry/cloudflare-ai-gateway.d.ts +13 -0
  48. package/dist/types/registry/cursor.d.ts +7 -0
  49. package/dist/types/registry/deepseek.d.ts +8 -0
  50. package/dist/types/registry/derived.d.ts +5 -0
  51. package/dist/types/registry/firepass.d.ts +16 -0
  52. package/dist/types/registry/fireworks.d.ts +7 -0
  53. package/dist/types/registry/github-copilot.d.ts +7 -0
  54. package/dist/types/registry/gitlab-duo.d.ts +9 -0
  55. package/dist/types/registry/google-antigravity.d.ts +9 -0
  56. package/dist/types/registry/google-gemini-cli.d.ts +9 -0
  57. package/dist/types/registry/google-vertex.d.ts +5 -0
  58. package/dist/types/registry/google.d.ts +4 -0
  59. package/dist/types/registry/groq.d.ts +4 -0
  60. package/dist/types/registry/huggingface.d.ts +7 -0
  61. package/dist/types/registry/index.d.ts +4 -0
  62. package/dist/types/registry/kagi.d.ts +14 -0
  63. package/dist/types/registry/kilo.d.ts +7 -0
  64. package/dist/types/registry/kimi-code.d.ts +7 -0
  65. package/dist/types/registry/litellm.d.ts +13 -0
  66. package/dist/types/registry/lm-studio.d.ts +8 -0
  67. package/dist/types/registry/minimax-code-cn.d.ts +6 -0
  68. package/dist/types/registry/minimax-code.d.ts +6 -0
  69. package/dist/types/registry/minimax.d.ts +4 -0
  70. package/dist/types/registry/mistral.d.ts +4 -0
  71. package/dist/types/registry/moonshot.d.ts +7 -0
  72. package/dist/types/registry/nanogpt.d.ts +7 -0
  73. package/dist/types/registry/nvidia.d.ts +7 -0
  74. package/dist/types/registry/oauth/__tests__/xai-oauth.test.d.ts +1 -0
  75. package/dist/types/{utils → registry}/oauth/anthropic.d.ts +2 -1
  76. package/dist/types/{utils → registry}/oauth/github-copilot.d.ts +15 -23
  77. package/dist/types/{utils → registry}/oauth/index.d.ts +1 -0
  78. package/dist/types/{utils → registry}/oauth/minimax-code.d.ts +5 -5
  79. package/dist/types/{utils → registry}/oauth/types.d.ts +6 -1
  80. package/dist/types/{utils → registry}/oauth/xai-oauth.d.ts +2 -1
  81. package/dist/types/registry/ollama-cloud.d.ts +7 -0
  82. package/dist/types/registry/ollama.d.ts +12 -0
  83. package/dist/types/registry/openai-codex-device.d.ts +8 -0
  84. package/dist/types/registry/openai-codex.d.ts +9 -0
  85. package/dist/types/registry/openai.d.ts +4 -0
  86. package/dist/types/registry/opencode-go.d.ts +6 -0
  87. package/dist/types/registry/opencode-zen.d.ts +6 -0
  88. package/dist/types/registry/openrouter.d.ts +13 -0
  89. package/dist/types/registry/parallel.d.ts +14 -0
  90. package/dist/types/registry/perplexity.d.ts +7 -0
  91. package/dist/types/registry/qianfan.d.ts +7 -0
  92. package/dist/types/registry/qwen-portal.d.ts +7 -0
  93. package/dist/types/registry/registry.d.ts +272 -0
  94. package/dist/types/registry/synthetic.d.ts +6 -0
  95. package/dist/types/registry/tavily.d.ts +14 -0
  96. package/dist/types/registry/together.d.ts +6 -0
  97. package/dist/types/registry/types.d.ts +51 -0
  98. package/dist/types/registry/venice.d.ts +13 -0
  99. package/dist/types/registry/vercel-ai-gateway.d.ts +7 -0
  100. package/dist/types/registry/vllm.d.ts +7 -0
  101. package/dist/types/registry/wafer-pass.d.ts +6 -0
  102. package/dist/types/registry/wafer-serverless.d.ts +6 -0
  103. package/dist/types/registry/xai-oauth.d.ts +7 -0
  104. package/dist/types/registry/xai.d.ts +4 -0
  105. package/dist/types/registry/xiaomi-token-plan-ams.d.ts +6 -0
  106. package/dist/types/registry/xiaomi-token-plan-cn.d.ts +6 -0
  107. package/dist/types/registry/xiaomi-token-plan-sgp.d.ts +6 -0
  108. package/dist/types/registry/xiaomi.d.ts +6 -0
  109. package/dist/types/registry/zai.d.ts +7 -0
  110. package/dist/types/registry/zenmux.d.ts +7 -0
  111. package/dist/types/registry/zhipu-coding-plan.d.ts +7 -0
  112. package/dist/types/stream.d.ts +9 -1
  113. package/dist/types/types.d.ts +56 -295
  114. package/dist/types/usage/google-antigravity.d.ts +15 -1
  115. package/dist/types/usage/openai-codex-reset.d.ts +79 -0
  116. package/dist/types/usage/openai-codex.d.ts +1 -0
  117. package/dist/types/usage.d.ts +77 -4
  118. package/dist/types/utils/abort.d.ts +6 -0
  119. package/dist/types/utils/event-stream.d.ts +2 -0
  120. package/dist/types/utils/http-inspector.d.ts +0 -1
  121. package/dist/types/utils/idle-iterator.d.ts +35 -0
  122. package/dist/types/utils/openai-http.d.ts +58 -0
  123. package/dist/types/utils/request-debug.d.ts +3 -0
  124. package/dist/types/utils/retry-after.d.ts +1 -0
  125. package/dist/types/utils/schema/fields.d.ts +5 -0
  126. package/dist/types/utils/schema/json-schema-validator.d.ts +8 -0
  127. package/dist/types/utils/schema/stamps.d.ts +7 -15
  128. package/dist/types/utils/sse-debug.d.ts +0 -5
  129. package/dist/types/utils/stream-markup-healing.d.ts +2 -0
  130. package/dist/types/utils.d.ts +1 -5
  131. package/package.json +17 -29
  132. package/src/auth-broker/remote-store.ts +10 -1
  133. package/src/auth-broker/snapshot-cache.ts +1 -1
  134. package/src/auth-broker/wire-schemas.ts +1 -1
  135. package/src/auth-gateway/http.ts +1 -1
  136. package/src/auth-gateway/server.ts +95 -30
  137. package/src/auth-gateway/types.ts +10 -2
  138. package/src/auth-retry.ts +238 -0
  139. package/src/auth-storage.ts +935 -430
  140. package/src/errors.ts +32 -0
  141. package/src/index.ts +9 -14
  142. package/src/provider-details.ts +1 -1
  143. package/src/providers/__tests__/google-auth.test.ts +144 -0
  144. package/src/providers/amazon-bedrock.ts +70 -40
  145. package/src/providers/anthropic-client.ts +15 -13
  146. package/src/providers/anthropic-messages-server-schema.ts +17 -7
  147. package/src/providers/anthropic-messages-server.ts +88 -20
  148. package/src/providers/anthropic-wire.ts +4 -3
  149. package/src/providers/anthropic.ts +1234 -621
  150. package/src/providers/aws-credentials.ts +47 -5
  151. package/src/providers/aws-eventstream.ts +5 -0
  152. package/src/providers/azure-openai-responses.ts +117 -67
  153. package/src/providers/cursor.ts +30 -30
  154. package/src/providers/github-copilot-headers.ts +1 -1
  155. package/src/providers/gitlab-duo.ts +36 -29
  156. package/src/providers/google-auth.ts +71 -8
  157. package/src/providers/google-gemini-cli.ts +118 -22
  158. package/src/providers/google-shared.ts +163 -43
  159. package/src/providers/google-types.ts +10 -1
  160. package/src/providers/kimi.ts +1 -1
  161. package/src/providers/mock.ts +11 -3
  162. package/src/providers/ollama.ts +64 -7
  163. package/src/providers/openai-anthropic-shim.ts +17 -8
  164. package/src/providers/openai-chat-server-schema.ts +9 -3
  165. package/src/providers/openai-chat-server.ts +82 -16
  166. package/src/providers/openai-chat-wire.ts +847 -0
  167. package/src/providers/openai-codex/request-transformer.ts +129 -34
  168. package/src/providers/openai-codex/response-handler.ts +22 -1
  169. package/src/providers/openai-codex-responses.ts +699 -247
  170. package/src/providers/openai-completions-compat.ts +8 -308
  171. package/src/providers/openai-completions.ts +416 -267
  172. package/src/providers/openai-responses-server-schema.ts +15 -9
  173. package/src/providers/openai-responses-server.ts +162 -114
  174. package/src/providers/openai-responses-shared.ts +320 -82
  175. package/src/providers/openai-responses-wire.ts +6391 -0
  176. package/src/providers/openai-responses.ts +382 -176
  177. package/src/providers/prometheus-native-client.ts +27 -11
  178. package/src/providers/prometheus-native-server.ts +44 -17
  179. package/src/providers/transform-messages.ts +311 -120
  180. package/src/providers/vision-guard.ts +5 -3
  181. package/src/rate-limit-utils.ts +13 -3
  182. package/src/registry/aimlapi.ts +6 -0
  183. package/src/{utils/oauth → registry}/alibaba-coding-plan.ts +8 -18
  184. package/src/registry/amazon-bedrock.ts +22 -0
  185. package/src/registry/anthropic.ts +26 -0
  186. package/src/{utils/oauth → registry}/api-key-login.ts +25 -3
  187. package/src/{utils/oauth → registry}/api-key-validation.ts +62 -2
  188. package/src/{utils/oauth → registry}/cerebras.ts +8 -1
  189. package/src/{utils/oauth → registry}/cloudflare-ai-gateway.ts +8 -12
  190. package/src/registry/cursor.ts +20 -0
  191. package/src/{utils/oauth → registry}/deepseek.ts +9 -17
  192. package/src/registry/derived.ts +9 -0
  193. package/src/{utils/oauth → registry}/firepass.ts +10 -2
  194. package/src/{utils/oauth → registry}/fireworks.ts +8 -1
  195. package/src/registry/github-copilot.ts +22 -0
  196. package/src/registry/gitlab-duo.ts +19 -0
  197. package/src/registry/google-antigravity.ts +21 -0
  198. package/src/registry/google-gemini-cli.ts +21 -0
  199. package/src/registry/google-vertex.ts +38 -0
  200. package/src/registry/google.ts +6 -0
  201. package/src/registry/groq.ts +6 -0
  202. package/src/{utils/oauth → registry}/huggingface.ts +8 -19
  203. package/src/registry/index.ts +4 -0
  204. package/src/{utils/oauth → registry}/kagi.ts +9 -11
  205. package/src/{utils/oauth → registry}/kilo.ts +11 -6
  206. package/src/registry/kimi-code.ts +17 -0
  207. package/src/{utils/oauth → registry}/litellm.ts +8 -12
  208. package/src/{utils/oauth → registry}/lm-studio.ts +9 -17
  209. package/src/registry/minimax-code-cn.ts +12 -0
  210. package/src/registry/minimax-code.ts +12 -0
  211. package/src/registry/minimax.ts +6 -0
  212. package/src/registry/mistral.ts +6 -0
  213. package/src/{utils/oauth → registry}/moonshot.ts +8 -9
  214. package/src/{utils/oauth → registry}/nanogpt.ts +8 -1
  215. package/src/{utils/oauth → registry}/nvidia.ts +8 -18
  216. package/src/{utils → registry}/oauth/__tests__/xai-oauth.test.ts +4 -7
  217. package/src/{utils → registry}/oauth/anthropic.ts +38 -17
  218. package/src/{utils → registry}/oauth/github-copilot.ts +79 -115
  219. package/src/registry/oauth/gitlab-duo.ts +198 -0
  220. package/src/{utils → registry}/oauth/google-antigravity.ts +1 -4
  221. package/src/{utils → registry}/oauth/google-gemini-cli.ts +1 -4
  222. package/src/registry/oauth/index.ts +164 -0
  223. package/src/{utils → registry}/oauth/minimax-code.ts +16 -14
  224. package/src/{utils → registry}/oauth/types.ts +7 -51
  225. package/src/{utils → registry}/oauth/wafer.ts +1 -1
  226. package/src/{utils → registry}/oauth/xai-oauth.ts +16 -8
  227. package/src/{utils → registry}/oauth/xiaomi.ts +9 -4
  228. package/src/{utils/oauth → registry}/ollama-cloud.ts +8 -1
  229. package/src/{utils/oauth → registry}/ollama.ts +8 -13
  230. package/src/registry/openai-codex-device.ts +18 -0
  231. package/src/registry/openai-codex.ts +19 -0
  232. package/src/registry/openai.ts +6 -0
  233. package/src/registry/opencode-go.ts +12 -0
  234. package/src/registry/opencode-zen.ts +12 -0
  235. package/src/{utils/oauth → registry}/openrouter.ts +10 -2
  236. package/src/{utils/oauth → registry}/parallel.ts +9 -11
  237. package/src/registry/perplexity.ts +13 -0
  238. package/src/{utils/oauth → registry}/qianfan.ts +8 -17
  239. package/src/{utils/oauth → registry}/qwen-portal.ts +8 -19
  240. package/src/registry/registry.ts +149 -0
  241. package/src/{utils/oauth → registry}/synthetic.ts +7 -1
  242. package/src/{utils/oauth → registry}/tavily.ts +10 -12
  243. package/src/{utils/oauth → registry}/together.ts +7 -1
  244. package/src/registry/types.ts +56 -0
  245. package/src/{utils/oauth → registry}/venice.ts +8 -12
  246. package/src/{utils/oauth → registry}/vercel-ai-gateway.ts +8 -18
  247. package/src/{utils/oauth → registry}/vllm.ts +9 -16
  248. package/src/registry/wafer-pass.ts +12 -0
  249. package/src/registry/wafer-serverless.ts +12 -0
  250. package/src/registry/xai-oauth.ts +17 -0
  251. package/src/registry/xai.ts +6 -0
  252. package/src/registry/xiaomi-token-plan-ams.ts +12 -0
  253. package/src/registry/xiaomi-token-plan-cn.ts +12 -0
  254. package/src/registry/xiaomi-token-plan-sgp.ts +12 -0
  255. package/src/registry/xiaomi.ts +12 -0
  256. package/src/{utils/oauth → registry}/zai.ts +10 -22
  257. package/src/{utils/oauth → registry}/zenmux.ts +8 -1
  258. package/src/{utils/oauth/zhipu.ts → registry/zhipu-coding-plan.ts} +9 -21
  259. package/src/stream.ts +229 -199
  260. package/src/types.ts +63 -384
  261. package/src/usage/claude.ts +4 -2
  262. package/src/usage/github-copilot.ts +4 -2
  263. package/src/usage/google-antigravity.ts +196 -28
  264. package/src/usage/kimi.ts +1 -1
  265. package/src/usage/minimax-code.ts +5 -6
  266. package/src/usage/openai-codex-reset.ts +174 -0
  267. package/src/usage/openai-codex.ts +19 -2
  268. package/src/usage/zai.ts +2 -1
  269. package/src/usage.ts +93 -4
  270. package/src/utils/abort.ts +14 -0
  271. package/src/utils/event-stream.ts +17 -0
  272. package/src/utils/http-inspector.ts +4 -12
  273. package/src/utils/idle-iterator.ts +250 -79
  274. package/src/utils/openai-http.ts +157 -0
  275. package/src/utils/request-debug.ts +67 -19
  276. package/src/utils/retry-after.ts +1 -1
  277. package/src/utils/retry.ts +23 -2
  278. package/src/utils/schema/CONSTRAINTS.md +4 -2
  279. package/src/utils/schema/fields.ts +16 -0
  280. package/src/utils/schema/json-schema-validator.ts +19 -1
  281. package/src/utils/schema/normalize.ts +80 -8
  282. package/src/utils/schema/stamps.ts +22 -10
  283. package/src/utils/schema/wire.ts +2 -2
  284. package/src/utils/sse-debug.ts +0 -271
  285. package/src/utils/stream-markup-healing.ts +50 -8
  286. package/src/utils/validation.ts +49 -13
  287. package/src/utils.ts +2 -26
  288. package/dist/types/model-cache.d.ts +0 -17
  289. package/dist/types/model-manager.d.ts +0 -64
  290. package/dist/types/model-thinking.d.ts +0 -100
  291. package/dist/types/models.d.ts +0 -12
  292. package/dist/types/provider-models/bundled-references.d.ts +0 -4
  293. package/dist/types/provider-models/descriptors.d.ts +0 -50
  294. package/dist/types/provider-models/google.d.ts +0 -24
  295. package/dist/types/provider-models/index.d.ts +0 -5
  296. package/dist/types/provider-models/ollama.d.ts +0 -7
  297. package/dist/types/provider-models/openai-compat.d.ts +0 -323
  298. package/dist/types/provider-models/special.d.ts +0 -16
  299. package/dist/types/utils/discovery/antigravity.d.ts +0 -61
  300. package/dist/types/utils/discovery/codex.d.ts +0 -38
  301. package/dist/types/utils/discovery/cursor.d.ts +0 -23
  302. package/dist/types/utils/discovery/gemini.d.ts +0 -25
  303. package/dist/types/utils/discovery/index.d.ts +0 -4
  304. package/dist/types/utils/discovery/openai-compatible.d.ts +0 -72
  305. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +0 -18
  306. package/dist/types/utils/oauth/cerebras.d.ts +0 -1
  307. package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +0 -18
  308. package/dist/types/utils/oauth/deepseek.d.ts +0 -10
  309. package/dist/types/utils/oauth/firepass.d.ts +0 -1
  310. package/dist/types/utils/oauth/fireworks.d.ts +0 -1
  311. package/dist/types/utils/oauth/huggingface.d.ts +0 -19
  312. package/dist/types/utils/oauth/kagi.d.ts +0 -17
  313. package/dist/types/utils/oauth/kilo.d.ts +0 -5
  314. package/dist/types/utils/oauth/litellm.d.ts +0 -18
  315. package/dist/types/utils/oauth/lm-studio.d.ts +0 -17
  316. package/dist/types/utils/oauth/moonshot.d.ts +0 -1
  317. package/dist/types/utils/oauth/nanogpt.d.ts +0 -1
  318. package/dist/types/utils/oauth/nvidia.d.ts +0 -18
  319. package/dist/types/utils/oauth/ollama-cloud.d.ts +0 -2
  320. package/dist/types/utils/oauth/ollama.d.ts +0 -18
  321. package/dist/types/utils/oauth/openrouter.d.ts +0 -1
  322. package/dist/types/utils/oauth/parallel.d.ts +0 -17
  323. package/dist/types/utils/oauth/qianfan.d.ts +0 -17
  324. package/dist/types/utils/oauth/qwen-portal.d.ts +0 -19
  325. package/dist/types/utils/oauth/synthetic.d.ts +0 -1
  326. package/dist/types/utils/oauth/tavily.d.ts +0 -17
  327. package/dist/types/utils/oauth/together.d.ts +0 -1
  328. package/dist/types/utils/oauth/venice.d.ts +0 -18
  329. package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +0 -18
  330. package/dist/types/utils/oauth/vllm.d.ts +0 -16
  331. package/dist/types/utils/oauth/zai.d.ts +0 -18
  332. package/dist/types/utils/oauth/zenmux.d.ts +0 -1
  333. package/dist/types/utils/oauth/zhipu.d.ts +0 -18
  334. package/src/model-cache.ts +0 -129
  335. package/src/model-manager.ts +0 -469
  336. package/src/model-thinking.ts +0 -756
  337. package/src/models.json +0 -60287
  338. package/src/models.json.d.ts +0 -9
  339. package/src/models.ts +0 -56
  340. package/src/provider-models/bundled-references.ts +0 -38
  341. package/src/provider-models/descriptors.ts +0 -364
  342. package/src/provider-models/google.ts +0 -88
  343. package/src/provider-models/index.ts +0 -5
  344. package/src/provider-models/ollama.ts +0 -153
  345. package/src/provider-models/openai-compat.ts +0 -2904
  346. package/src/provider-models/special.ts +0 -67
  347. package/src/utils/discovery/antigravity.ts +0 -261
  348. package/src/utils/discovery/codex.ts +0 -371
  349. package/src/utils/discovery/cursor.ts +0 -306
  350. package/src/utils/discovery/gemini.ts +0 -248
  351. package/src/utils/discovery/index.ts +0 -4
  352. package/src/utils/discovery/openai-compatible.ts +0 -224
  353. package/src/utils/oauth/gitlab-duo.ts +0 -123
  354. package/src/utils/oauth/index.ts +0 -502
  355. /package/dist/types/{utils/oauth/__tests__/xai-oauth.test.d.ts → providers/__tests__/google-auth.test.d.ts} +0 -0
  356. /package/dist/types/{utils → registry}/oauth/callback-server.d.ts +0 -0
  357. /package/dist/types/{utils → registry}/oauth/cursor.d.ts +0 -0
  358. /package/dist/types/{utils → registry}/oauth/gitlab-duo.d.ts +0 -0
  359. /package/dist/types/{utils → registry}/oauth/google-antigravity.d.ts +0 -0
  360. /package/dist/types/{utils → registry}/oauth/google-gemini-cli.d.ts +0 -0
  361. /package/dist/types/{utils → registry}/oauth/google-oauth-shared.d.ts +0 -0
  362. /package/dist/types/{utils → registry}/oauth/kimi.d.ts +0 -0
  363. /package/dist/types/{utils → registry}/oauth/openai-codex.d.ts +0 -0
  364. /package/dist/types/{utils → registry}/oauth/opencode.d.ts +0 -0
  365. /package/dist/types/{utils → registry}/oauth/perplexity.d.ts +0 -0
  366. /package/dist/types/{utils → registry}/oauth/pkce.d.ts +0 -0
  367. /package/dist/types/{utils → registry}/oauth/wafer.d.ts +0 -0
  368. /package/dist/types/{utils → registry}/oauth/xiaomi.d.ts +0 -0
  369. /package/src/{utils → registry}/oauth/callback-server.ts +0 -0
  370. /package/src/{utils → registry}/oauth/cursor.ts +0 -0
  371. /package/src/{utils → registry}/oauth/google-oauth-shared.ts +0 -0
  372. /package/src/{utils → registry}/oauth/kimi.ts +0 -0
  373. /package/src/{utils → registry}/oauth/oauth.html +0 -0
  374. /package/src/{utils → registry}/oauth/openai-codex.ts +0 -0
  375. /package/src/{utils → registry}/oauth/opencode.ts +0 -0
  376. /package/src/{utils → registry}/oauth/perplexity.ts +0 -0
  377. /package/src/{utils → registry}/oauth/pkce.ts +0 -0
@@ -11,30 +11,42 @@ import { Database, type Statement } from "bun:sqlite";
11
11
  import * as fs from "node:fs/promises";
12
12
  import * as path from "node:path";
13
13
  import { getAgentDbPath, logger } from "@prometheus-ai/utils";
14
- import { getEnvApiKey } from "./stream";
14
+ import type { ApiKeyResolver } from "./auth-retry";
15
+ import { isUsageLimitError } from "./rate-limit-utils";
16
+ import { getProviderDefinition } from "./registry";
17
+ import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
18
+ import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
19
+ import { getEnvApiKey, getEnvApiKeyName } from "./stream";
15
20
  import type { Provider } from "./types";
16
21
  import type {
22
+ CredentialRankingContext,
17
23
  CredentialRankingStrategy,
18
24
  UsageCredential,
19
25
  UsageFetchContext,
20
26
  UsageFetchParams,
27
+ UsageHistoryEntry,
28
+ UsageHistoryQuery,
21
29
  UsageLimit,
22
30
  UsageLogger,
23
31
  UsageProvider,
24
32
  UsageReport,
25
33
  } from "./usage";
34
+ import { resolveUsedFraction } from "./usage";
26
35
  import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
27
36
  import { googleGeminiCliUsageProvider } from "./usage/gemini";
28
37
  import { githubCopilotUsageProvider } from "./usage/github-copilot";
29
- import { antigravityUsageProvider } from "./usage/google-antigravity";
38
+ import { antigravityRankingStrategy, antigravityUsageProvider } from "./usage/google-antigravity";
30
39
  import { kimiUsageProvider } from "./usage/kimi";
31
40
  import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
41
+ import {
42
+ type CodexResetConsumeCode,
43
+ type CodexResetCredit,
44
+ consumeCodexResetCredit,
45
+ listCodexResetCredits,
46
+ } from "./usage/openai-codex-reset";
32
47
  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";
48
+
49
+ const USAGE_RANKING_METRIC_EPSILON = 1e-9;
38
50
 
39
51
  // ─────────────────────────────────────────────────────────────────────────────
40
52
  // Credential Types
@@ -55,6 +67,23 @@ export type AuthCredentialEntry = AuthCredential | AuthCredential[];
55
67
 
56
68
  export type AuthStorageData = Record<string, AuthCredentialEntry>;
57
69
 
70
+ /**
71
+ * Cascade leg that supplies a provider's active credential, highest precedence
72
+ * first — mirrors {@link AuthStorage.getApiKey}'s resolution order.
73
+ */
74
+ export type CredentialOriginKind = "runtime" | "config" | "oauth" | "api_key" | "env" | "fallback";
75
+
76
+ /**
77
+ * Structured provenance for a provider's auth, for UI that needs a machine
78
+ * tag (the `/login` provider list) rather than the prose of
79
+ * {@link AuthStorage.describeCredentialSource}.
80
+ */
81
+ export interface CredentialOrigin {
82
+ kind: CredentialOriginKind;
83
+ /** Env var name when `kind === "env"` and a single named variable backs it. */
84
+ envVar?: string;
85
+ }
86
+
58
87
  /**
59
88
  * Serialized representation of AuthStorage for passing to subagent workers.
60
89
  * Contains only the essential credential data, not runtime state.
@@ -264,6 +293,14 @@ export interface AuthCredentialStore {
264
293
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
265
294
  setCache(key: string, value: string, expiresAtSec: number): void;
266
295
  cleanExpiredCache(): void;
296
+ /**
297
+ * Append usage-limit snapshots for trend history. Optional: stores without
298
+ * durable storage (e.g. the broker remote store) omit it and recording is
299
+ * skipped — the broker host records into its own database instead.
300
+ */
301
+ recordUsageSnapshots?(entries: UsageHistoryEntry[]): void;
302
+ /** Read recorded usage-limit snapshots, oldest first. */
303
+ listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[];
267
304
  /**
268
305
  * Optional store-supplied OAuth refresh. When present, `AuthStorage` uses
269
306
  * it before the per-provider local refresh path. `RemoteAuthCredentialStore`
@@ -339,6 +376,11 @@ export interface AuthCredentialStore {
339
376
  * `replaceAuthCredentialsForProvider`.
340
377
  */
341
378
  replaceAuthCredentialsRemote?(provider: string, credentials: AuthCredential[]): Promise<StoredAuthCredential[]>;
379
+ /**
380
+ * Optional async write hook for disabling one stored credential. Remote stores
381
+ * use it to await broker persistence before AuthStorage updates its snapshot.
382
+ */
383
+ deleteAuthCredentialRemote?(id: number, disabledCause: string): Promise<boolean>;
342
384
  /**
343
385
  * Optional async write hook for clearing every credential for a provider
344
386
  * (logout). When present, `AuthStorage.remove` routes through this instead
@@ -408,7 +450,7 @@ export type AuthStorageOptions = {
408
450
  *
409
451
  * Examples:
410
452
  * - `"local ~/.prometheus/agent/agent.db"`
411
- * - `"broker http://can.internal:8765"`
453
+ * - `"broker http://auth-broker.internal:8765"`
412
454
  */
413
455
  sourceLabel?: string;
414
456
  /**
@@ -416,7 +458,7 @@ export type AuthStorageOptions = {
416
458
  * calls this instead of fanning out per-credential. The primary use case is
417
459
  * routing through a broker that egresses from a less-throttled IP — e.g. a
418
460
  * 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.
461
+ * endpoint and drops 2-of-5 credentials, while the broker gets all 5.
420
462
  *
421
463
  * Implementations may return null when no usage data is available; the
422
464
  * AuthStorage caller surfaces that to its own consumer unchanged.
@@ -463,6 +505,13 @@ const USAGE_CACHE_PREFIX = "usage_cache:";
463
505
  const USAGE_REPORT_TTL_MS = 5 * 60_000;
464
506
  const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
465
507
  const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
508
+ /**
509
+ * Downsample usage history to at most one row per hour per account window: a
510
+ * snapshot landing in the same hour bucket as the series' latest row
511
+ * overwrites it in place. That bound makes further retention pruning
512
+ * unnecessary — 1 row/hour is ~9k rows per account window per year.
513
+ */
514
+ const USAGE_HISTORY_BUCKET_MS = 60 * 60_000;
466
515
  /**
467
516
  * Per-credential cool-down after a usage fetch fails. While this window is
468
517
  * active we serve the last successful value to avoid dropping the credential
@@ -473,6 +522,9 @@ const USAGE_FAILURE_BACKOFF_MS = 10_000;
473
522
  // Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
474
523
  // (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
475
524
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
525
+ const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial<Record<Provider, number>> = {
526
+ "google-antigravity": 2,
527
+ };
476
528
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
477
529
  /**
478
530
  * Refresh OAuth access tokens this many ms before their stated expiry. The
@@ -517,6 +569,23 @@ export function isDefinitiveOAuthFailure(errorMsg: string): boolean {
517
569
  return false;
518
570
  }
519
571
 
572
+ /**
573
+ * Outcome of {@link AuthStorage.markUsageLimitReached}.
574
+ *
575
+ * `switched` is `true` when an unblocked same-type sibling credential is
576
+ * available right now, so the caller can retry immediately and the next
577
+ * `getApiKey` will hand it out. When `false`, `retryAtMs` (epoch ms) carries
578
+ * the earliest moment any same-type sibling's temporary block expires —
579
+ * callers should prefer waiting until then over the provider's (often
580
+ * multi-hour) retry-after when it is sooner. `retryAtMs` is `undefined` when
581
+ * no sibling credentials exist at all, or when the session has no tracked
582
+ * credential to rotate away from.
583
+ */
584
+ export interface UsageLimitMarkResult {
585
+ switched: boolean;
586
+ retryAtMs?: number;
587
+ }
588
+
520
589
  type UsageCacheEntry<T> = {
521
590
  value: T;
522
591
  expiresAt: number;
@@ -544,6 +613,13 @@ type AuthApiKeyOptions = {
544
613
  * stranding the caller for `timeoutMs * (maxRetries + 1)`.
545
614
  */
546
615
  signal?: AbortSignal;
616
+ /**
617
+ * Force a re-mint of the session-preferred OAuth credential's access token,
618
+ * bypassing the not-yet-expired short-circuit. Powers step (b) of the
619
+ * auth-retry policy ("refresh the SAME account") so a locally-cached token
620
+ * that a peer/broker rotated out from under us is replaced before retrying.
621
+ */
622
+ forceRefresh?: boolean;
547
623
  };
548
624
  type OAuthResolutionResult = { apiKey: string; credential: OAuthCredential };
549
625
 
@@ -573,12 +649,65 @@ export interface OAuthAccessFailure {
573
649
  error: string;
574
650
  }
575
651
 
652
+ /**
653
+ * Identity of the OAuth credential a session is currently routed to. Read-only
654
+ * display/metadata shape: `accountId` is the provider's account UUID, `email`
655
+ * the user-facing login, `projectId` the GCP-style project for providers that
656
+ * key usage on it (Gemini CLI / Antigravity).
657
+ */
658
+ export interface OAuthAccountIdentity {
659
+ accountId?: string;
660
+ email?: string;
661
+ projectId?: string;
662
+ }
663
+
576
664
  export type OAuthAccessResolution = ({ ok: true } & OAuthAccess) | ({ ok: false } & OAuthAccessFailure);
577
665
  export interface InvalidateCredentialMatchingOptions {
578
666
  signal?: AbortSignal;
579
667
  sessionId?: string;
580
668
  }
581
669
 
670
+ /**
671
+ * Identifies which stored account to redeem a saved rate-limit reset for.
672
+ * Any one field is enough; `credentialId` is the most precise.
673
+ */
674
+ export interface ResetCreditTarget {
675
+ credentialId?: number;
676
+ accountId?: string;
677
+ email?: string;
678
+ }
679
+
680
+ /** Outcome of {@link AuthStorage.redeemResetCredit}. */
681
+ export interface ResetCreditRedeemOutcome {
682
+ /** `true` only when a reset was actually applied (`code === "reset"`). */
683
+ ok: boolean;
684
+ /**
685
+ * Result code. Backend codes: `reset` (success), `already_redeemed`,
686
+ * `no_credit`, `nothing_to_reset`. Locally-synthesized: `no_account`
687
+ * (target not found), `account_unavailable` (token refresh failed),
688
+ * `http_<status>` (unexpected HTTP).
689
+ */
690
+ code: CodexResetConsumeCode;
691
+ accountId?: string;
692
+ email?: string;
693
+ /** The credit that was spent (when one was). */
694
+ creditId?: string;
695
+ }
696
+
697
+ /** One stored account's live saved-reset status, from {@link AuthStorage.listResetCredits}. */
698
+ export interface ResetCreditAccountStatus {
699
+ credentialId?: number;
700
+ accountId?: string;
701
+ email?: string;
702
+ /** Resets redeemable for this account right now (live, not cached). */
703
+ availableCount: number;
704
+ credits: CodexResetCredit[];
705
+ /** Whether this is the given session's active account. */
706
+ active: boolean;
707
+ /** Set when the account's token refresh or list call failed. */
708
+ error?: string;
709
+ }
710
+
582
711
  function isAbortSignalOption(
583
712
  value: InvalidateCredentialMatchingOptions | AbortSignal | undefined,
584
713
  ): value is AbortSignal {
@@ -606,6 +735,14 @@ function hasOpenAICodexProPlan(report: UsageReport | null): boolean {
606
735
  return getUsagePlanType(report)?.includes("pro") === true;
607
736
  }
608
737
 
738
+ function compareUsageRankingMetric(left: number, right: number): number {
739
+ if (left === right) return 0;
740
+ if (!Number.isFinite(left) || !Number.isFinite(right)) return left < right ? -1 : 1;
741
+ const delta = left - right;
742
+ const tolerance = Math.max(USAGE_RANKING_METRIC_EPSILON, Math.max(Math.abs(left), Math.abs(right)) * 0.000001);
743
+ return Math.abs(delta) <= tolerance ? 0 : delta;
744
+ }
745
+
609
746
  function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefined {
610
747
  return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
611
748
  }
@@ -613,6 +750,7 @@ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefi
613
750
  const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
614
751
  ["openai-codex", codexRankingStrategy],
615
752
  ["anthropic", claudeRankingStrategy],
753
+ ["google-antigravity", antigravityRankingStrategy],
616
754
  ]);
617
755
 
618
756
  function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
@@ -816,6 +954,14 @@ export class AuthStorage {
816
954
  this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
817
955
  this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
818
956
  this.#usageCache = new AuthStorageUsageCache(this.#store);
957
+ // Opportunistic hygiene, once per AuthStorage lifetime: drop expired
958
+ // cache rows (24h last-good retention). A cheap indexed DELETE;
959
+ // failures must never block construction.
960
+ try {
961
+ this.#store.cleanExpiredCache();
962
+ } catch {
963
+ // Best-effort.
964
+ }
819
965
  this.#usageFetch = options.usageFetch ?? fetch;
820
966
  this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
821
967
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
@@ -836,7 +982,7 @@ export class AuthStorage {
836
982
 
837
983
  /**
838
984
  * Create an AuthStorage instance backed by a AuthCredentialStore.
839
- * Convenience factory for standalone use (e.g., @prometheus-ai/ai CLI).
985
+ * Convenience factory for standalone use (e.g., Prometheus AI CLI).
840
986
  * @param dbPath - Path to SQLite database
841
987
  */
842
988
  static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
@@ -1130,33 +1276,58 @@ export class AuthStorage {
1130
1276
  return order;
1131
1277
  }
1132
1278
 
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);
1279
+ #toScopedBackoffKey(providerKey: string, blockScope: string | undefined): string {
1280
+ return blockScope ? `${providerKey}\0${blockScope}` : providerKey;
1281
+ }
1282
+
1283
+ /** Returns block expiry timestamp for a credential/key pair, cleaning up expired entries. */
1284
+ #getCredentialBlockedUntilForKey(backoffKey: string, credentialIndex: number): number | undefined {
1285
+ const backoffMap = this.#credentialBackoff.get(backoffKey);
1136
1286
  if (!backoffMap) return undefined;
1137
1287
  const blockedUntil = backoffMap.get(credentialIndex);
1138
1288
  if (!blockedUntil) return undefined;
1139
1289
  if (blockedUntil <= Date.now()) {
1140
1290
  backoffMap.delete(credentialIndex);
1141
1291
  if (backoffMap.size === 0) {
1142
- this.#credentialBackoff.delete(providerKey);
1292
+ this.#credentialBackoff.delete(backoffKey);
1143
1293
  }
1144
1294
  return undefined;
1145
1295
  }
1146
1296
  return blockedUntil;
1147
1297
  }
1148
1298
 
1299
+ /** Returns block expiry timestamp for a credential, checking global then scoped blocks. */
1300
+ #getCredentialBlockedUntil(
1301
+ providerKey: string,
1302
+ credentialIndex: number,
1303
+ blockScope: string | undefined = undefined,
1304
+ ): number | undefined {
1305
+ const globalBlockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialIndex);
1306
+ if (globalBlockedUntil !== undefined || !blockScope) return globalBlockedUntil;
1307
+ return this.#getCredentialBlockedUntilForKey(this.#toScopedBackoffKey(providerKey, blockScope), credentialIndex);
1308
+ }
1309
+
1149
1310
  /** 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;
1311
+ #isCredentialBlocked(
1312
+ providerKey: string,
1313
+ credentialIndex: number,
1314
+ blockScope: string | undefined = undefined,
1315
+ ): boolean {
1316
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex, blockScope) !== undefined;
1152
1317
  }
1153
1318
 
1154
1319
  /** 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>();
1320
+ #markCredentialBlocked(
1321
+ providerKey: string,
1322
+ credentialIndex: number,
1323
+ blockedUntilMs: number,
1324
+ blockScope: string | undefined = undefined,
1325
+ ): void {
1326
+ const backoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
1327
+ const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, number>();
1157
1328
  const existing = backoffMap.get(credentialIndex) ?? 0;
1158
1329
  backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
1159
- this.#credentialBackoff.set(providerKey, backoffMap);
1330
+ this.#credentialBackoff.set(backoffKey, backoffMap);
1160
1331
  }
1161
1332
 
1162
1333
  /** Records which credential was used for a session (for rate-limit switching). */
@@ -1352,6 +1523,32 @@ export class AuthStorage {
1352
1523
  this.#resetProviderAssignments(provider);
1353
1524
  }
1354
1525
 
1526
+ /**
1527
+ * List stored credential rows, optionally filtered by provider.
1528
+ */
1529
+ listStoredCredentials(provider?: string): StoredAuthCredential[] {
1530
+ if (provider !== undefined) {
1531
+ return this.#getStoredCredentials(provider).map(entry => ({
1532
+ id: entry.id,
1533
+ provider,
1534
+ credential: entry.credential,
1535
+ disabledCause: null,
1536
+ }));
1537
+ }
1538
+ const rows: StoredAuthCredential[] = [];
1539
+ for (const [storedProvider, entries] of this.#data) {
1540
+ for (const entry of entries) {
1541
+ rows.push({
1542
+ id: entry.id,
1543
+ provider: storedProvider,
1544
+ credential: entry.credential,
1545
+ disabledCause: null,
1546
+ });
1547
+ }
1548
+ }
1549
+ return rows;
1550
+ }
1551
+
1355
1552
  /**
1356
1553
  * Remove credential for a provider.
1357
1554
  */
@@ -1365,6 +1562,28 @@ export class AuthStorage {
1365
1562
  this.#resetProviderAssignments(provider);
1366
1563
  }
1367
1564
 
1565
+ /**
1566
+ * Remove one stored credential for a provider.
1567
+ */
1568
+ async removeCredential(provider: string, credentialId: number): Promise<boolean> {
1569
+ const entries = this.#getStoredCredentials(provider);
1570
+ const index = entries.findIndex(entry => entry.id === credentialId);
1571
+ if (index === -1) return false;
1572
+
1573
+ if (this.#store.deleteAuthCredentialRemote) {
1574
+ const deleted = await this.#store.deleteAuthCredentialRemote(credentialId, "deleted by user");
1575
+ if (!deleted) return false;
1576
+ } else {
1577
+ this.#store.deleteAuthCredential(credentialId, "deleted by user");
1578
+ }
1579
+ this.#setStoredCredentials(
1580
+ provider,
1581
+ entries.filter((_entry, entryIndex) => entryIndex !== index),
1582
+ );
1583
+ this.#resetProviderAssignments(provider);
1584
+ return true;
1585
+ }
1586
+
1368
1587
  /**
1369
1588
  * List all providers with credentials.
1370
1589
  */
@@ -1411,6 +1630,26 @@ export class AuthStorage {
1411
1630
  return false;
1412
1631
  }
1413
1632
 
1633
+ /**
1634
+ * Classify where a provider's auth comes from, following the same precedence
1635
+ * as {@link AuthStorage.getApiKey}: runtime override → config override →
1636
+ * stored credential (api_key before oauth, matching getApiKey) → env var →
1637
+ * fallback resolver. Returns undefined when no auth is configured.
1638
+ *
1639
+ * Compact, structured counterpart to {@link describeCredentialSource}.
1640
+ */
1641
+ getCredentialOrigin(provider: string): CredentialOrigin | undefined {
1642
+ if (this.#runtimeOverrides.has(provider)) return { kind: "runtime" };
1643
+ if (this.#configOverrides.has(provider)) return { kind: "config" };
1644
+ const stored = this.#getCredentialsForProvider(provider);
1645
+ if (stored.length > 0) {
1646
+ return { kind: stored.some(credential => credential.type === "api_key") ? "api_key" : "oauth" };
1647
+ }
1648
+ if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
1649
+ if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
1650
+ return undefined;
1651
+ }
1652
+
1414
1653
  /**
1415
1654
  * Check if OAuth credentials are configured for a provider.
1416
1655
  */
@@ -1470,6 +1709,28 @@ export class AuthStorage {
1470
1709
  return typeof accountId === "string" && accountId.length > 0 ? accountId : undefined;
1471
1710
  }
1472
1711
 
1712
+ /**
1713
+ * Get the OAuth account identity for a provider, preferring the credential that
1714
+ * is session-sticky for `sessionId`. This is a read-only lookup for display and
1715
+ * metadata paths; it does not refresh tokens, rank usage, or advance selection.
1716
+ */
1717
+ getOAuthAccountIdentity(provider: string, sessionId?: string): OAuthAccountIdentity | undefined {
1718
+ const preferred = this.#resolveActiveOAuthCredential(provider, sessionId);
1719
+ if (!preferred) return undefined;
1720
+ const identity: OAuthAccountIdentity = {};
1721
+ if (typeof preferred.accountId === "string" && preferred.accountId.length > 0) {
1722
+ identity.accountId = preferred.accountId;
1723
+ }
1724
+ if (typeof preferred.email === "string" && preferred.email.length > 0) {
1725
+ identity.email = preferred.email;
1726
+ }
1727
+ if (typeof preferred.projectId === "string" && preferred.projectId.length > 0) {
1728
+ identity.projectId = preferred.projectId;
1729
+ }
1730
+ if (!identity.accountId && !identity.email && !identity.projectId) return undefined;
1731
+ return identity;
1732
+ }
1733
+
1473
1734
  /**
1474
1735
  * Get all credentials.
1475
1736
  */
@@ -1498,345 +1759,34 @@ export class AuthStorage {
1498
1759
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
1499
1760
  },
1500
1761
  ): Promise<void> {
1501
- let credentials: OAuthCredentials;
1502
1762
  const saveApiKeyCredential = async (apiKey: string): Promise<void> => {
1503
1763
  const newCredential: ApiKeyCredential = { type: "api_key", key: apiKey };
1504
1764
  await this.set(provider, newCredential);
1505
1765
  };
1506
1766
  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);
1767
+ // Built-in registry first, then runtime-registered extension providers.
1768
+ const def = getProviderDefinition(provider) ?? getOAuthProvider(provider);
1769
+ if (!def?.login) {
1770
+ throw new Error(`Unknown OAuth provider: ${provider}`);
1771
+ }
1772
+ const result = await def.login({
1773
+ onAuth: ctrl.onAuth,
1774
+ onProgress: ctrl.onProgress,
1775
+ onPrompt: ctrl.onPrompt,
1776
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1777
+ signal: ctrl.signal,
1778
+ fetch: ctrl.fetch,
1779
+ });
1780
+ if (typeof result === "string") {
1781
+ // Some flows (e.g. ollama) return "" to signal that no key was entered.
1782
+ if (!result) {
1816
1783
  return;
1817
1784
  }
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
- }
1785
+ await saveApiKeyCredential(result);
1786
+ return;
1837
1787
  }
1838
- const newCredential: OAuthCredential = { type: "oauth", ...credentials };
1839
- await this.#upsertOAuthCredential(provider, newCredential);
1788
+ const newCredential: OAuthCredential = { type: "oauth", ...result };
1789
+ await this.#upsertOAuthCredential(def.storeCredentialsAs ?? provider, newCredential);
1840
1790
  }
1841
1791
 
1842
1792
  /**
@@ -1896,15 +1846,19 @@ export class AuthStorage {
1896
1846
  #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
1897
1847
  const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
1898
1848
  const identity = this.#buildUsageCacheIdentity(request.credential);
1899
- return `report:${request.provider}:${baseUrl}:${identity}`;
1849
+ const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
1850
+ const providerKey = versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
1851
+ return `report:${providerKey}:${baseUrl}:${identity}`;
1900
1852
  }
1901
1853
 
1902
1854
  #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
1903
1855
  const snapshot = requests
1904
- .map(
1905
- request =>
1906
- `${request.provider}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`,
1907
- )
1856
+ .map(request => {
1857
+ const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
1858
+ const providerKey =
1859
+ versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
1860
+ return `${providerKey}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`;
1861
+ })
1908
1862
  .sort()
1909
1863
  .join("\n");
1910
1864
  return `reports:${Bun.hash(snapshot).toString(16)}`;
@@ -2146,6 +2100,7 @@ export class AuthStorage {
2146
2100
  // fan-out trips 429s every cycle. With ±25% jitter on TTL the refresh
2147
2101
  // times decorrelate within a few cycles.
2148
2102
  this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
2103
+ this.#recordUsageHistory(request, report);
2149
2104
  return report;
2150
2105
  }
2151
2106
  // Failure: cache the LAST GOOD value (if any) with a short jittered TTL
@@ -2167,6 +2122,50 @@ export class AuthStorage {
2167
2122
  return promise;
2168
2123
  }
2169
2124
 
2125
+ /**
2126
+ * Append a freshly fetched report to durable usage history (when the store
2127
+ * supports it). The usage cache is latest-snapshot-only — these rows are
2128
+ * the only place limit utilization is kept over time.
2129
+ */
2130
+ #recordUsageHistory(request: UsageRequestDescriptor, report: UsageReport): void {
2131
+ const record = this.#store.recordUsageSnapshots;
2132
+ if (!record || report.limits.length === 0) return;
2133
+ const recordedAt = Number.isFinite(report.fetchedAt) && report.fetchedAt > 0 ? report.fetchedAt : Date.now();
2134
+ const accountKey = this.#buildUsageCacheIdentity(request.credential);
2135
+ const metadata = report.metadata ?? {};
2136
+ const metaEmail = typeof metadata.email === "string" ? metadata.email : undefined;
2137
+ const metaAccountId = typeof metadata.accountId === "string" ? metadata.accountId : undefined;
2138
+ const entries: UsageHistoryEntry[] = report.limits.map(limit => ({
2139
+ recordedAt,
2140
+ provider: request.provider,
2141
+ accountKey,
2142
+ email: request.credential.email ?? metaEmail,
2143
+ accountId: request.credential.accountId ?? limit.scope.accountId ?? metaAccountId,
2144
+ limitId: limit.id,
2145
+ label: limit.label,
2146
+ windowLabel: limit.window?.label ?? limit.scope.windowId,
2147
+ usedFraction: resolveUsedFraction(limit),
2148
+ status: limit.status,
2149
+ resetsAt: limit.window?.resetsAt,
2150
+ }));
2151
+ try {
2152
+ record.call(this.#store, entries);
2153
+ } catch (error) {
2154
+ this.#usageLogger?.debug("usage history record failed", {
2155
+ provider: request.provider,
2156
+ error: String(error),
2157
+ });
2158
+ }
2159
+ }
2160
+
2161
+ /**
2162
+ * Recorded usage-limit snapshots, oldest first. Empty when the underlying
2163
+ * store has no durable history (e.g. a broker-backed remote store).
2164
+ */
2165
+ listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
2166
+ return this.#store.listUsageHistory?.(query) ?? [];
2167
+ }
2168
+
2170
2169
  ingestUsageHeaders(
2171
2170
  provider: Provider,
2172
2171
  headers: Record<string, string>,
@@ -2288,6 +2287,16 @@ export class AuthStorage {
2288
2287
  return undefined;
2289
2288
  }
2290
2289
 
2290
+ #getUsageReportScopeProjectId(report: UsageReport): string | undefined {
2291
+ const ids = new Set<string>();
2292
+ for (const limit of report.limits) {
2293
+ const projectId = limit.scope.projectId?.trim();
2294
+ if (projectId) ids.add(projectId);
2295
+ }
2296
+ if (ids.size === 1) return [...ids][0];
2297
+ return undefined;
2298
+ }
2299
+
2291
2300
  #getUsageReportIdentifiers(report: UsageReport): string[] {
2292
2301
  const identifiers: string[] = [];
2293
2302
  const email = this.#getUsageReportMetadataValue(report, "email");
@@ -2295,6 +2304,11 @@ export class AuthStorage {
2295
2304
  if (report.provider === "openai-codex" || report.provider === "anthropic") {
2296
2305
  return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
2297
2306
  }
2307
+ const projectId =
2308
+ this.#getUsageReportMetadataValue(report, "projectId") ?? this.#getUsageReportScopeProjectId(report);
2309
+ // Only add project as a fallback when no email is available — two users
2310
+ // with different emails on the same GCP project must not merge.
2311
+ if (projectId && !email) identifiers.push(`project:${projectId}`);
2298
2312
  const accountId = this.#getUsageReportMetadataValue(report, "accountId");
2299
2313
  if (accountId) identifiers.push(`account:${accountId}`);
2300
2314
  const account = this.#getUsageReportMetadataValue(report, "account");
@@ -2391,15 +2405,24 @@ export class AuthStorage {
2391
2405
  return false;
2392
2406
  }
2393
2407
 
2408
+ /** Return the usage limits that apply to the requested model for this strategy. */
2409
+ #getScopedUsageLimits(
2410
+ strategy: CredentialRankingStrategy,
2411
+ report: UsageReport,
2412
+ context: CredentialRankingContext,
2413
+ ): UsageLimit[] {
2414
+ return strategy.scopeLimits?.(report, context) ?? report.limits;
2415
+ }
2416
+
2394
2417
  /** Returns true if usage indicates rate limit has been reached. */
2395
- #isUsageLimitReached(report: UsageReport): boolean {
2396
- return report.limits.some(limit => this.#isUsageLimitExhausted(limit));
2418
+ #isUsageLimitReached(limits: UsageLimit[]): boolean {
2419
+ return limits.some(limit => this.#isUsageLimitExhausted(limit));
2397
2420
  }
2398
2421
 
2399
2422
  /** Extracts the earliest reset timestamp from exhausted windows (in ms). */
2400
- #getUsageResetAtMs(report: UsageReport, nowMs: number): number | undefined {
2423
+ #getUsageResetAtMs(limits: UsageLimit[], nowMs: number): number | undefined {
2401
2424
  const candidates: number[] = [];
2402
- for (const limit of report.limits) {
2425
+ for (const limit of limits) {
2403
2426
  if (!this.#isUsageLimitExhausted(limit)) continue;
2404
2427
  const window = limit.window;
2405
2428
  if (window?.resetsAt && window.resetsAt > nowMs) {
@@ -2685,34 +2708,42 @@ export class AuthStorage {
2685
2708
  /**
2686
2709
  * Marks the current session's credential as temporarily blocked due to usage limits.
2687
2710
  * 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.
2711
+ * Returns whether a sibling credential is available now; when none is, also
2712
+ * reports the earliest time a blocked sibling becomes available again so
2713
+ * callers can wait for the sibling instead of the provider's full window.
2689
2714
  */
2690
2715
  async markUsageLimitReached(
2691
2716
  provider: string,
2692
2717
  sessionId: string | undefined,
2693
- options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
2694
- ): Promise<boolean> {
2718
+ options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; signal?: AbortSignal },
2719
+ ): Promise<UsageLimitMarkResult> {
2695
2720
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
2696
- if (!sessionCredential) return false;
2721
+ if (!sessionCredential) return { switched: false };
2697
2722
 
2698
2723
  const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
2724
+ const strategy = this.#rankingStrategyResolver?.(provider);
2725
+ const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
2726
+ const blockScope = strategy?.blockScope?.(rankingContext);
2699
2727
  const now = Date.now();
2700
2728
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
2701
2729
 
2702
- if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
2730
+ if (sessionCredential.type === "oauth" && strategy) {
2703
2731
  const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
2704
2732
  if (credential?.type === "oauth") {
2705
2733
  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;
2734
+ if (report) {
2735
+ const scopedLimits = this.#getScopedUsageLimits(strategy, report, rankingContext);
2736
+ if (this.#isUsageLimitReached(scopedLimits)) {
2737
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
2738
+ if (resetAtMs && resetAtMs > blockedUntil) {
2739
+ blockedUntil = resetAtMs;
2740
+ }
2710
2741
  }
2711
2742
  }
2712
2743
  }
2713
2744
  }
2714
2745
 
2715
- this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil);
2746
+ this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil, blockScope);
2716
2747
 
2717
2748
  const remainingCredentials = this.#getCredentialsForProvider(provider)
2718
2749
  .map((credential, index) => ({ credential, index }))
@@ -2721,7 +2752,13 @@ export class AuthStorage {
2721
2752
  entry.credential.type === sessionCredential.type && entry.index !== sessionCredential.index,
2722
2753
  );
2723
2754
 
2724
- return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
2755
+ let retryAtMs: number | undefined;
2756
+ for (const candidate of remainingCredentials) {
2757
+ const candidateBlockedUntil = this.#getCredentialBlockedUntil(providerKey, candidate.index, blockScope);
2758
+ if (candidateBlockedUntil === undefined) return { switched: true };
2759
+ if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
2760
+ }
2761
+ return { switched: false, retryAtMs };
2725
2762
  }
2726
2763
 
2727
2764
  #resolveWindowResetAt(window: UsageLimit["window"]): number | undefined {
@@ -2781,12 +2818,14 @@ export class AuthStorage {
2781
2818
  return left.planPriority - right.planPriority;
2782
2819
  }
2783
2820
  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;
2821
+ let metric = compareUsageRankingMetric(left.secondaryDrainRate, right.secondaryDrainRate);
2822
+ if (metric !== 0) return metric;
2823
+ metric = compareUsageRankingMetric(left.secondaryUsed, right.secondaryUsed);
2824
+ if (metric !== 0) return metric;
2825
+ metric = compareUsageRankingMetric(left.primaryDrainRate, right.primaryDrainRate);
2826
+ if (metric !== 0) return metric;
2827
+ metric = compareUsageRankingMetric(left.primaryUsed, right.primaryUsed);
2828
+ if (metric !== 0) return metric;
2790
2829
  return 0;
2791
2830
  }
2792
2831
 
@@ -2880,6 +2919,8 @@ export class AuthStorage {
2880
2919
  options?: AuthApiKeyOptions;
2881
2920
  sessionId?: string;
2882
2921
  strategy: CredentialRankingStrategy;
2922
+ rankingContext: CredentialRankingContext;
2923
+ blockScope?: string;
2883
2924
  }): Promise<OAuthCandidate[]> {
2884
2925
  const nowMs = Date.now();
2885
2926
  const { strategy } = args;
@@ -2893,7 +2934,7 @@ export class AuthStorage {
2893
2934
  args.order.map(async idx => {
2894
2935
  const selection = args.credentials[idx];
2895
2936
  if (!selection) return null;
2896
- const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
2937
+ const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index, args.blockScope);
2897
2938
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
2898
2939
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
2899
2940
  ...args.options,
@@ -2926,13 +2967,14 @@ export class AuthStorage {
2926
2967
  const { selection, usage, usageChecked } = result;
2927
2968
  let { blockedUntil } = result;
2928
2969
  let blocked = blockedUntil !== undefined;
2929
- if (!blocked && usage && this.#isUsageLimitReached(usage)) {
2930
- const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
2970
+ const scopedLimits = usage ? this.#getScopedUsageLimits(strategy, usage, args.rankingContext) : undefined;
2971
+ if (!blocked && scopedLimits && this.#isUsageLimitReached(scopedLimits)) {
2972
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, nowMs);
2931
2973
  blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
2932
- this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
2974
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil, args.blockScope);
2933
2975
  blocked = true;
2934
2976
  }
2935
- const windows = usage ? strategy.findWindowLimits(usage) : undefined;
2977
+ const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
2936
2978
  const primary = windows?.primary;
2937
2979
  const secondary = windows?.secondary;
2938
2980
  const secondaryTarget = secondary ?? primary;
@@ -2981,6 +3023,8 @@ export class AuthStorage {
2981
3023
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
2982
3024
  const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
2983
3025
  const strategy = this.#rankingStrategyResolver?.(provider);
3026
+ const rankingContext: CredentialRankingContext = { modelId: options?.modelId };
3027
+ const blockScope = strategy?.blockScope?.(rankingContext);
2984
3028
  const requiresProModel = requiresOpenAICodexProModel(provider, options?.modelId);
2985
3029
  const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
2986
3030
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
@@ -2990,7 +3034,8 @@ export class AuthStorage {
2990
3034
  // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
2991
3035
  // with the most headroom proactively and fall back intelligently when rate-limited.
2992
3036
  const sessionPreferredIsAvailable =
2993
- sessionPreferredIndex !== undefined && !this.#isCredentialBlocked(providerKey, sessionPreferredIndex);
3037
+ sessionPreferredIndex !== undefined &&
3038
+ !this.#isCredentialBlocked(providerKey, sessionPreferredIndex, blockScope);
2994
3039
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
2995
3040
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
2996
3041
  const candidates = shouldRank
@@ -3002,6 +3047,8 @@ export class AuthStorage {
3002
3047
  options,
3003
3048
  sessionId,
3004
3049
  strategy: strategy!,
3050
+ rankingContext,
3051
+ blockScope,
3005
3052
  })
3006
3053
  : order
3007
3054
  .map(idx => credentials[idx])
@@ -3011,7 +3058,7 @@ export class AuthStorage {
3011
3058
  if (sessionPreferredIndex !== undefined && !requiresProModel) {
3012
3059
  const sessionPreferredCandidate = candidates.findIndex(
3013
3060
  candidate =>
3014
- !this.#isCredentialBlocked(providerKey, candidate.selection.index) &&
3061
+ !this.#isCredentialBlocked(providerKey, candidate.selection.index, blockScope) &&
3015
3062
  candidate.selection.index === sessionPreferredIndex,
3016
3063
  );
3017
3064
  if (sessionPreferredCandidate > 0) {
@@ -3019,19 +3066,46 @@ export class AuthStorage {
3019
3066
  candidates.unshift(preferred);
3020
3067
  }
3021
3068
  }
3069
+ // Step (b) of the auth-retry policy: when `forceRefresh` is set, re-mint
3070
+ // the session-preferred credential (or the first candidate when no
3071
+ // session preference exists yet) even if its cached token still looks
3072
+ // valid — a peer/broker may have rotated it out from under us.
3073
+ const forceRefreshIndex = options?.forceRefresh
3074
+ ? (sessionPreferredIndex ?? candidates[0]?.selection.index)
3075
+ : undefined;
3022
3076
  await Promise.all(
3023
3077
  candidates.map(async candidate => {
3024
- if (Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires) return;
3078
+ const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
3079
+ const initialCredentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
3080
+ let syncedPeerCredential = false;
3081
+ if (initialCredentialId !== undefined) {
3082
+ const beforeSync = candidate.selection.credential;
3083
+ if (!this.#syncOAuthSelectionFromStore(provider, candidate.selection, initialCredentialId)) return;
3084
+ syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
3085
+ }
3086
+ const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
3087
+ if ((!force || syncedPeerCredential) && hasFreshAccess) return;
3025
3088
  const latestCredential = this.#getCredentialsForProvider(provider)[candidate.selection.index];
3026
- if (latestCredential?.type === "oauth" && Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires) {
3089
+ if (
3090
+ !force &&
3091
+ latestCredential?.type === "oauth" &&
3092
+ Date.now() + OAUTH_REFRESH_SKEW_MS < latestCredential.expires
3093
+ ) {
3027
3094
  candidate.selection.credential = latestCredential;
3028
3095
  return;
3029
3096
  }
3030
3097
  try {
3031
3098
  const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
3099
+ // Hand #refreshOAuthCredential a stale clone (expires:0) so its
3100
+ // not-yet-expired short-circuit doesn't suppress the forced
3101
+ // re-mint; an in-flight peer refresh is still awaited via the
3102
+ // per-credential single-flight.
3103
+ const refreshTarget = force
3104
+ ? { ...candidate.selection.credential, expires: 0 }
3105
+ : candidate.selection.credential;
3032
3106
  const refreshedCredentials = await this.#refreshOAuthCredential(
3033
3107
  provider,
3034
- candidate.selection.credential,
3108
+ refreshTarget,
3035
3109
  credentialId,
3036
3110
  options?.signal,
3037
3111
  );
@@ -3042,7 +3116,17 @@ export class AuthStorage {
3042
3116
  };
3043
3117
  candidate.selection.credential = updated;
3044
3118
  this.#replaceCredentialAt(provider, candidate.selection.index, updated);
3045
- } catch {}
3119
+ } catch (error) {
3120
+ // Recovery for definitive failures (incl. peer rotation) lives in
3121
+ // #tryOAuthCredential; log instead of swallowing silently — a bare
3122
+ // catch here hid stale-refresh-token replays from concurrent
3123
+ // sessions (one-turn 401 "Invalid authentication credentials").
3124
+ logger.debug("OAuth preflight refresh failed", {
3125
+ provider,
3126
+ index: candidate.selection.index,
3127
+ error: String(error),
3128
+ });
3129
+ }
3046
3130
  }),
3047
3131
  );
3048
3132
 
@@ -3066,18 +3150,24 @@ export class AuthStorage {
3066
3150
  prefetchedUsage: candidate.usage,
3067
3151
  usagePrechecked: candidate.usageChecked,
3068
3152
  enforceProRequirement,
3153
+ strategy,
3154
+ rankingContext,
3155
+ blockScope,
3069
3156
  },
3070
3157
  );
3071
3158
  if (resolved) return resolved;
3072
3159
  }
3073
3160
 
3074
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
3161
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, blockScope)) {
3075
3162
  return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3076
3163
  checkUsage,
3077
3164
  allowBlocked: true,
3078
3165
  prefetchedUsage: fallback.usage,
3079
3166
  usagePrechecked: fallback.usageChecked,
3080
3167
  enforceProRequirement,
3168
+ strategy,
3169
+ rankingContext,
3170
+ blockScope,
3081
3171
  });
3082
3172
  }
3083
3173
 
@@ -3156,25 +3246,17 @@ export class AuthStorage {
3156
3246
  }
3157
3247
  }
3158
3248
 
3159
- async #prepareOAuthCredentialForRequest(
3249
+ #syncOAuthSelectionFromStore(
3160
3250
  provider: string,
3161
3251
  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;
3252
+ credentialId: number,
3253
+ ): boolean {
3172
3254
  const latestRows = this.#store.listAuthCredentials(provider);
3173
3255
  this.#setStoredCredentials(
3174
3256
  provider,
3175
3257
  latestRows.map(row => ({ id: row.id, credential: row.credential })),
3176
3258
  );
3177
- const latestIndex = latestRows.findIndex(row => row.id === selected.id);
3259
+ const latestIndex = latestRows.findIndex(row => row.id === credentialId);
3178
3260
  if (latestIndex === -1) return false;
3179
3261
  const latest = latestRows[latestIndex];
3180
3262
  if (latest?.credential.type !== "oauth") return false;
@@ -3183,6 +3265,22 @@ export class AuthStorage {
3183
3265
  return true;
3184
3266
  }
3185
3267
 
3268
+ async #prepareOAuthCredentialForRequest(
3269
+ provider: string,
3270
+ selection: { credential: OAuthCredential; index: number },
3271
+ options: AuthApiKeyOptions | undefined,
3272
+ ): Promise<boolean> {
3273
+ const stored = this.#getStoredCredentials(provider);
3274
+ const selected = stored[selection.index];
3275
+ if (selected?.credential.type !== "oauth") return false;
3276
+
3277
+ const prepare = this.#store.prepareForRequest?.bind(this.#store);
3278
+ if (prepare) {
3279
+ await prepare(selected.id, { signal: options?.signal });
3280
+ }
3281
+ return this.#syncOAuthSelectionFromStore(provider, selection, selected.id);
3282
+ }
3283
+
3186
3284
  /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
3187
3285
  async #tryOAuthCredential(
3188
3286
  provider: Provider,
@@ -3196,6 +3294,9 @@ export class AuthStorage {
3196
3294
  prefetchedUsage?: UsageReport | null;
3197
3295
  usagePrechecked?: boolean;
3198
3296
  enforceProRequirement?: boolean;
3297
+ strategy?: CredentialRankingStrategy;
3298
+ rankingContext?: CredentialRankingContext;
3299
+ blockScope?: string;
3199
3300
  },
3200
3301
  ): Promise<OAuthResolutionResult | undefined> {
3201
3302
  const {
@@ -3204,8 +3305,11 @@ export class AuthStorage {
3204
3305
  prefetchedUsage = null,
3205
3306
  usagePrechecked = false,
3206
3307
  enforceProRequirement,
3308
+ strategy,
3309
+ rankingContext,
3310
+ blockScope,
3207
3311
  } = usageOptions;
3208
- if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
3312
+ if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, blockScope)) {
3209
3313
  return undefined;
3210
3314
  }
3211
3315
 
@@ -3232,14 +3336,18 @@ export class AuthStorage {
3232
3336
  if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3233
3337
  return undefined;
3234
3338
  }
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;
3339
+ if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
3340
+ const scopedLimits = this.#getScopedUsageLimits(strategy, usage, rankingContext);
3341
+ if (this.#isUsageLimitReached(scopedLimits)) {
3342
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3343
+ this.#markCredentialBlocked(
3344
+ providerKey,
3345
+ selection.index,
3346
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
3347
+ blockScope,
3348
+ );
3349
+ return undefined;
3350
+ }
3243
3351
  }
3244
3352
  }
3245
3353
 
@@ -3298,14 +3406,18 @@ export class AuthStorage {
3298
3406
  if (applyProFilter && !hasOpenAICodexProPlan(usage)) {
3299
3407
  return undefined;
3300
3408
  }
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;
3409
+ if (checkUsage && !allowBlocked && usage && strategy && rankingContext) {
3410
+ const scopedLimits = this.#getScopedUsageLimits(strategy, usage, rankingContext);
3411
+ if (this.#isUsageLimitReached(scopedLimits)) {
3412
+ const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3413
+ this.#markCredentialBlocked(
3414
+ providerKey,
3415
+ selection.index,
3416
+ resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
3417
+ blockScope,
3418
+ );
3419
+ return undefined;
3420
+ }
3309
3421
  }
3310
3422
  }
3311
3423
  this.#recordSessionCredential(provider, sessionId, "oauth", selection.index);
@@ -3575,6 +3687,151 @@ export class AuthStorage {
3575
3687
  );
3576
3688
  }
3577
3689
 
3690
+ /**
3691
+ * List saved rate-limit resets for every stored OAuth account of `provider`
3692
+ * (Codex), fetched LIVE from the dedicated `rate-limit-reset-credits` route.
3693
+ *
3694
+ * This deliberately bypasses the usage-report cache: `/wham/usage` is
3695
+ * IP-rate-limited and may serve stale (or pre-feature) snapshots when many
3696
+ * accounts are polled, which would hide redeemable credits. One entry per
3697
+ * account, with the session's active account flagged and unreachable
3698
+ * accounts carrying an `error`.
3699
+ */
3700
+ async listResetCredits(options?: {
3701
+ provider?: string;
3702
+ sessionId?: string;
3703
+ baseUrlResolver?: (provider: string) => string | undefined;
3704
+ signal?: AbortSignal;
3705
+ }): Promise<ResetCreditAccountStatus[]> {
3706
+ const provider = options?.provider ?? "openai-codex";
3707
+ const accesses = await this.getOAuthAccesses(provider);
3708
+ if (accesses.length === 0) return [];
3709
+ const baseUrl = options?.baseUrlResolver?.(provider);
3710
+ const activeId = this.getOAuthAccountIdentity(provider, options?.sessionId);
3711
+ return Promise.all(
3712
+ accesses.map(async (access): Promise<ResetCreditAccountStatus> => {
3713
+ const active =
3714
+ !!activeId &&
3715
+ ((!!activeId.accountId && activeId.accountId === access.accountId) ||
3716
+ (!!activeId.email && activeId.email === access.email));
3717
+ const base = {
3718
+ credentialId: access.credentialId,
3719
+ accountId: access.accountId,
3720
+ email: access.email,
3721
+ active,
3722
+ };
3723
+ if (!access.ok) return { ...base, availableCount: 0, credits: [], error: access.error };
3724
+ const list = await listCodexResetCredits({
3725
+ accessToken: access.accessToken,
3726
+ accountId: access.accountId,
3727
+ baseUrl,
3728
+ fetch: this.#usageFetch,
3729
+ signal: options?.signal,
3730
+ });
3731
+ if (!list) return { ...base, availableCount: 0, credits: [], error: "Failed to load saved resets" };
3732
+ return { ...base, availableCount: list.availableCount, credits: list.credits };
3733
+ }),
3734
+ );
3735
+ }
3736
+
3737
+ /**
3738
+ * Redeem one saved rate-limit reset (OpenAI Codex "saved resets") for a
3739
+ * specific stored account.
3740
+ *
3741
+ * Resolves a fresh access token for the target account, picks an available
3742
+ * credit (the given `creditId`, else the first redeemable one), spends it,
3743
+ * and invalidates the cached usage report so the next `/usage` reflects the
3744
+ * reset. Never throws for business outcomes — inspect the returned `code`.
3745
+ */
3746
+ async redeemResetCredit(options: {
3747
+ target: ResetCreditTarget;
3748
+ provider?: string;
3749
+ creditId?: string;
3750
+ baseUrlResolver?: (provider: string) => string | undefined;
3751
+ signal?: AbortSignal;
3752
+ }): Promise<ResetCreditRedeemOutcome> {
3753
+ const provider = options.provider ?? "openai-codex";
3754
+ const baseUrl = options.baseUrlResolver?.(provider);
3755
+ const { target } = options;
3756
+ const accesses = await this.getOAuthAccesses(provider);
3757
+ const match = accesses.find(
3758
+ access =>
3759
+ (target.credentialId !== undefined && access.credentialId === target.credentialId) ||
3760
+ (!!target.accountId && access.accountId === target.accountId) ||
3761
+ (!!target.email && access.email === target.email),
3762
+ );
3763
+ if (!match) return { ok: false, code: "no_account", accountId: target.accountId, email: target.email };
3764
+ if (!match.ok) {
3765
+ return { ok: false, code: "account_unavailable", accountId: match.accountId, email: match.email };
3766
+ }
3767
+
3768
+ let creditId = options.creditId;
3769
+ if (!creditId) {
3770
+ const list = await listCodexResetCredits({
3771
+ accessToken: match.accessToken,
3772
+ accountId: match.accountId,
3773
+ baseUrl,
3774
+ fetch: this.#usageFetch,
3775
+ signal: options.signal,
3776
+ });
3777
+ const credit = list?.credits.find(entry => (entry.status ?? "available") === "available") ?? list?.credits[0];
3778
+ if (!credit) return { ok: false, code: "no_credit", accountId: match.accountId, email: match.email };
3779
+ creditId = credit.id;
3780
+ }
3781
+
3782
+ const result = await consumeCodexResetCredit({
3783
+ creditId,
3784
+ accessToken: match.accessToken,
3785
+ accountId: match.accountId,
3786
+ baseUrl,
3787
+ fetch: this.#usageFetch,
3788
+ signal: options.signal,
3789
+ });
3790
+ if (result.ok) {
3791
+ this.#invalidateUsageReportCache(provider, baseUrl);
3792
+ // The window this credential was blocked on (by markUsageLimitReached)
3793
+ // is now reset, so lift its temporary block — otherwise selection
3794
+ // keeps skipping/under-ranking the freshly-reset account.
3795
+ if (match.credentialId !== undefined) this.#clearCredentialBlocks(provider, match.credentialId);
3796
+ }
3797
+ return { ok: result.ok, code: result.code, accountId: match.accountId, email: match.email, creditId };
3798
+ }
3799
+
3800
+ /**
3801
+ * Force the next usage fetch for `provider` to bypass the 5-min cache, so
3802
+ * `/usage` reflects a freshly-redeemed reset instead of stale numbers.
3803
+ */
3804
+ #invalidateUsageReportCache(provider: string, baseUrl?: string): void {
3805
+ const expired = Date.now() - 1;
3806
+ for (const entry of this.#getStoredCredentials(provider)) {
3807
+ if (entry.credential.type !== "oauth") continue;
3808
+ const cacheKey = this.#buildUsageReportCacheKey(
3809
+ this.#buildUsageRequestForOauth(provider, entry.credential, baseUrl),
3810
+ );
3811
+ const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
3812
+ this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired });
3813
+ }
3814
+ }
3815
+
3816
+ /**
3817
+ * Lift any temporary backoff blocks on one credential (across the bare
3818
+ * `provider:oauth` key and its scoped `\0`-suffixed derivatives). Called
3819
+ * after a saved reset is redeemed so the just-reset account is immediately
3820
+ * selectable again instead of being skipped/under-ranked by a stale block
3821
+ * that `markUsageLimitReached` set for the now-obsolete reset time.
3822
+ */
3823
+ #clearCredentialBlocks(provider: string, credentialId: number): void {
3824
+ const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
3825
+ if (index < 0) return;
3826
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
3827
+ const scopedPrefix = `${providerKey}\0`;
3828
+ for (const [key, backoffMap] of this.#credentialBackoff) {
3829
+ if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
3830
+ backoffMap.delete(index);
3831
+ if (backoffMap.size === 0) this.#credentialBackoff.delete(key);
3832
+ }
3833
+ }
3834
+
3578
3835
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
3579
3836
  if (!apiKey.startsWith("{")) return undefined;
3580
3837
  try {
@@ -3643,6 +3900,95 @@ export class AuthStorage {
3643
3900
  return true;
3644
3901
  }
3645
3902
 
3903
+ /**
3904
+ * Rotate away from the session's current credential after a retryable auth
3905
+ * error — step (c) of the auth-retry policy. Stateless: looks up the
3906
+ * session-sticky credential (no API-key matching needed), applies the
3907
+ * storage action for the error class, then clears the sticky so the next
3908
+ * {@link AuthStorage.getApiKey} for this session picks a sibling.
3909
+ *
3910
+ * - usage-limit / account-rate-limit error → {@link AuthStorage.markUsageLimitReached}
3911
+ * (temporary block via its own backoff — default plus server usage-report
3912
+ * reset; sticky left intact so the next resolve re-ranks around the block).
3913
+ * - otherwise (hard 401 / auth failure) → mark the credential suspect (or
3914
+ * reload when no broker hook is wired) and block it, then drop the sticky.
3915
+ *
3916
+ * Returns whether another usable credential of the same type remains.
3917
+ */
3918
+ async rotateSessionCredential(
3919
+ provider: string,
3920
+ sessionId: string | undefined,
3921
+ options?: { error?: unknown; modelId?: string; signal?: AbortSignal },
3922
+ ): Promise<boolean> {
3923
+ const sessionCredential = this.#getSessionCredential(provider, sessionId);
3924
+ if (!sessionCredential) return false;
3925
+
3926
+ const error = options?.error;
3927
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : undefined;
3928
+ if (message && isUsageLimitError(message)) {
3929
+ return (
3930
+ await this.markUsageLimitReached(provider, sessionId, {
3931
+ modelId: options?.modelId,
3932
+ signal: options?.signal,
3933
+ })
3934
+ ).switched;
3935
+ }
3936
+
3937
+ const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
3938
+ // Snapshot sibling availability before mutating so a soft-deleting
3939
+ // suspect hook can't reindex the answer out from under us.
3940
+ const hasSibling = this.#getCredentialsForProvider(provider).some(
3941
+ (credential, index) =>
3942
+ credential.type === sessionCredential.type &&
3943
+ index !== sessionCredential.index &&
3944
+ !this.#isCredentialBlocked(providerKey, index),
3945
+ );
3946
+ const target = this.#getStoredCredentials(provider)[sessionCredential.index];
3947
+ this.#clearSessionCredential(provider, sessionId);
3948
+ this.#markCredentialBlocked(providerKey, sessionCredential.index, Date.now() + AuthStorage.#defaultBackoffMs);
3949
+
3950
+ if (target) {
3951
+ const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
3952
+ if (markSuspect) {
3953
+ await markSuspect(target.id, { signal: options?.signal });
3954
+ } else {
3955
+ await this.reload();
3956
+ }
3957
+ const latestRows = this.#store.listAuthCredentials(provider);
3958
+ this.#setStoredCredentials(
3959
+ provider,
3960
+ latestRows.map(row => ({ id: row.id, credential: row.credential })),
3961
+ );
3962
+ }
3963
+
3964
+ return hasSibling;
3965
+ }
3966
+
3967
+ /**
3968
+ * Build an {@link ApiKeyResolver} backed by this storage, implementing the
3969
+ * central a/b/c auth-retry policy:
3970
+ *
3971
+ * - initial (`error: undefined`) → resolve the session credential.
3972
+ * - step (b) `!lastChance` → force-refresh the SAME session-sticky credential.
3973
+ * - step (c) `lastChance` → rotate to a sibling credential, then re-resolve.
3974
+ *
3975
+ * Used by web-search providers and other consumers that hold an AuthStorage
3976
+ * directly (no ModelRegistry in scope).
3977
+ */
3978
+ resolver(provider: string, options?: { sessionId?: string; baseUrl?: string; modelId?: string }): ApiKeyResolver {
3979
+ const { sessionId, baseUrl, modelId } = options ?? {};
3980
+ return async ({ lastChance, error, signal }) => {
3981
+ if (error === undefined) {
3982
+ return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
3983
+ }
3984
+ if (lastChance) {
3985
+ await this.rotateSessionCredential(provider, sessionId, { error, modelId, signal });
3986
+ return this.getApiKey(provider, sessionId, { baseUrl, modelId, signal });
3987
+ }
3988
+ return this.getApiKey(provider, sessionId, { baseUrl, modelId, forceRefresh: true, signal });
3989
+ };
3990
+ }
3991
+
3646
3992
  // ─── Auth Broker integration ────────────────────────────────────────────
3647
3993
 
3648
3994
  /**
@@ -3861,6 +4207,17 @@ type SerializedCredentialRecord = {
3861
4207
  const AUTH_SCHEMA_VERSION = 4;
3862
4208
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
3863
4209
 
4210
+ /**
4211
+ * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended
4212
+ * variants `SQLITE_BUSY_RECOVERY` (concurrent WAL recovery), `SQLITE_BUSY_SNAPSHOT`,
4213
+ * and `SQLITE_BUSY_TIMEOUT`. All warrant the same backoff-and-retry treatment.
4214
+ */
4215
+ export function isSqliteBusyError(err: unknown): boolean {
4216
+ if (err === null || typeof err !== "object") return false;
4217
+ const code = (err as { code?: unknown }).code;
4218
+ return typeof code === "string" && code.startsWith("SQLITE_BUSY");
4219
+ }
4220
+
3864
4221
  function normalizeStoredAccountId(accountId: string | null | undefined): string | null {
3865
4222
  const normalized = accountId?.trim();
3866
4223
  return normalized && normalized.length > 0 ? normalized : null;
@@ -3932,6 +4289,8 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
3932
4289
  const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
3933
4290
  if (accountIdentifier) return accountIdentifier;
3934
4291
  if (emailIdentifier) return emailIdentifier;
4292
+ const projectIdentifier = identifiers.find(identifier => identifier.startsWith("project:"));
4293
+ if (projectIdentifier) return projectIdentifier;
3935
4294
  return null;
3936
4295
  }
3937
4296
 
@@ -3967,6 +4326,8 @@ function extractOAuthCredentialIdentifiers(credential: OAuthCredential): string[
3967
4326
  if (accountId) identifiers.add(`account:${accountId}`);
3968
4327
  const email = normalizeStoredEmail(credential.email);
3969
4328
  if (email) identifiers.add(`email:${email}`);
4329
+ const projectId = normalizeStoredAccountId(credential.projectId);
4330
+ if (projectId) identifiers.add(`project:${projectId}`);
3970
4331
  const accessIdentifiers = extractOAuthTokenIdentifiers(credential.access) ?? [];
3971
4332
  for (const identifier of accessIdentifiers) {
3972
4333
  identifiers.add(identifier);
@@ -4023,7 +4384,7 @@ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | und
4023
4384
  /**
4024
4385
  * Default SQLite-backed implementation of {@link AuthCredentialStore}.
4025
4386
  *
4026
- * Used by the @prometheus-ai/ai CLI and as the default store for `AuthStorage.create()`.
4387
+ * Used by the Prometheus AI CLI and as the default store for `AuthStorage.create()`.
4027
4388
  * Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
4028
4389
  * `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
4029
4390
  * without going through `AuthStorage`.
@@ -4043,6 +4404,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4043
4404
  #getCacheIncludingExpiredStmt: Statement;
4044
4405
  #upsertCacheStmt: Statement;
4045
4406
  #deleteExpiredCacheStmt: Statement;
4407
+ #insertUsageHistoryStmt: Statement;
4408
+ #lastUsageHistoryStmt: Statement;
4409
+ #listUsageHistoryStmt: Statement;
4410
+ #updateUsageHistoryStmt: Statement;
4046
4411
  #closed = false;
4047
4412
 
4048
4413
  constructor(db: Database) {
@@ -4082,6 +4447,18 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4082
4447
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
4083
4448
  );
4084
4449
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
4450
+ this.#insertUsageHistoryStmt = this.#db.prepare(
4451
+ "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
4452
+ );
4453
+ this.#lastUsageHistoryStmt = this.#db.prepare(
4454
+ "SELECT id, recorded_at FROM usage_history WHERE provider = ? AND account_key = ? AND limit_id = ? ORDER BY recorded_at DESC LIMIT 1",
4455
+ );
4456
+ this.#updateUsageHistoryStmt = this.#db.prepare(
4457
+ "UPDATE usage_history SET recorded_at = ?, email = ?, account_id = ?, label = ?, window_label = ?, used_fraction = ?, status = ?, resets_at = ? WHERE id = ?",
4458
+ );
4459
+ this.#listUsageHistoryStmt = this.#db.prepare(
4460
+ "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC",
4461
+ );
4085
4462
  }
4086
4463
 
4087
4464
  static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
@@ -4094,21 +4471,49 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4094
4471
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
4095
4472
  }
4096
4473
 
4097
- const db = new Database(dbPath);
4098
- try {
4099
- await fs.chmod(dbPath, 0o600);
4100
- } catch {
4101
- // Ignore chmod failures (e.g., Windows)
4474
+ // Concurrent prometheus startups can race against WAL recovery and the schema
4475
+ // init's first lock-taking statement. Bun's default `busy_timeout` is 0,
4476
+ // so retry the open on `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY` with bounded
4477
+ // exponential backoff before surfacing the failure. See issue #2421.
4478
+ const maxAttempts = 4;
4479
+ const baseDelayMs = 100;
4480
+ let lastBusyError: Error | undefined;
4481
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
4482
+ let db: Database | undefined;
4483
+ try {
4484
+ db = new Database(dbPath);
4485
+ try {
4486
+ await fs.chmod(dbPath, 0o600);
4487
+ } catch {
4488
+ // Ignore chmod failures (e.g., Windows)
4489
+ }
4490
+ return new SqliteAuthCredentialStore(db);
4491
+ } catch (err) {
4492
+ db?.close();
4493
+ if (!isSqliteBusyError(err)) {
4494
+ throw err;
4495
+ }
4496
+ lastBusyError = err instanceof Error ? err : new Error(String(err));
4497
+ if (attempt < maxAttempts - 1) {
4498
+ await Bun.sleep(baseDelayMs * 2 ** attempt);
4499
+ }
4500
+ }
4102
4501
  }
4103
-
4104
- return new SqliteAuthCredentialStore(db);
4502
+ throw new Error(
4503
+ `Failed to open auth database at '${dbPath}' after ${maxAttempts} attempts: ${lastBusyError?.message}`,
4504
+ { cause: lastBusyError },
4505
+ );
4105
4506
  }
4106
4507
 
4107
4508
  #initializeSchema(): void {
4509
+ // Install the busy handler BEFORE any lock-taking statement (incl.
4510
+ // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
4511
+ // recovery). Without this, concurrent prometheus startups can crash here with
4512
+ // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. See issue #2421.
4513
+ this.#db.run("PRAGMA busy_timeout = 5000");
4108
4514
  this.#db.run(`
4109
4515
  PRAGMA journal_mode=WAL;
4110
4516
  PRAGMA synchronous=NORMAL;
4111
- PRAGMA busy_timeout=5000;
4112
4517
  CREATE TABLE IF NOT EXISTS auth_schema_version (
4113
4518
  id INTEGER PRIMARY KEY CHECK (id = 1),
4114
4519
  version INTEGER NOT NULL
@@ -4119,6 +4524,22 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4119
4524
  expires_at INTEGER NOT NULL
4120
4525
  );
4121
4526
  CREATE INDEX IF NOT EXISTS idx_cache_expires ON cache(expires_at);
4527
+ CREATE TABLE IF NOT EXISTS usage_history (
4528
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
4529
+ recorded_at INTEGER NOT NULL,
4530
+ provider TEXT NOT NULL,
4531
+ account_key TEXT NOT NULL,
4532
+ email TEXT,
4533
+ account_id TEXT,
4534
+ limit_id TEXT NOT NULL,
4535
+ label TEXT NOT NULL,
4536
+ window_label TEXT,
4537
+ used_fraction REAL,
4538
+ status TEXT,
4539
+ resets_at INTEGER
4540
+ );
4541
+ CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at);
4542
+ CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at);
4122
4543
  `);
4123
4544
 
4124
4545
  if (!this.#authCredentialsTableExists()) {
@@ -4127,8 +4548,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4127
4548
  return;
4128
4549
  }
4129
4550
 
4130
- const schemaVersion = this.#readAuthSchemaVersion() ?? this.#inferAuthSchemaVersion();
4131
- const shouldWriteSchemaVersion = schemaVersion <= AUTH_SCHEMA_VERSION;
4551
+ const recordedVersion = this.#readAuthSchemaVersion();
4552
+ const schemaVersion = recordedVersion ?? this.#inferAuthSchemaVersion();
4132
4553
  if (schemaVersion > AUTH_SCHEMA_VERSION) {
4133
4554
  logger.warn("SqliteAuthCredentialStore schema version mismatch", {
4134
4555
  current: schemaVersion,
@@ -4140,7 +4561,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4140
4561
 
4141
4562
  this.#createAuthCredentialIndexes();
4142
4563
  this.#backfillCredentialIdentityKeys();
4143
- if (shouldWriteSchemaVersion) {
4564
+ // Rewriting an already-current version row is a no-op write transaction
4565
+ // on every boot; only persist when the recorded version actually changes.
4566
+ if (recordedVersion !== AUTH_SCHEMA_VERSION && schemaVersion <= AUTH_SCHEMA_VERSION) {
4144
4567
  this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
4145
4568
  }
4146
4569
  }
@@ -4296,9 +4719,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4296
4719
  .all() as AuthRow[];
4297
4720
  if (rows.length === 0) return;
4298
4721
 
4299
- const updateIdentity = this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4722
+ let updateIdentity: Statement | null = null;
4300
4723
  for (const row of rows) {
4301
4724
  const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4725
+ // Rows whose identity cannot be derived stay NULL; writing NULL over
4726
+ // NULL would just burn a write transaction on every boot.
4727
+ if (identityKey === null) continue;
4728
+ updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4302
4729
  updateIdentity.run(identityKey, row.id);
4303
4730
  }
4304
4731
  }
@@ -4520,6 +4947,80 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4520
4947
  }
4521
4948
  }
4522
4949
 
4950
+ recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
4951
+ try {
4952
+ for (const entry of entries) {
4953
+ const bucket = Math.floor(entry.recordedAt / USAGE_HISTORY_BUCKET_MS);
4954
+ const last = this.#lastUsageHistoryStmt.get(entry.provider, entry.accountKey, entry.limitId) as
4955
+ | { id: number; recorded_at: number }
4956
+ | undefined;
4957
+ if (last && Math.floor(last.recorded_at / USAGE_HISTORY_BUCKET_MS) === bucket) {
4958
+ this.#updateUsageHistoryStmt.run(
4959
+ entry.recordedAt,
4960
+ entry.email ?? null,
4961
+ entry.accountId ?? null,
4962
+ entry.label,
4963
+ entry.windowLabel ?? null,
4964
+ entry.usedFraction ?? null,
4965
+ entry.status ?? null,
4966
+ entry.resetsAt ?? null,
4967
+ last.id,
4968
+ );
4969
+ continue;
4970
+ }
4971
+ this.#insertUsageHistoryStmt.run(
4972
+ entry.recordedAt,
4973
+ entry.provider,
4974
+ entry.accountKey,
4975
+ entry.email ?? null,
4976
+ entry.accountId ?? null,
4977
+ entry.limitId,
4978
+ entry.label,
4979
+ entry.windowLabel ?? null,
4980
+ entry.usedFraction ?? null,
4981
+ entry.status ?? null,
4982
+ entry.resetsAt ?? null,
4983
+ );
4984
+ }
4985
+ } catch {
4986
+ // History is best-effort; never break the usage fetch path.
4987
+ }
4988
+ }
4989
+
4990
+ listUsageHistory(query?: UsageHistoryQuery): UsageHistoryEntry[] {
4991
+ try {
4992
+ const provider = query?.provider ?? null;
4993
+ const rows = this.#listUsageHistoryStmt.all(query?.sinceMs ?? 0, provider, provider) as Array<{
4994
+ recorded_at: number;
4995
+ provider: string;
4996
+ account_key: string;
4997
+ email: string | null;
4998
+ account_id: string | null;
4999
+ limit_id: string;
5000
+ label: string;
5001
+ window_label: string | null;
5002
+ used_fraction: number | null;
5003
+ status: string | null;
5004
+ resets_at: number | null;
5005
+ }>;
5006
+ return rows.map(row => ({
5007
+ recordedAt: row.recorded_at,
5008
+ provider: row.provider as Provider,
5009
+ accountKey: row.account_key,
5010
+ email: row.email ?? undefined,
5011
+ accountId: row.account_id ?? undefined,
5012
+ limitId: row.limit_id,
5013
+ label: row.label,
5014
+ windowLabel: row.window_label ?? undefined,
5015
+ usedFraction: row.used_fraction ?? undefined,
5016
+ status: (row.status ?? undefined) as UsageHistoryEntry["status"],
5017
+ resetsAt: row.resets_at ?? undefined,
5018
+ }));
5019
+ } catch {
5020
+ return [];
5021
+ }
5022
+ }
5023
+
4523
5024
  // ─── Convenience methods for CLI ────────────────────────────────────────
4524
5025
 
4525
5026
  /**
@@ -4603,6 +5104,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4603
5104
  this.#getCacheIncludingExpiredStmt.finalize();
4604
5105
  this.#upsertCacheStmt.finalize();
4605
5106
  this.#deleteExpiredCacheStmt.finalize();
5107
+ this.#insertUsageHistoryStmt.finalize();
5108
+ this.#lastUsageHistoryStmt.finalize();
5109
+ this.#listUsageHistoryStmt.finalize();
5110
+ this.#updateUsageHistoryStmt.finalize();
4606
5111
  this.#db.close();
4607
5112
  }
4608
5113
  }