@gajae-code/ai 0.1.1

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 (349) hide show
  1. package/CHANGELOG.md +2644 -0
  2. package/README.md +1181 -0
  3. package/dist/types/api-registry.d.ts +30 -0
  4. package/dist/types/auth-broker/client.d.ts +66 -0
  5. package/dist/types/auth-broker/index.d.ts +5 -0
  6. package/dist/types/auth-broker/refresher.d.ts +25 -0
  7. package/dist/types/auth-broker/remote-store.d.ts +96 -0
  8. package/dist/types/auth-broker/server.d.ts +32 -0
  9. package/dist/types/auth-broker/types.d.ts +105 -0
  10. package/dist/types/auth-broker/wire-schemas.d.ts +412 -0
  11. package/dist/types/auth-gateway/http.d.ts +39 -0
  12. package/dist/types/auth-gateway/index.d.ts +3 -0
  13. package/dist/types/auth-gateway/server.d.ts +17 -0
  14. package/dist/types/auth-gateway/types.d.ts +115 -0
  15. package/dist/types/auth-storage.d.ts +641 -0
  16. package/dist/types/cli.d.ts +2 -0
  17. package/dist/types/index.d.ts +49 -0
  18. package/dist/types/model-cache.d.ts +17 -0
  19. package/dist/types/model-manager.d.ts +62 -0
  20. package/dist/types/model-thinking.d.ts +71 -0
  21. package/dist/types/models.d.ts +12 -0
  22. package/dist/types/provider-details.d.ts +24 -0
  23. package/dist/types/provider-models/bundled-references.d.ts +4 -0
  24. package/dist/types/provider-models/descriptors.d.ts +48 -0
  25. package/dist/types/provider-models/google.d.ts +20 -0
  26. package/dist/types/provider-models/index.d.ts +5 -0
  27. package/dist/types/provider-models/ollama.d.ts +7 -0
  28. package/dist/types/provider-models/openai-compat.d.ts +237 -0
  29. package/dist/types/provider-models/special.d.ts +16 -0
  30. package/dist/types/providers/amazon-bedrock.d.ts +36 -0
  31. package/dist/types/providers/anthropic-messages-server-schema.d.ts +450 -0
  32. package/dist/types/providers/anthropic-messages-server.d.ts +17 -0
  33. package/dist/types/providers/anthropic.d.ts +188 -0
  34. package/dist/types/providers/aws-credentials.d.ts +43 -0
  35. package/dist/types/providers/aws-eventstream.d.ts +38 -0
  36. package/dist/types/providers/aws-sigv4.d.ts +55 -0
  37. package/dist/types/providers/azure-openai-responses.d.ts +15 -0
  38. package/dist/types/providers/cursor/gen/agent_pb.d.ts +13022 -0
  39. package/dist/types/providers/cursor.d.ts +42 -0
  40. package/dist/types/providers/error-message.d.ts +27 -0
  41. package/dist/types/providers/github-copilot-headers.d.ts +40 -0
  42. package/dist/types/providers/gitlab-duo.d.ts +27 -0
  43. package/dist/types/providers/google-auth.d.ts +24 -0
  44. package/dist/types/providers/google-gemini-cli.d.ts +72 -0
  45. package/dist/types/providers/google-gemini-headers.d.ts +18 -0
  46. package/dist/types/providers/google-shared.d.ts +163 -0
  47. package/dist/types/providers/google-types.d.ts +138 -0
  48. package/dist/types/providers/google-vertex.d.ts +7 -0
  49. package/dist/types/providers/google.d.ts +4 -0
  50. package/dist/types/providers/grammar.d.ts +1 -0
  51. package/dist/types/providers/kimi.d.ts +27 -0
  52. package/dist/types/providers/mock.d.ts +175 -0
  53. package/dist/types/providers/ollama.d.ts +6 -0
  54. package/dist/types/providers/openai-anthropic-shim.d.ts +31 -0
  55. package/dist/types/providers/openai-chat-server-schema.d.ts +814 -0
  56. package/dist/types/providers/openai-chat-server.d.ts +16 -0
  57. package/dist/types/providers/openai-codex/constants.d.ts +26 -0
  58. package/dist/types/providers/openai-codex/request-transformer.d.ts +49 -0
  59. package/dist/types/providers/openai-codex/response-handler.d.ts +17 -0
  60. package/dist/types/providers/openai-codex-responses.d.ts +67 -0
  61. package/dist/types/providers/openai-completions-compat.d.ts +25 -0
  62. package/dist/types/providers/openai-completions.d.ts +33 -0
  63. package/dist/types/providers/openai-responses-server-schema.d.ts +392 -0
  64. package/dist/types/providers/openai-responses-server.d.ts +17 -0
  65. package/dist/types/providers/openai-responses-shared.d.ts +89 -0
  66. package/dist/types/providers/openai-responses.d.ts +32 -0
  67. package/dist/types/providers/pi-native-client.d.ts +13 -0
  68. package/dist/types/providers/pi-native-server.d.ts +68 -0
  69. package/dist/types/providers/register-builtins.d.ts +31 -0
  70. package/dist/types/providers/synthetic.d.ts +26 -0
  71. package/dist/types/providers/transform-messages.d.ts +12 -0
  72. package/dist/types/providers/vision-guard.d.ts +8 -0
  73. package/dist/types/rate-limit-utils.d.ts +19 -0
  74. package/dist/types/stream.d.ts +24 -0
  75. package/dist/types/types.d.ts +746 -0
  76. package/dist/types/usage/claude.d.ts +3 -0
  77. package/dist/types/usage/gemini.d.ts +2 -0
  78. package/dist/types/usage/github-copilot.d.ts +7 -0
  79. package/dist/types/usage/google-antigravity.d.ts +2 -0
  80. package/dist/types/usage/kimi.d.ts +2 -0
  81. package/dist/types/usage/minimax-code.d.ts +2 -0
  82. package/dist/types/usage/openai-codex.d.ts +3 -0
  83. package/dist/types/usage/shared.d.ts +1 -0
  84. package/dist/types/usage/zai.d.ts +2 -0
  85. package/dist/types/usage.d.ts +258 -0
  86. package/dist/types/utils/abort.d.ts +19 -0
  87. package/dist/types/utils/anthropic-auth.d.ts +31 -0
  88. package/dist/types/utils/discovery/antigravity.d.ts +61 -0
  89. package/dist/types/utils/discovery/codex.d.ts +38 -0
  90. package/dist/types/utils/discovery/cursor.d.ts +23 -0
  91. package/dist/types/utils/discovery/gemini.d.ts +25 -0
  92. package/dist/types/utils/discovery/index.d.ts +4 -0
  93. package/dist/types/utils/discovery/openai-compatible.d.ts +72 -0
  94. package/dist/types/utils/event-stream.d.ts +28 -0
  95. package/dist/types/utils/fireworks-model-id.d.ts +10 -0
  96. package/dist/types/utils/foundry.d.ts +1 -0
  97. package/dist/types/utils/h2-fetch.d.ts +22 -0
  98. package/dist/types/utils/http-inspector.d.ts +31 -0
  99. package/dist/types/utils/idle-iterator.d.ts +67 -0
  100. package/dist/types/utils/json-parse.d.ts +10 -0
  101. package/dist/types/utils/oauth/alibaba-coding-plan.d.ts +18 -0
  102. package/dist/types/utils/oauth/anthropic.d.ts +22 -0
  103. package/dist/types/utils/oauth/api-key-login.d.ts +35 -0
  104. package/dist/types/utils/oauth/api-key-validation.d.ts +27 -0
  105. package/dist/types/utils/oauth/callback-server.d.ts +57 -0
  106. package/dist/types/utils/oauth/cerebras.d.ts +1 -0
  107. package/dist/types/utils/oauth/cloudflare-ai-gateway.d.ts +18 -0
  108. package/dist/types/utils/oauth/cursor.d.ts +15 -0
  109. package/dist/types/utils/oauth/deepseek.d.ts +10 -0
  110. package/dist/types/utils/oauth/firepass.d.ts +1 -0
  111. package/dist/types/utils/oauth/fireworks.d.ts +1 -0
  112. package/dist/types/utils/oauth/github-copilot.d.ts +38 -0
  113. package/dist/types/utils/oauth/gitlab-duo.d.ts +3 -0
  114. package/dist/types/utils/oauth/google-antigravity.d.ts +11 -0
  115. package/dist/types/utils/oauth/google-gemini-cli.d.ts +10 -0
  116. package/dist/types/utils/oauth/google-oauth-shared.d.ts +28 -0
  117. package/dist/types/utils/oauth/huggingface.d.ts +19 -0
  118. package/dist/types/utils/oauth/index.d.ts +38 -0
  119. package/dist/types/utils/oauth/kagi.d.ts +17 -0
  120. package/dist/types/utils/oauth/kilo.d.ts +5 -0
  121. package/dist/types/utils/oauth/kimi.d.ts +21 -0
  122. package/dist/types/utils/oauth/litellm.d.ts +18 -0
  123. package/dist/types/utils/oauth/lm-studio.d.ts +17 -0
  124. package/dist/types/utils/oauth/minimax-code.d.ts +28 -0
  125. package/dist/types/utils/oauth/moonshot.d.ts +1 -0
  126. package/dist/types/utils/oauth/nanogpt.d.ts +1 -0
  127. package/dist/types/utils/oauth/nvidia.d.ts +18 -0
  128. package/dist/types/utils/oauth/ollama-cloud.d.ts +2 -0
  129. package/dist/types/utils/oauth/ollama.d.ts +18 -0
  130. package/dist/types/utils/oauth/openai-codex.d.ts +21 -0
  131. package/dist/types/utils/oauth/opencode.d.ts +18 -0
  132. package/dist/types/utils/oauth/parallel.d.ts +17 -0
  133. package/dist/types/utils/oauth/perplexity.d.ts +9 -0
  134. package/dist/types/utils/oauth/pkce.d.ts +8 -0
  135. package/dist/types/utils/oauth/qianfan.d.ts +17 -0
  136. package/dist/types/utils/oauth/qwen-portal.d.ts +19 -0
  137. package/dist/types/utils/oauth/synthetic.d.ts +1 -0
  138. package/dist/types/utils/oauth/tavily.d.ts +17 -0
  139. package/dist/types/utils/oauth/together.d.ts +1 -0
  140. package/dist/types/utils/oauth/types.d.ts +44 -0
  141. package/dist/types/utils/oauth/venice.d.ts +18 -0
  142. package/dist/types/utils/oauth/vercel-ai-gateway.d.ts +18 -0
  143. package/dist/types/utils/oauth/vllm.d.ts +16 -0
  144. package/dist/types/utils/oauth/xiaomi.d.ts +19 -0
  145. package/dist/types/utils/oauth/zai.d.ts +18 -0
  146. package/dist/types/utils/oauth/zenmux.d.ts +1 -0
  147. package/dist/types/utils/overflow.d.ts +54 -0
  148. package/dist/types/utils/parse-bind.d.ts +23 -0
  149. package/dist/types/utils/provider-response.d.ts +3 -0
  150. package/dist/types/utils/retry-after.d.ts +3 -0
  151. package/dist/types/utils/retry.d.ts +26 -0
  152. package/dist/types/utils/schema/adapt.d.ts +24 -0
  153. package/dist/types/utils/schema/compatibility.d.ts +30 -0
  154. package/dist/types/utils/schema/dereference.d.ts +11 -0
  155. package/dist/types/utils/schema/draft.d.ts +10 -0
  156. package/dist/types/utils/schema/equality.d.ts +4 -0
  157. package/dist/types/utils/schema/fields.d.ts +49 -0
  158. package/dist/types/utils/schema/index.d.ts +13 -0
  159. package/dist/types/utils/schema/json-schema-validator.d.ts +12 -0
  160. package/dist/types/utils/schema/meta-validator.d.ts +2 -0
  161. package/dist/types/utils/schema/normalize.d.ts +93 -0
  162. package/dist/types/utils/schema/spill.d.ts +8 -0
  163. package/dist/types/utils/schema/stamps.d.ts +25 -0
  164. package/dist/types/utils/schema/types.d.ts +4 -0
  165. package/dist/types/utils/schema/wire.d.ts +54 -0
  166. package/dist/types/utils/schema/zod-decontaminate.d.ts +31 -0
  167. package/dist/types/utils/sse-debug.d.ts +10 -0
  168. package/dist/types/utils/tool-call-healing.d.ts +71 -0
  169. package/dist/types/utils/tool-choice.d.ts +50 -0
  170. package/dist/types/utils/validation.d.ts +17 -0
  171. package/dist/types/utils.d.ts +28 -0
  172. package/package.json +146 -0
  173. package/src/api-registry.ts +96 -0
  174. package/src/auth-broker/client.ts +358 -0
  175. package/src/auth-broker/index.ts +5 -0
  176. package/src/auth-broker/refresher.ts +127 -0
  177. package/src/auth-broker/remote-store.ts +623 -0
  178. package/src/auth-broker/server.ts +644 -0
  179. package/src/auth-broker/types.ts +127 -0
  180. package/src/auth-broker/wire-schemas.ts +200 -0
  181. package/src/auth-gateway/http.ts +194 -0
  182. package/src/auth-gateway/index.ts +3 -0
  183. package/src/auth-gateway/server.ts +717 -0
  184. package/src/auth-gateway/types.ts +134 -0
  185. package/src/auth-storage.ts +4104 -0
  186. package/src/cli.ts +262 -0
  187. package/src/index.ts +54 -0
  188. package/src/model-cache.ts +129 -0
  189. package/src/model-manager.ts +450 -0
  190. package/src/model-thinking.ts +691 -0
  191. package/src/models.json +73853 -0
  192. package/src/models.json.d.ts +9 -0
  193. package/src/models.ts +56 -0
  194. package/src/prompts/turn-aborted-guidance.md +4 -0
  195. package/src/provider-details.ts +90 -0
  196. package/src/provider-models/bundled-references.ts +38 -0
  197. package/src/provider-models/descriptors.ts +308 -0
  198. package/src/provider-models/google.ts +91 -0
  199. package/src/provider-models/index.ts +5 -0
  200. package/src/provider-models/ollama.ts +153 -0
  201. package/src/provider-models/openai-compat.ts +2275 -0
  202. package/src/provider-models/special.ts +67 -0
  203. package/src/providers/amazon-bedrock.ts +849 -0
  204. package/src/providers/anthropic-messages-server-schema.ts +229 -0
  205. package/src/providers/anthropic-messages-server.ts +677 -0
  206. package/src/providers/anthropic.ts +2696 -0
  207. package/src/providers/aws-credentials.ts +501 -0
  208. package/src/providers/aws-eventstream.ts +185 -0
  209. package/src/providers/aws-sigv4.ts +218 -0
  210. package/src/providers/azure-openai-responses.ts +337 -0
  211. package/src/providers/cursor/gen/agent_pb.ts +15274 -0
  212. package/src/providers/cursor/proto/agent.proto +3526 -0
  213. package/src/providers/cursor/proto/buf.gen.yaml +6 -0
  214. package/src/providers/cursor/proto/buf.yaml +17 -0
  215. package/src/providers/cursor.ts +2561 -0
  216. package/src/providers/error-message.ts +21 -0
  217. package/src/providers/github-copilot-headers.ts +140 -0
  218. package/src/providers/gitlab-duo.ts +372 -0
  219. package/src/providers/google-auth.ts +252 -0
  220. package/src/providers/google-gemini-cli.ts +795 -0
  221. package/src/providers/google-gemini-headers.ts +41 -0
  222. package/src/providers/google-shared.ts +902 -0
  223. package/src/providers/google-types.ts +167 -0
  224. package/src/providers/google-vertex.ts +88 -0
  225. package/src/providers/google.ts +41 -0
  226. package/src/providers/grammar.ts +70 -0
  227. package/src/providers/kimi.ts +52 -0
  228. package/src/providers/mock.ts +500 -0
  229. package/src/providers/ollama.ts +544 -0
  230. package/src/providers/openai-anthropic-shim.ts +138 -0
  231. package/src/providers/openai-chat-server-schema.ts +243 -0
  232. package/src/providers/openai-chat-server.ts +628 -0
  233. package/src/providers/openai-codex/constants.ts +43 -0
  234. package/src/providers/openai-codex/request-transformer.ts +161 -0
  235. package/src/providers/openai-codex/response-handler.ts +81 -0
  236. package/src/providers/openai-codex-responses.ts +2598 -0
  237. package/src/providers/openai-completions-compat.ts +279 -0
  238. package/src/providers/openai-completions.ts +1853 -0
  239. package/src/providers/openai-responses-server-schema.ts +290 -0
  240. package/src/providers/openai-responses-server.ts +1183 -0
  241. package/src/providers/openai-responses-shared.ts +800 -0
  242. package/src/providers/openai-responses.ts +621 -0
  243. package/src/providers/pi-native-client.ts +228 -0
  244. package/src/providers/pi-native-server.ts +210 -0
  245. package/src/providers/register-builtins.ts +412 -0
  246. package/src/providers/synthetic.ts +50 -0
  247. package/src/providers/transform-messages.ts +309 -0
  248. package/src/providers/vision-guard.ts +31 -0
  249. package/src/rate-limit-utils.ts +84 -0
  250. package/src/stream.ts +895 -0
  251. package/src/types.ts +884 -0
  252. package/src/usage/claude.ts +431 -0
  253. package/src/usage/gemini.ts +250 -0
  254. package/src/usage/github-copilot.ts +421 -0
  255. package/src/usage/google-antigravity.ts +201 -0
  256. package/src/usage/kimi.ts +271 -0
  257. package/src/usage/minimax-code.ts +31 -0
  258. package/src/usage/openai-codex.ts +503 -0
  259. package/src/usage/shared.ts +10 -0
  260. package/src/usage/zai.ts +247 -0
  261. package/src/usage.ts +183 -0
  262. package/src/utils/abort.ts +51 -0
  263. package/src/utils/anthropic-auth.ts +87 -0
  264. package/src/utils/discovery/antigravity.ts +261 -0
  265. package/src/utils/discovery/codex.ts +371 -0
  266. package/src/utils/discovery/cursor.ts +306 -0
  267. package/src/utils/discovery/gemini.ts +248 -0
  268. package/src/utils/discovery/index.ts +4 -0
  269. package/src/utils/discovery/openai-compatible.ts +224 -0
  270. package/src/utils/event-stream.ts +142 -0
  271. package/src/utils/fireworks-model-id.ts +30 -0
  272. package/src/utils/foundry.ts +8 -0
  273. package/src/utils/h2-fetch.ts +60 -0
  274. package/src/utils/http-inspector.ts +176 -0
  275. package/src/utils/idle-iterator.ts +250 -0
  276. package/src/utils/json-parse.ts +148 -0
  277. package/src/utils/oauth/alibaba-coding-plan.ts +59 -0
  278. package/src/utils/oauth/anthropic.ts +200 -0
  279. package/src/utils/oauth/api-key-login.ts +87 -0
  280. package/src/utils/oauth/api-key-validation.ts +92 -0
  281. package/src/utils/oauth/callback-server.ts +276 -0
  282. package/src/utils/oauth/cerebras.ts +16 -0
  283. package/src/utils/oauth/cloudflare-ai-gateway.ts +48 -0
  284. package/src/utils/oauth/cursor.ts +157 -0
  285. package/src/utils/oauth/deepseek.ts +53 -0
  286. package/src/utils/oauth/firepass.ts +24 -0
  287. package/src/utils/oauth/fireworks.ts +15 -0
  288. package/src/utils/oauth/github-copilot.ts +362 -0
  289. package/src/utils/oauth/gitlab-duo.ts +123 -0
  290. package/src/utils/oauth/google-antigravity.ts +200 -0
  291. package/src/utils/oauth/google-gemini-cli.ts +256 -0
  292. package/src/utils/oauth/google-oauth-shared.ts +110 -0
  293. package/src/utils/oauth/huggingface.ts +62 -0
  294. package/src/utils/oauth/index.ts +444 -0
  295. package/src/utils/oauth/kagi.ts +47 -0
  296. package/src/utils/oauth/kilo.ts +87 -0
  297. package/src/utils/oauth/kimi.ts +254 -0
  298. package/src/utils/oauth/litellm.ts +47 -0
  299. package/src/utils/oauth/lm-studio.ts +38 -0
  300. package/src/utils/oauth/minimax-code.ts +78 -0
  301. package/src/utils/oauth/moonshot.ts +16 -0
  302. package/src/utils/oauth/nanogpt.ts +15 -0
  303. package/src/utils/oauth/nvidia.ts +70 -0
  304. package/src/utils/oauth/oauth.html +199 -0
  305. package/src/utils/oauth/ollama-cloud.ts +28 -0
  306. package/src/utils/oauth/ollama.ts +47 -0
  307. package/src/utils/oauth/openai-codex.ts +299 -0
  308. package/src/utils/oauth/opencode.ts +49 -0
  309. package/src/utils/oauth/parallel.ts +46 -0
  310. package/src/utils/oauth/perplexity.ts +206 -0
  311. package/src/utils/oauth/pkce.ts +18 -0
  312. package/src/utils/oauth/qianfan.ts +58 -0
  313. package/src/utils/oauth/qwen-portal.ts +60 -0
  314. package/src/utils/oauth/synthetic.ts +16 -0
  315. package/src/utils/oauth/tavily.ts +46 -0
  316. package/src/utils/oauth/together.ts +16 -0
  317. package/src/utils/oauth/types.ts +94 -0
  318. package/src/utils/oauth/venice.ts +59 -0
  319. package/src/utils/oauth/vercel-ai-gateway.ts +47 -0
  320. package/src/utils/oauth/vllm.ts +40 -0
  321. package/src/utils/oauth/xiaomi.ts +137 -0
  322. package/src/utils/oauth/zai.ts +60 -0
  323. package/src/utils/oauth/zenmux.ts +15 -0
  324. package/src/utils/overflow.ts +137 -0
  325. package/src/utils/parse-bind.ts +54 -0
  326. package/src/utils/provider-response.ts +30 -0
  327. package/src/utils/retry-after.ts +110 -0
  328. package/src/utils/retry.ts +54 -0
  329. package/src/utils/schema/CONSTRAINTS.md +164 -0
  330. package/src/utils/schema/adapt.ts +36 -0
  331. package/src/utils/schema/compatibility.ts +435 -0
  332. package/src/utils/schema/dereference.ts +98 -0
  333. package/src/utils/schema/draft.ts +341 -0
  334. package/src/utils/schema/equality.ts +97 -0
  335. package/src/utils/schema/fields.ts +190 -0
  336. package/src/utils/schema/index.ts +13 -0
  337. package/src/utils/schema/json-schema-validator.ts +577 -0
  338. package/src/utils/schema/meta-validator.ts +167 -0
  339. package/src/utils/schema/normalize.ts +1588 -0
  340. package/src/utils/schema/spill.ts +43 -0
  341. package/src/utils/schema/stamps.ts +97 -0
  342. package/src/utils/schema/types.ts +11 -0
  343. package/src/utils/schema/wire.ts +213 -0
  344. package/src/utils/schema/zod-decontaminate.ts +331 -0
  345. package/src/utils/sse-debug.ts +289 -0
  346. package/src/utils/tool-call-healing.ts +271 -0
  347. package/src/utils/tool-choice.ts +99 -0
  348. package/src/utils/validation.ts +1019 -0
  349. package/src/utils.ts +166 -0
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Client-side {@link AuthCredentialStore} that mirrors a remote broker's
3
+ * snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
4
+ * `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
5
+ *
6
+ * Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
7
+ * usage reports cache TTL is 5 minutes per credential, so durability across
8
+ * runs isn't required.
9
+ */
10
+ import { scheduler } from "node:timers/promises";
11
+ import { logger } from "@gajae-code/utils";
12
+ import {
13
+ type AuthCredential,
14
+ type AuthCredentialSnapshotEntry,
15
+ type AuthCredentialStore,
16
+ type OAuthCredential,
17
+ REMOTE_REFRESH_SENTINEL,
18
+ type StoredAuthCredential,
19
+ } from "../auth-storage";
20
+ import type { Provider } from "../types";
21
+ import type { UsageReport } from "../usage";
22
+ import type { OAuthCredentials } from "../utils/oauth/types";
23
+ import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./client";
24
+ import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
25
+
26
+ /**
27
+ * Client-side TTL for the aggregate `/v1/usage` response. Set below the
28
+ * broker server's own 30s usage cache so we typically pick up the broker's
29
+ * cached value instead of re-walking the network — but high enough to absorb
30
+ * the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
31
+ */
32
+ const USAGE_CACHE_TTL_MS = 15_000;
33
+ const WAIT_THRESHOLD_MS = 1_000;
34
+ const MAX_WAIT_MS = 5_000;
35
+ const BACKGROUND_WAIT_MS = 30_000;
36
+ const BACKGROUND_BACKOFF_INITIAL_MS = 500;
37
+ const BACKGROUND_BACKOFF_MAX_MS = 30_000;
38
+
39
+ function emptySnapshot(): SnapshotResponse {
40
+ return {
41
+ generation: 0,
42
+ generatedAt: 0,
43
+ serverNowMs: 0,
44
+ refresher: {
45
+ enabled: false,
46
+ intervalMs: 0,
47
+ skewMs: 0,
48
+ nextSweepInMs: Number.MAX_SAFE_INTEGER,
49
+ },
50
+ credentials: [],
51
+ };
52
+ }
53
+
54
+ interface CacheEntry {
55
+ value: string;
56
+ expiresAtSec: number;
57
+ }
58
+
59
+ interface UsageCacheEntry {
60
+ reports: UsageReport[];
61
+ fetchedAt: number;
62
+ }
63
+
64
+ export interface RemoteAuthCredentialStoreOptions {
65
+ client: AuthBrokerClient;
66
+ /**
67
+ * Initial snapshot. When omitted, callers must call
68
+ * {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
69
+ */
70
+ initialSnapshot?: SnapshotResponse;
71
+ /**
72
+ * Subscribe to the broker's SSE snapshot stream when available. Falls back
73
+ * to long-poll permanently when the broker returns 404. Default `true`.
74
+ */
75
+ streamSnapshots?: boolean;
76
+ }
77
+
78
+ export class RemoteAuthCredentialStore implements AuthCredentialStore {
79
+ readonly #client: AuthBrokerClient;
80
+ readonly #streamSnapshots: boolean;
81
+ #snapshot: SnapshotResponse = emptySnapshot();
82
+ #snapshotReceivedAt = Date.now();
83
+ #generation = 0;
84
+ #backgroundAbort = new AbortController();
85
+ #cache: Map<string, CacheEntry> = new Map();
86
+ #usageCache?: UsageCacheEntry;
87
+ #usageInflight?: Promise<UsageReport[] | null>;
88
+ #closed = false;
89
+ /**
90
+ * `true` once the SSE consumer received its first frame and hasn't dropped
91
+ * since. Writes consult this to suppress the otherwise-mandatory
92
+ * `refreshSnapshot()` follow-up — the stream will deliver the new
93
+ * generation without an extra GET.
94
+ */
95
+ #streamingActive = false;
96
+ /** Latched once the broker has answered 404 — never try the stream again. */
97
+ #streamingUnsupported = false;
98
+
99
+ constructor(opts: RemoteAuthCredentialStoreOptions) {
100
+ this.#client = opts.client;
101
+ this.#streamSnapshots = opts.streamSnapshots ?? true;
102
+ this.#applySnapshot(opts.initialSnapshot ?? emptySnapshot(), opts.initialSnapshot?.generation ?? 0);
103
+ void this.#runBackground();
104
+ }
105
+
106
+ get client(): AuthBrokerClient {
107
+ return this.#client;
108
+ }
109
+
110
+ get snapshot(): SnapshotResponse {
111
+ return this.#snapshot;
112
+ }
113
+
114
+ #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
115
+ this.#snapshot = snapshot;
116
+ this.#generation = generation;
117
+ this.#snapshotReceivedAt = Date.now();
118
+ }
119
+
120
+ async #runBackground(): Promise<void> {
121
+ let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
122
+ while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
123
+ if (this.#streamSnapshots && !this.#streamingUnsupported) {
124
+ try {
125
+ await this.#consumeSnapshotStream();
126
+ backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
127
+ continue;
128
+ } catch (error) {
129
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
130
+ if (error instanceof AuthBrokerStreamUnsupportedError) {
131
+ this.#streamingUnsupported = true;
132
+ logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
133
+ continue;
134
+ }
135
+ logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
136
+ await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
137
+ backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
138
+ continue;
139
+ }
140
+ }
141
+ try {
142
+ const result = await this.#client.fetchSnapshot({
143
+ ifGenerationGt: this.#generation,
144
+ waitMs: BACKGROUND_WAIT_MS,
145
+ signal: this.#backgroundAbort.signal,
146
+ });
147
+ if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
148
+ backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
149
+ } catch (error) {
150
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
151
+ logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
152
+ await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
153
+ backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
154
+ }
155
+ }
156
+ }
157
+
158
+ async #consumeSnapshotStream(): Promise<void> {
159
+ const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
160
+ try {
161
+ for await (const event of iterator) {
162
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
163
+ this.#streamingActive = true;
164
+ this.#applyStreamEvent(event);
165
+ }
166
+ } finally {
167
+ this.#streamingActive = false;
168
+ }
169
+ }
170
+
171
+ #applyStreamEvent(event: SnapshotStreamEvent): void {
172
+ switch (event.kind) {
173
+ case "snapshot": {
174
+ // Strip the discriminator so we store the wire-shape SnapshotResponse.
175
+ const { kind: _kind, ...snapshot } = event;
176
+ if (snapshot.generation < this.#generation) {
177
+ logger.debug("auth-broker stream snapshot older than local; ignoring", {
178
+ local: this.#generation,
179
+ incoming: snapshot.generation,
180
+ });
181
+ return;
182
+ }
183
+ this.#applySnapshot(snapshot, snapshot.generation);
184
+ return;
185
+ }
186
+ case "entry": {
187
+ if (event.generation < this.#generation) return;
188
+ this.#applyStreamEntry(event.entry, event.refresher, event.generation, event.serverNowMs);
189
+ return;
190
+ }
191
+ case "removed": {
192
+ if (event.generation < this.#generation) return;
193
+ this.#removeStreamCredential(event.id, event.refresher, event.generation, event.serverNowMs);
194
+ return;
195
+ }
196
+ }
197
+ }
198
+
199
+ #applyStreamEntry(
200
+ entry: SnapshotEntry,
201
+ refresher: RefresherSchedule,
202
+ generation: number,
203
+ serverNowMs: number,
204
+ ): void {
205
+ const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
206
+ const credentials =
207
+ index === -1
208
+ ? [...this.#snapshot.credentials, entry]
209
+ : this.#snapshot.credentials.map((candidate, i) => (i === index ? entry : candidate));
210
+ this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
211
+ this.#generation = generation;
212
+ this.#snapshotReceivedAt = Date.now();
213
+ }
214
+
215
+ #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
216
+ const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
217
+ this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
218
+ this.#generation = generation;
219
+ this.#snapshotReceivedAt = Date.now();
220
+ }
221
+
222
+ /** Re-hydrate the in-memory snapshot from the broker. */
223
+ async refreshSnapshot(): Promise<SnapshotResponse> {
224
+ const result = await this.#client.fetchSnapshot();
225
+ if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
226
+ return this.#snapshot;
227
+ }
228
+
229
+ listAuthCredentials(provider?: string): StoredAuthCredential[] {
230
+ const out: StoredAuthCredential[] = [];
231
+ for (const entry of this.#snapshot.credentials) {
232
+ if (provider !== undefined && entry.provider !== provider) continue;
233
+ out.push({
234
+ id: entry.id,
235
+ provider: entry.provider,
236
+ credential: entry.credential as AuthCredential,
237
+ disabledCause: null,
238
+ });
239
+ }
240
+ return out;
241
+ }
242
+
243
+ /**
244
+ * In-memory update from a successful refresh through the broker. AuthStorage
245
+ * calls this after `#replaceCredentialAt`; the broker already persisted the
246
+ * authoritative row, so we just mirror it.
247
+ */
248
+ updateAuthCredential(id: number, credential: AuthCredential): void {
249
+ for (const entry of this.#snapshot.credentials) {
250
+ if (entry.id !== id) continue;
251
+ entry.credential = credential as typeof entry.credential;
252
+ return;
253
+ }
254
+ }
255
+
256
+ deleteAuthCredential(id: number, disabledCause: string): void {
257
+ this.#removeCredentialById(id);
258
+ // Fire-and-forget: tell the broker to persist the disable.
259
+ this.#client.disableCredential(id, disabledCause).catch(error => {
260
+ logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
261
+ });
262
+ }
263
+
264
+ tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
265
+ const found = this.#snapshot.credentials.find(entry => entry.id === id);
266
+ if (!found) return false;
267
+ this.deleteAuthCredential(id, disabledCause);
268
+ return true;
269
+ }
270
+
271
+ async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
272
+ const previousGeneration = this.#generation;
273
+ const result = await this.#client.fetchSnapshot({
274
+ ifGenerationGt: this.#generation,
275
+ waitMs: maxWaitMs,
276
+ signal: opts.signal,
277
+ });
278
+ if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
279
+ return this.#generation !== previousGeneration;
280
+ }
281
+
282
+ async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
283
+ const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
284
+ if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
285
+ const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
286
+ if (remainingMs > WAIT_THRESHOLD_MS) return false;
287
+ return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
288
+ }
289
+
290
+ async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
291
+ const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
292
+ if (entry.credential.type !== "oauth") {
293
+ throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
294
+ }
295
+ this.#applyCredentialEntry(entry);
296
+ this.#maybeRefreshSnapshot("suspect credential refresh");
297
+ }
298
+
299
+ replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
300
+ throw new Error(
301
+ "RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker login <provider>` to mutate credentials.",
302
+ );
303
+ }
304
+
305
+ upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
306
+ throw new Error(
307
+ "RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker login <provider>` to mutate credentials.",
308
+ );
309
+ }
310
+
311
+ deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
312
+ throw new Error(
313
+ "RemoteAuthCredentialStore is read-only on the client. Use `gjc auth-broker logout <provider>` to mutate credentials.",
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Upsert a single credential through the broker. The broker server is the
319
+ * canonical writer — see `POST /v1/credential`. The redacted snapshot
320
+ * entries returned by the server replace the provider's rows in our local
321
+ * snapshot, and the global snapshot is then refreshed in the background so
322
+ * any concurrent peer (refresh, generation bump) stays in sync.
323
+ */
324
+ async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
325
+ const { entries } = await this.#client.uploadCredential(provider, credential);
326
+ this.#applyProviderEntries(provider, entries);
327
+ this.#maybeRefreshSnapshot("upload");
328
+ return this.listAuthCredentials(provider);
329
+ }
330
+
331
+ /**
332
+ * Replace-all semantics: disable every active credential for the provider,
333
+ * then upload each of the new credentials. Used by API-key login so a new
334
+ * key clobbers any previously stored key for the same provider.
335
+ */
336
+ async replaceAuthCredentialsRemote(
337
+ provider: string,
338
+ credentials: AuthCredential[],
339
+ ): Promise<StoredAuthCredential[]> {
340
+ const existing = this.listAuthCredentials(provider);
341
+ for (const entry of existing) {
342
+ try {
343
+ await this.#client.disableCredential(entry.id, "replaced by newer credential");
344
+ } catch (error) {
345
+ logger.warn("auth-broker disable during replace failed", {
346
+ provider,
347
+ id: entry.id,
348
+ error: String(error),
349
+ });
350
+ }
351
+ }
352
+ // Snapshot reflects the disables before we add the new rows so a concurrent
353
+ // reader cannot momentarily see old + new together for the same provider.
354
+ this.#removeProviderEntries(provider);
355
+ for (const credential of credentials) {
356
+ const { entries } = await this.#client.uploadCredential(provider, credential);
357
+ this.#applyProviderEntries(provider, entries);
358
+ }
359
+ this.#maybeRefreshSnapshot("replace");
360
+ return this.listAuthCredentials(provider);
361
+ }
362
+
363
+ /**
364
+ * Logout: disable every active credential for the provider on the broker,
365
+ * then drop them from the local snapshot. Refresh fetches the authoritative
366
+ * post-state in the background.
367
+ */
368
+ async deleteAuthCredentialsRemote(provider: string, disabledCause: string): Promise<void> {
369
+ const existing = this.listAuthCredentials(provider);
370
+ for (const entry of existing) {
371
+ try {
372
+ await this.#client.disableCredential(entry.id, disabledCause);
373
+ } catch (error) {
374
+ logger.warn("auth-broker disable during delete failed", {
375
+ provider,
376
+ id: entry.id,
377
+ error: String(error),
378
+ });
379
+ }
380
+ }
381
+ this.#removeProviderEntries(provider);
382
+ this.#maybeRefreshSnapshot("delete");
383
+ }
384
+
385
+ #applyProviderEntries(provider: string, entries: AuthCredentialSnapshotEntry[]): void {
386
+ // `entries` is the broker's authoritative post-upsert list of rows for
387
+ // `provider`. Drop our existing rows for the same provider and splice in
388
+ // the fresh set — preserving every other provider's rows in place.
389
+ const others = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
390
+ const incoming = entries.map(entry => ({ ...entry, rotatesInMs: null }));
391
+ this.#snapshot = { ...this.#snapshot, credentials: [...others, ...incoming] };
392
+ }
393
+ #applyCredentialEntry(entry: AuthCredentialSnapshotEntry): void {
394
+ const incoming = { ...entry, rotatesInMs: null };
395
+ const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === entry.id);
396
+ if (index === -1) {
397
+ this.#snapshot = { ...this.#snapshot, credentials: [...this.#snapshot.credentials, incoming] };
398
+ return;
399
+ }
400
+ const credentials = [...this.#snapshot.credentials];
401
+ credentials[index] = incoming;
402
+ this.#snapshot = { ...this.#snapshot, credentials };
403
+ }
404
+
405
+ #removeProviderEntries(provider: string): void {
406
+ const next = this.#snapshot.credentials.filter(entry => entry.provider !== provider);
407
+ this.#snapshot = { ...this.#snapshot, credentials: next };
408
+ }
409
+
410
+ #removeCredentialById(id: number): void {
411
+ const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
412
+ this.#snapshot = { ...this.#snapshot, credentials: next };
413
+ }
414
+
415
+ /**
416
+ * Fire-and-forget `refreshSnapshot()` after a write. When the SSE stream is
417
+ * active the broker will deliver the new generation push, so the extra GET
418
+ * is wasted bandwidth and we skip it.
419
+ */
420
+ #maybeRefreshSnapshot(reason: string): void {
421
+ if (this.#streamingActive) return;
422
+ void this.refreshSnapshot().catch(error => {
423
+ logger.debug("auth-broker snapshot refresh after write failed", { reason, error: String(error) });
424
+ });
425
+ }
426
+
427
+ getCache(key: string): string | null {
428
+ const entry = this.#cache.get(key);
429
+ if (!entry) return null;
430
+ if (entry.expiresAtSec * 1000 <= Date.now()) {
431
+ this.#cache.delete(key);
432
+ return null;
433
+ }
434
+ return entry.value;
435
+ }
436
+
437
+ setCache(key: string, value: string, expiresAtSec: number): void {
438
+ this.#cache.set(key, { value, expiresAtSec });
439
+ }
440
+
441
+ cleanExpiredCache(): void {
442
+ const nowSec = Math.floor(Date.now() / 1000);
443
+ for (const [key, entry] of this.#cache) {
444
+ if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Store-level hook consumed by `AuthStorage` — routes refresh through the
450
+ * broker so the actual refresh token never leaves the broker host. Returns
451
+ * the broker-redacted credential with {@link REMOTE_REFRESH_SENTINEL} in
452
+ * the `refresh` slot.
453
+ */
454
+ async refreshOAuthCredential(
455
+ _provider: Provider,
456
+ credentialId: number,
457
+ _credential: OAuthCredential,
458
+ signal?: AbortSignal,
459
+ ): Promise<OAuthCredentials> {
460
+ const { entry } = await this.#client.refreshCredential(credentialId, signal);
461
+ if (!this.#streamingActive) {
462
+ await this.refreshSnapshot().catch(error => {
463
+ logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
464
+ });
465
+ }
466
+ if (entry.credential.type !== "oauth") {
467
+ throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
468
+ }
469
+ const refreshed = entry.credential;
470
+ return {
471
+ access: refreshed.access,
472
+ refresh: REMOTE_REFRESH_SENTINEL,
473
+ expires: refreshed.expires,
474
+ accountId: refreshed.accountId,
475
+ email: refreshed.email,
476
+ projectId: refreshed.projectId,
477
+ enterpriseUrl: refreshed.enterpriseUrl,
478
+ };
479
+ }
480
+
481
+ /**
482
+ * Store-level hook consumed by `AuthStorage.fetchUsageReports()` — proxies
483
+ * to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
484
+ * rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
485
+ * residential laptop is, so all credentials surface every cycle.
486
+ */
487
+ async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
488
+ return this.#raceWithSignal(this.#loadUsageReports(), signal);
489
+ }
490
+
491
+ /**
492
+ * Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
493
+ * the aggregate broker `/v1/usage` once and serves all callers from the
494
+ * same response (coalesced + cached), then matches the credential to a
495
+ * report by provider + identity (accountId / email / projectId).
496
+ *
497
+ * The broker already aggregates with its own 30s TTL on the server side; our
498
+ * 15s client TTL is below that so we usually re-use the broker's cache too.
499
+ */
500
+ async getUsageReport(
501
+ provider: Provider,
502
+ credential: OAuthCredential,
503
+ signal?: AbortSignal,
504
+ ): Promise<UsageReport | null> {
505
+ const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
506
+ if (!reports) return null;
507
+ return matchUsageReport(reports, provider, credential);
508
+ }
509
+
510
+ /**
511
+ * Reject the awaited promise when the caller's signal aborts, without
512
+ * affecting the shared upstream fetch. Used to give each caller their
513
+ * own cancel without one caller's abort cascading into a peer's in-flight
514
+ * request through the single-flight `#usageInflight`.
515
+ */
516
+ #raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
517
+ if (!signal) return promise;
518
+ if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
519
+ return new Promise<T>((resolve, reject) => {
520
+ const onAbort = (): void => {
521
+ signal.removeEventListener("abort", onAbort);
522
+ reject(new Error("auth-broker request aborted"));
523
+ };
524
+ signal.addEventListener("abort", onAbort, { once: true });
525
+ promise.then(
526
+ value => {
527
+ signal.removeEventListener("abort", onAbort);
528
+ resolve(value);
529
+ },
530
+ err => {
531
+ signal.removeEventListener("abort", onAbort);
532
+ reject(err);
533
+ },
534
+ );
535
+ });
536
+ }
537
+
538
+ #loadUsageReports(): Promise<UsageReport[] | null> {
539
+ const cached = this.#usageCache;
540
+ if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
541
+ return Promise.resolve(cached.reports);
542
+ }
543
+ if (this.#usageInflight) return this.#usageInflight;
544
+ const inflight = this.#client
545
+ .fetchUsage()
546
+ .then(body => {
547
+ this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
548
+ return body.reports;
549
+ })
550
+ .catch(error => {
551
+ logger.warn("auth-broker usage fetch failed", { error: String(error) });
552
+ return null;
553
+ })
554
+ .finally(() => {
555
+ this.#usageInflight = undefined;
556
+ });
557
+ this.#usageInflight = inflight;
558
+ return inflight;
559
+ }
560
+
561
+ close(): void {
562
+ if (this.#closed) return;
563
+ this.#closed = true;
564
+ this.#backgroundAbort.abort();
565
+ this.#cache.clear();
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Match a broker-supplied usage report to a specific OAuth credential. The
571
+ * broker returns aggregate reports across all credentials it manages, so we
572
+ * pick the one whose identity (accountId / email / projectId) lines up with
573
+ * the credential the caller is asking about.
574
+ *
575
+ * Falls back to the lone candidate when only one matches the provider; falls
576
+ * through to `null` when nothing matches, which `AuthStorage` treats as "no
577
+ * usage data" (ranking proceeds without a usage signal for this credential).
578
+ */
579
+ function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
580
+ const candidates = reports.filter(report => report.provider === provider);
581
+ if (candidates.length === 0) return null;
582
+ if (candidates.length === 1) return candidates[0];
583
+ const accountId = credential.accountId?.trim().toLowerCase();
584
+ const email = credential.email?.trim().toLowerCase();
585
+ const projectId = credential.projectId?.trim().toLowerCase();
586
+ for (const report of candidates) {
587
+ if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
588
+ }
589
+ return null;
590
+ }
591
+
592
+ function reportMatchesIdentity(
593
+ report: UsageReport,
594
+ accountId: string | undefined,
595
+ email: string | undefined,
596
+ projectId: string | undefined,
597
+ ): boolean {
598
+ const metadata = (report.metadata ?? {}) as Record<string, unknown>;
599
+ if (accountId) {
600
+ const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
601
+ if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
602
+ for (const limit of report.limits) {
603
+ if (limit.scope.accountId?.toLowerCase() === accountId) return true;
604
+ }
605
+ }
606
+ if (email) {
607
+ const metaEmail = readMetadataString(metadata, "email");
608
+ if (metaEmail && metaEmail.toLowerCase() === email) return true;
609
+ }
610
+ if (projectId) {
611
+ const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
612
+ if (metaProject && metaProject.toLowerCase() === projectId) return true;
613
+ for (const limit of report.limits) {
614
+ if (limit.scope.projectId?.toLowerCase() === projectId) return true;
615
+ }
616
+ }
617
+ return false;
618
+ }
619
+
620
+ function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
621
+ const value = metadata[key];
622
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
623
+ }