cartethyia 2.7.0 → 2.7.2

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 (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,15 +1,14 @@
1
1
  // Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
2
2
  import { GatewayError } from "../protocol/gateway-error";
3
- import { Agent as HttpAgent, request as httpRequest, type RequestOptions } from "node:http";
4
- import { Agent as HttpsAgent, request as httpsRequest } from "node:https";
5
- import { Readable, type Transform } from "node:stream";
6
- import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
3
+ import { Agent as HttpAgent } from "node:http";
4
+ import { Agent as HttpsAgent } from "node:https";
5
+ import { Agent as UndiciAgent, fetch as undiciFetch, type UndiciRequestInit } from "undici";
6
+ import { SocksProxyAgent } from "socks-proxy-agent";
7
7
  import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
8
8
  import type { SsrfPolicy } from "../config/network";
9
9
  import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
10
10
  import { metrics } from "../observability/metrics";
11
11
  import { trackNetworkCall } from "../observability/performance-metrics";
12
- import { stripResponseHeaders } from "./response-headers";
13
12
  // Validated fetch — single egress with ProxyAgent support
14
13
  export interface ValidatedFetchOptions {
15
14
  readonly fetchFn?: typeof fetch;
@@ -17,73 +16,25 @@ export interface ValidatedFetchOptions {
17
16
  readonly maxRedirects?: number;
18
17
  readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
19
18
  readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
19
+ /**
20
+ * Proxy URL to route the dial through, forwarded to the runtime-native fetch
21
+ * (`proxy` option). Pool egress passes the pool's endpoint here; without it
22
+ * a request that should leave through a proxy leaves directly.
23
+ */
24
+ readonly proxyUrl?: string;
25
+ /**
26
+ * Fetch implementation used for SOCKS5 dials (via the undici dispatcher).
27
+ * Defaults to undici's fetch; injectable for offline tests.
28
+ */
29
+ readonly socksFetch?: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response>;
20
30
  }
21
31
  export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
22
32
 
23
33
  /**
24
- * Node validates `agent.protocol` per request, so a pool pair contributes
25
- * the flavor matching the *target* scheme (redirect hops reselect, since
26
- * the scheme can change mid-chain).
34
+ * Whether a configured pool/relay agent owns this dial. When it does, the
35
+ * proxy resolves the upstream target itself, so the local resolve below is an
36
+ * advisory SSRF check rather than a prerequisite for egress.
27
37
  */
28
- function agentForTarget(
29
- agent: HttpAgent | HttpsAgent | ProxyAgentPair | undefined,
30
- protocol: string,
31
- ): HttpAgent | HttpsAgent | undefined {
32
- if (!agent) return undefined;
33
- if (isProxyAgentPair(agent)) return protocol === "https:" ? agent.https : agent.http;
34
- return agent;
35
- }
36
-
37
- /**
38
- * Keep-alive options for the direct (non-pool) egress agents.
39
- *
40
- * Matched to `network/pool/agent.ts`, which builds pool agents from
41
- * `resolveProxyMaxSockets()` / `resolveProxyMaxFreeSockets()` and
42
- * `PROXY_KEEP_ALIVE_TIMEOUT_MS`. Direct egress has no per-pool config to
43
- * read, so it uses the documented defaults those resolvers return.
44
- */
45
- const DIRECT_AGENT_OPTIONS = {
46
- keepAlive: true,
47
- maxSockets: 100,
48
- maxFreeSockets: 20,
49
- timeout: 60_000,
50
- } as const;
51
-
52
- /**
53
- * An agent whose socket key includes the SSRF-validated address.
54
- *
55
- * The direct (non-pool) path pins egress by passing a `lookup` that returns the
56
- * one validated address, but `Agent#getName` — the keep-alive socket key —
57
- * hashes only host/port/localAddress/family. Two requests to the same
58
- * `host:port` that validated to *different* addresses therefore share a pooled
59
- * socket, and the second one silently egresses to the first one's address:
60
- * the pin is bypassed without any error surfacing.
61
- *
62
- * Including the address in the key makes each validated address its own socket
63
- * bucket, so a re-resolved address always dials fresh while the *same* address
64
- * still reuses its socket (keep-alive is preserved, not defeated).
65
- *
66
- * This is a real gap, not a theoretical one: with no agent at all the request
67
- * falls back to the global agent, which has the same key shape *and* is shared
68
- * with unrelated traffic.
69
- */
70
- class PinnedAddressAgent extends HttpAgent {
71
- override getName(options: Record<string, unknown> = {}): string {
72
- const base = super.getName(options as never);
73
- const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
74
- return `${base}:${pinned ?? "-"}`;
75
- }
76
- }
77
- class PinnedAddressHttpsAgent extends HttpsAgent {
78
- override getName(options: Record<string, unknown> = {}): string {
79
- const base = super.getName(options as never);
80
- const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
81
- return `${base}:${pinned ?? "-"}`;
82
- }
83
- }
84
-
85
- const DIRECT_HTTP_AGENT = new PinnedAddressAgent(DIRECT_AGENT_OPTIONS);
86
- const DIRECT_HTTPS_AGENT = new PinnedAddressHttpsAgent(DIRECT_AGENT_OPTIONS);
87
38
 
88
39
  /**
89
40
  * Builds the application-relay request used by hosted relay front doors.
@@ -110,162 +61,127 @@ function relayRequest(
110
61
  return { url: relayUrl, init: { ...init, headers } };
111
62
  }
112
63
 
64
+ const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
65
+
66
+ function stripCredentialsOnCrossOrigin(
67
+ headersInit: HeadersInit | undefined,
68
+ ): Headers {
69
+ const headers = new Headers(headersInit);
70
+ for (const name of CREDENTIAL_HEADERS) headers.delete(name);
71
+ return headers;
72
+ }
73
+
113
74
  /**
114
- * Decompresses an upstream body the way `fetch` would, because this path
115
- * bypasses `fetch`.
116
- *
117
- * `node:http` hands back the raw body and does not decode `content-encoding`,
118
- * while `fetch`/Undici transparently gunzips. Rebuilding a `Response` around the
119
- * still-compressed stream therefore told every consumer the body was plain when
120
- * it was not: `response.text()` returned binary garbage, and a JSON parse on it
121
- * failed as `platform_unavailable: Claude response is not valid JSON`. That is
122
- * why only the upstreams that actually compress (`br` for Anthropic) appeared
123
- * broken while others worked — nothing here requested an encoding, but a server
124
- * may compress anyway.
125
- *
126
- * Returns the stream untouched for `identity`, an unknown encoding, or when no
127
- * `content-encoding` is present.
75
+ * The injected dialer. `fetchFn` is the seam offline suites use to record
76
+ * requests without a socket; the runtime fetch is the only other dialer.
77
+ */
78
+ function dialer(options: ValidatedFetchOptions): typeof fetch {
79
+ return options.fetchFn ?? fetch;
80
+ }
81
+
82
+ /**
83
+ * Builds the `undici` dispatcher that routes a dial through a SOCKS5 pool
84
+ * agent. The runtime-native fetch does not support `socks5://` proxies
85
+ * (`UnsupportedProxyProtocol`), so the socks pair drives the transport via
86
+ * undici's dispatcher instead — no node `http`/`https` adapter is involved.
128
87
  */
129
- function decompressBody(
130
- body: Readable,
131
- encoding: string | undefined,
132
- ): { stream: Readable; encoding: string | undefined } {
133
- const normalized = encoding?.trim().toLowerCase();
134
- if (normalized === undefined || normalized === "" || normalized === "identity") {
135
- return { stream: body, encoding: undefined };
88
+ let socksDispatcherCache: WeakMap<SocksProxyAgent, UndiciAgent> | undefined;
89
+ function socksDispatcherFor(agent: SocksProxyAgent): UndiciAgent {
90
+ socksDispatcherCache ??= new WeakMap();
91
+ let dispatcher = socksDispatcherCache.get(agent);
92
+ if (!dispatcher) {
93
+ dispatcher = new UndiciAgent({
94
+ connect: async (opts) => {
95
+ const o = opts as {
96
+ hostname?: string;
97
+ port?: number | string;
98
+ family?: number;
99
+ lookup?: (hostname: string, options: unknown, cb: (err: Error | null, addresses: unknown) => void) => void;
100
+ };
101
+ return await agent.connect(
102
+ {} as never,
103
+ {
104
+ host: o.hostname ?? "localhost",
105
+ port: Number(o.port ?? 80),
106
+ ...(o.family === undefined ? {} : { family: o.family }),
107
+ ...(o.lookup === undefined ? {} : { lookup: o.lookup }),
108
+ } as never,
109
+ );
110
+ },
111
+ });
112
+ socksDispatcherCache.set(agent, dispatcher);
136
113
  }
137
- const decoder: Transform | undefined =
138
- normalized === "gzip" || normalized === "x-gzip"
139
- ? createGunzip()
140
- : normalized === "deflate"
141
- ? createInflate()
142
- : normalized === "br"
143
- ? createBrotliDecompress()
144
- : undefined;
145
- // An encoding we cannot decode (e.g. zstd) is left alone: forwarding the
146
- // header with the raw body keeps the caller's own decoder in charge, which is
147
- // strictly better than claiming a plain body we never decoded.
148
- if (decoder === undefined) return { stream: body, encoding: normalized };
149
- body.on("error", (error) => decoder.destroy(error));
150
- return { stream: body.pipe(decoder), encoding: undefined };
114
+ return dispatcher;
151
115
  }
152
116
 
153
- async function pinnedFetch(
117
+ /**
118
+ * One dial, always through `fetch`.
119
+ *
120
+ * The runtime's own fetch (Bun/Undici) is the transport the clients this
121
+ * gateway impersonates actually use, and several upstreams meter per client
122
+ * transport fingerprint — OpenCode's anonymous free tier refuses a
123
+ * Node-stack dial with 429 FreeUsageLimitError while accepting the identical
124
+ * bytes over this one. There is no Node `http`/`https` path to choose
125
+ * between: the SSRF resolve/validate pass that runs before this dial is the
126
+ * security boundary, and it is identical for every caller.
127
+ *
128
+ * A configured pool/relay agent is still honored through `dispatcher` when the
129
+ * runtime supports it (Bun and Undici both do); when it is not supported the
130
+ * agent is ignored rather than silently replaced by an unvalidated dial.
131
+ *
132
+ * `content-length` is set for a known-length body only when the caller did not
133
+ * set it: without the header the request falls back to chunked encoding, and
134
+ * some upstream gateways wait for the complete chunked stream before routing
135
+ * the request — an avoidable round trip per dial.
136
+ */
137
+ async function dial(
154
138
  url: URL,
155
139
  init: RequestInit,
156
- resolvedAddress: string | undefined,
157
- agent?: HttpAgent | HttpsAgent,
140
+ fetchFn: typeof fetch,
141
+ proxyUrl: string | undefined,
142
+ dispatcher?: UndiciAgent,
143
+ socksFetch: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response> = undiciFetch,
158
144
  ): Promise<Response> {
159
- const request = url.protocol === "https:" ? httpsRequest : httpRequest;
160
145
  const headers = new Headers(init.headers);
161
- // String/Buffer/Uint8Array bodies go direct: routing them through
162
- // `new Response(body).arrayBuffer()` copied the whole body per request.
163
146
  const rawBody = init.body;
164
- const body =
165
- rawBody === undefined || rawBody === null
166
- ? undefined
167
- : typeof rawBody === "string" || Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
168
- ? rawBody
169
- : Buffer.from(await new Response(rawBody).arrayBuffer());
170
- const outboundHeaders: Record<string, string> = {};
171
- headers.forEach((value, key) => {
172
- outboundHeaders[key] = value;
173
- });
174
- // A known-length JSON/string body must not fall back to chunked encoding.
175
- // Upstream gateways may wait for the complete chunked stream before routing
176
- // the request, adding an avoidable RTT compared with global fetch/Undici.
177
- if (body !== undefined && !headers.has("content-length")) {
178
- outboundHeaders["content-length"] =
179
- typeof body === "string" ? String(Buffer.byteLength(body, "utf8")) : String(body.byteLength);
147
+ if (rawBody !== undefined && rawBody !== null && !headers.has("content-length")) {
148
+ const bytes =
149
+ typeof rawBody === "string"
150
+ ? Buffer.byteLength(rawBody, "utf8")
151
+ : Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
152
+ ? rawBody.byteLength
153
+ : (await new Response(rawBody).arrayBuffer()).byteLength;
154
+ headers.set("content-length", String(bytes));
180
155
  }
181
- const lookup = Object.assign(
182
- (
183
- _hostname: string,
184
- options: { all?: boolean; family?: number } | number,
185
- callback: (
186
- error: Error | null,
187
- address?: string | Array<{ address: string; family: number }>,
188
- family?: number,
189
- ) => void,
190
- ): void => {
191
- if (resolvedAddress === undefined) {
192
- callback(new Error("no validated address available"));
193
- return;
194
- }
195
- const opts = typeof options === "number" ? { family: options } : options;
196
- const family = resolvedAddress.includes(":") ? 6 : 4;
197
- if (opts?.all) {
198
- callback(null, [{ address: resolvedAddress, family }] as never);
199
- return;
200
- }
201
- callback(null, resolvedAddress, family);
202
- },
203
- // Read by `PinnedAddressAgent#getName` so the keep-alive socket key
204
- // carries the validated address. See the class comment for why.
205
- { pinnedAddress: resolvedAddress },
206
- ) as unknown as NonNullable<RequestOptions["lookup"]>;
207
- // Direct egress gets a dedicated agent as well as the pinning `lookup`.
208
- // Passing only `lookup` left the request on the global agent, whose socket
209
- // key ignores `lookup` — so a re-resolved address could reuse a socket
210
- // dialed against an earlier one and egress past the SSRF check. The direct
211
- // agent keys its sockets on the pinned address, so the pin holds.
212
- const directAgent = url.protocol === "https:" ? DIRECT_HTTPS_AGENT : DIRECT_HTTP_AGENT;
213
- return new Promise<Response>((resolve, reject) => {
214
- const nodeRequest = request(
215
- {
216
- protocol: url.protocol,
217
- hostname: url.hostname,
218
- port: url.port ? url.port : url.protocol === "https:" ? "443" : "80",
219
- path: `${url.pathname}${url.search}`,
220
- method: init.method ?? "GET",
221
- headers: outboundHeaders,
222
- signal: init.signal ?? undefined,
223
- ...(agent ? { agent } : { agent: directAgent, lookup }),
224
- ...(url.protocol === "https:" ? { servername: url.hostname } : {}),
225
- },
226
- (response) => {
227
- const responseHeaders = stripResponseHeaders(response.headers);
228
- const { stream, encoding } = decompressBody(response, response.headers["content-encoding"]);
229
- if (encoding === undefined) {
230
- // Body is plain (decoded here, or never compressed): the header must
231
- // not survive, or the caller would try to decode a decoded body.
232
- responseHeaders.delete("content-encoding");
233
- }
234
- // A compressed body this path cannot decode keeps its header so the
235
- // caller's decoder still applies.
236
- const webStream = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
237
- resolve(
238
- new Response(webStream, {
239
- status: response.statusCode ?? 502,
240
- headers: responseHeaders,
241
- }),
242
- );
243
- },
244
- );
245
- nodeRequest.on("error", reject);
246
- if (body !== undefined) nodeRequest.write(body);
247
- nodeRequest.end();
156
+ if (dispatcher !== undefined) {
157
+ return await socksFetch(url, {
158
+ ...init,
159
+ headers,
160
+ dispatcher,
161
+ });
162
+ }
163
+ return await fetchFn(url, {
164
+ ...init,
165
+ headers,
166
+ ...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
248
167
  });
249
168
  }
250
169
 
251
- const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
252
-
253
- function stripCredentialsOnCrossOrigin(
254
- headersInit: HeadersInit | undefined,
255
- ): Headers {
256
- const headers = new Headers(headersInit);
257
- for (const name of CREDENTIAL_HEADERS) headers.delete(name);
258
- return headers;
259
- }
260
-
261
170
  export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
262
171
  const policy = options.policy ?? {};
263
172
  const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
264
173
  const resolveFn = options.resolveFn ?? resolveAllAddresses;
174
+ const fetchFn = dialer(options);
265
175
  const configuredAgent = options.agent;
266
176
  const relayEndpoint = isProxyAgentPair(configuredAgent)
267
177
  ? configuredAgent.relayEndpoint
268
178
  : undefined;
179
+ const socksAgent = isProxyAgentPair(configuredAgent)
180
+ ? configuredAgent.socks
181
+ : undefined;
182
+ const socksFetch = options.socksFetch ?? undiciFetch;
183
+ const proxyUrl =
184
+ options.proxyUrl ?? (isProxyAgentPair(configuredAgent) ? configuredAgent.proxyUrl : undefined);
269
185
  return async (input, init = {}) => {
270
186
  const startedAt = performance.now();
271
187
  let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
@@ -319,14 +235,14 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
319
235
  let response: Response;
320
236
  if (relayEndpoint) {
321
237
  const relay = relayRequest(relayEndpoint, url, requestInit);
322
- response = options.fetchFn
323
- ? await options.fetchFn(relay.url, relay.init)
324
- : await pinnedFetch(relay.url, relay.init, relayResolvedAddress!);
325
- } else if (options.fetchFn) {
326
- response = await options.fetchFn(url, requestInit);
238
+ // The relay front door is reached directly: it reads the target from
239
+ // `x-relay-target`/`x-relay-path` and performs the upstream fetch
240
+ // itself, so the runtime fetch must NOT be given `proxy` here — that
241
+ // would make it attempt a CONNECT tunnel against the relay, which a
242
+ // serverless front door refuses (400).
243
+ response = await dial(relay.url, relay.init, fetchFn, undefined);
327
244
  } else {
328
- const agent = agentForTarget(configuredAgent, url.protocol);
329
- response = await pinnedFetch(url, requestInit, resolvedAddress!, agent);
245
+ response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
330
246
  }
331
247
  if (response.status < 300 || response.status >= 400) {
332
248
  trackNetworkCall(url.hostname, performance.now() - startedAt);
@@ -309,6 +309,18 @@ export interface ProxyAgentPair {
309
309
  * validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
310
310
  */
311
311
  readonly relayEndpoint?: URL;
312
+ /**
313
+ * The proxy URL this pool dials through, forwarded to the runtime-native
314
+ * fetch (`proxy` option). The native transport has no agent concept, so
315
+ * the URL is what carries the pool egress.
316
+ */
317
+ readonly proxyUrl?: string;
318
+ /**
319
+ * The SOCKS5 agent instance for socks pools. The runtime-native fetch does
320
+ * not support `socks5://` proxies, so the validated fetcher drives this via
321
+ * the `undici` dispatcher when the pool is a socks pair.
322
+ */
323
+ readonly socks?: SocksProxyAgent;
312
324
  }
313
325
 
314
326
  export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
@@ -407,6 +419,7 @@ export function createHttpProxyAgent(
407
419
  return {
408
420
  http: new HttpProxyAgent(proxy, policy, config, poolId),
409
421
  https: new HttpsProxyAgent(proxy, policy, config, poolId),
422
+ proxyUrl: proxy.toString(),
410
423
  ...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
411
424
  };
412
425
  }
@@ -472,6 +485,8 @@ export function createSocks5Agent(
472
485
  return {
473
486
  http: http as unknown as HttpAgent,
474
487
  https: https as unknown as HttpsAgent,
488
+ socks: http,
489
+ proxyUrl: withCredential(url, credential).toString(),
475
490
  };
476
491
  }
477
492
 
@@ -28,6 +28,13 @@ export interface ProxyCooldownEntry {
28
28
  export interface PoolRotation {
29
29
  readonly key: string;
30
30
  readonly rotateCount: number;
31
+ /**
32
+ * The strategy that produced this rotation request. `round_robin` serves
33
+ * `rotateCount` requests per pool before advancing; `random` scatters each
34
+ * selection across the eligible pools in a fresh random order. Absent means
35
+ * the default weighted least-loaded scan.
36
+ */
37
+ readonly strategy?: "round_robin" | "random";
31
38
  }
32
39
 
33
40
  export interface PoolUsageSnapshot {
@@ -10,6 +10,7 @@
10
10
  import { redisEvalNumber, type RedisClient } from "../../persistence/redis";
11
11
  import { resolveInflightTtlSeconds } from "../../config/timeouts";
12
12
  import { log } from "../../observability/logger";
13
+ import { providerRateLimitIsIpScoped } from "../../providers/provider-metadata";
13
14
  import {
14
15
  COOLDOWN_CLEAR_SCRIPT,
15
16
  COOLDOWN_FLAG_SCRIPT,
@@ -67,6 +68,21 @@ export function effectiveConcurrency(limit: number): number {
67
68
  return limit;
68
69
  }
69
70
 
71
+ /**
72
+ * In-place Fisher–Yates shuffle. The `random` pool strategy uses it to scatter
73
+ * each selection across the eligible pools instead of always scanning from a
74
+ * fixed (or cursor-driven) order, so a burst does not clump onto the first
75
+ * pool and burn it while siblings sit idle.
76
+ */
77
+ function shuffleArray<T>(items: T[]): void {
78
+ for (let i = items.length - 1; i > 0; i -= 1) {
79
+ const j = Math.floor(Math.random() * (i + 1));
80
+ const swap = items[i]!;
81
+ items[i] = items[j]!;
82
+ items[j] = swap;
83
+ }
84
+ }
85
+
70
86
  function normalizedWeight(weight: number | undefined): number {
71
87
  if (weight === undefined) return DEFAULT_PROXY_WEIGHT;
72
88
  if (!Number.isInteger(weight) || weight < 1 || weight > MAX_PROXY_WEIGHT) return 0;
@@ -257,6 +273,20 @@ export class NetworkPoolSelector {
257
273
  return `${poolId}::${providerId.toLowerCase()}`;
258
274
  }
259
275
 
276
+ /**
277
+ * Seeds one cooldown entry into the local map without touching Redis.
278
+ *
279
+ * Boot restore is the only writer: it replays the durable markers the
280
+ * previous process recorded (`network_pools.provider_cooldowns`, see
281
+ * `pool-cooldown-store.ts`) so dispatch starts remembering the egresses the
282
+ * upstream is still refusing. Anything whose window already passed is
283
+ * dropped by `restore…` before this is reached.
284
+ */
285
+ hydrateProviderCooldown(entry: ProxyCooldownEntry): void {
286
+ enforceCooldownLimit(this.state.cooldowns);
287
+ this.state.cooldowns.set(this.cooldownKey(entry.poolId, entry.providerId), entry);
288
+ }
289
+
260
290
  async flagProviderCooldown(
261
291
  poolId: string,
262
292
  providerId: string,
@@ -308,12 +338,32 @@ export class NetworkPoolSelector {
308
338
  * The entry uses the same storage as a cooldown so every reader that already
309
339
  * skips a cooling pool skips this one too; `reason` is what the console
310
340
  * renders to tell an operator-blocked proxy from a throttled one.
341
+ *
342
+ * Refuses to retire an egress for a provider whose limits belong to a
343
+ * credential. An address-scoped verdict only exists for providers that count
344
+ * requests by egress (`ipScopedRateLimit` — opencode); for everyone else a
345
+ * refusal belongs to the account that was dialed, so retiring the pool would
346
+ * sideline an address that is still serving every sibling account. Callers
347
+ * keep their own guard, and this one is the backstop that makes the rule
348
+ * impossible to route around from a new call site.
311
349
  */
312
350
  async flagProviderBlocked(
313
351
  poolId: string,
314
352
  providerId: string,
315
353
  reason = "Upstream refused this egress address",
316
354
  ): Promise<ProxyCooldownEntry> {
355
+ if (!providerRateLimitIsIpScoped(providerId)) {
356
+ log.warn("[pool-selector] refused to block an egress for a non-IP-scoped provider", {
357
+ networkPoolId: poolId,
358
+ providerId,
359
+ });
360
+ return {
361
+ poolId,
362
+ providerId: providerId.toLowerCase(),
363
+ until: Date.now(),
364
+ reason,
365
+ };
366
+ }
317
367
  const entry = await this.flagProviderCooldown(poolId, providerId, PROVIDER_BLOCK_MS, reason);
318
368
  const blocked: ProxyCooldownEntry = { ...entry, blocked: true };
319
369
  // Rewrite both copies with the flag: the cooldown write above is shared
@@ -455,6 +505,38 @@ export class NetworkPoolSelector {
455
505
  await this.clearRedisCooldown(poolId, providerId);
456
506
  }
457
507
 
508
+ /**
509
+ * Releases a *retired* egress only, leaving a cooldown intact.
510
+ *
511
+ * The two are one key, so `clearProviderCooldown` cannot be reused: it would
512
+ * also drop a live 429 cooldown and hand a spent address back to dispatch.
513
+ * This is the correction a 429 probe is entitled to make — the address is
514
+ * answering its own rate limiter, so it was never retired — and nothing more.
515
+ *
516
+ * No-op when the marker is a cooldown, and no-op when there is no marker.
517
+ */
518
+ async clearProviderBlockedOnly(poolId: string, providerId: string): Promise<void> {
519
+ const key = this.cooldownKey(poolId, providerId);
520
+ const local = this.state.cooldowns.get(key);
521
+ if (local !== undefined) {
522
+ if (local.blocked !== true) return;
523
+ this.state.cooldowns.delete(key);
524
+ }
525
+ if (!this.redis) return;
526
+ try {
527
+ const raw = await this.redis.get(proxyCooldownKey(poolId, providerId));
528
+ const entry = raw ? parseCooldownEntry(raw) : undefined;
529
+ // A missing or unparseable marker says nothing about the address, and
530
+ // deleting a key we cannot read would drop a cooldown this is forbidden
531
+ // from touching. Only a marker that names itself a block is released.
532
+ if (entry?.blocked !== true) return;
533
+ await this.clearRedisCooldown(poolId, providerId);
534
+ } catch {
535
+ // The local copy is already corrected; the Redis mirror stays until its
536
+ // TTL or the next write.
537
+ }
538
+ }
539
+
458
540
  /**
459
541
  * Removes one cooldown marker and its index entry in one atomic step.
460
542
  *
@@ -564,6 +646,26 @@ export class NetworkPoolSelector {
564
646
  if (scored.length === 0) return undefined;
565
647
 
566
648
  if (!this.redis) {
649
+ if (rotation?.strategy === "random") {
650
+ // Random scatter: shuffle the eligible pools and take the first one
651
+ // with a free slot, falling through like the ordered scans do.
652
+ shuffleArray(scored);
653
+ for (const candidate of scored) {
654
+ if (this.getInflight(candidate.poolId) >= candidate.capacity) continue;
655
+ const slot =
656
+ providerCapacity === undefined
657
+ ? this.acquire(candidate.poolId, candidate.capacity)
658
+ : this.acquireProvider(
659
+ candidate.poolId,
660
+ providerId,
661
+ candidate.capacity,
662
+ providerCapacity,
663
+ );
664
+ if (!slot.acquired) continue;
665
+ return { poolId: candidate.poolId, release: slot.release };
666
+ }
667
+ return undefined;
668
+ }
567
669
  if (rotation) {
568
670
  // Round robin: scan from the tenant cursor; a pool that is full falls
569
671
  // through to the next offset (failover). Only a successful admission
@@ -662,7 +764,13 @@ export class NetworkPoolSelector {
662
764
  // Original (pre-sort) index = absolute rotation position and
663
765
  // deterministic tie-breaking.
664
766
  const absoluteIndex = new Map(withCounts.map((candidate, index) => [candidate.poolId, index]));
665
- if (rotation) {
767
+ if (rotation?.strategy === "random") {
768
+ // Random scatter: shuffle before the admit loop, which still enforces
769
+ // capacity and fails over to the next pool exactly like the ordered
770
+ // scans — but the order each request tries is fresh, so a burst does
771
+ // not gang up on the first pools in a fixed order.
772
+ shuffleArray(withCounts);
773
+ } else if (rotation) {
666
774
  // Round robin orders strictly by the tenant cursor; the CAS admit below
667
775
  // still enforces capacity, so a full pool falls through to the next
668
776
  // offset exactly like account failover.
@@ -715,7 +823,7 @@ export class NetworkPoolSelector {
715
823
  // to the next pool, which is the whole point of the ceiling: the request
716
824
  // moves to an egress that still has room for this provider.
717
825
  if (result !== 1) continue;
718
- if (rotation) {
826
+ if (rotation && rotation.strategy !== "random") {
719
827
  advanceRotation(
720
828
  this.state.rotationCursors,
721
829
  rotation.key,