cartethyia 2.6.7 → 2.7.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 (335) hide show
  1. package/.env.example +1 -7
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-D-J75hBM.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-Dd8ZSamp.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-QoJ6foxo.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-CKUFTcW4.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.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-Bwotu8UJ.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-ATpaxrbl.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  33. package/dist/dashboard/assets/SettingsPage-CETophpx.js +1 -0
  34. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-CAK-EN9z.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-B7W2_RhT.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-C23S9sgp.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-DU6qrMA_.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.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-e-Mlk0FH.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-Iec_ywbD.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-BLBLxX0h.js} +3 -3
  64. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-Bqy8RFvM.js} +1 -1
  66. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
  70. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BuDsrsPn.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-BeLRhsmt.js → index-D6QzEC_n.js} +2 -2
  74. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-WlyHWeG2.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-DaJ5baJr.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-B3oSK13z.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-ta6GMb-8.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  86. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  87. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-sMD965oO.js} +1 -1
  88. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  89. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-BO9dKaFr.js} +1 -1
  90. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  91. package/dist/dashboard/assets/providers/inception.svg +5 -0
  92. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  93. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CfL_Zdoh.js} +1 -1
  94. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  95. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-BnIpi1IJ.js} +1 -1
  96. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  97. package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-Cj5Nw43g.js} +1 -1
  98. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  99. package/dist/dashboard/assets/settings-BAFhDgX8.js +1 -0
  100. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  101. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-EMrOMt4I.js} +1 -1
  102. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  103. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-CWoo7ty5.js} +1 -1
  104. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  105. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-CCjT6IkF.js} +1 -1
  106. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-DjkLHUcE.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +55 -54
  112. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  114. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  115. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  116. package/package.json +1 -1
  117. package/scripts/cli/console-menu.ts +5 -2
  118. package/scripts/cli/tray.ts +21 -6
  119. package/src/boot/app.ts +24 -62
  120. package/src/boot/dependencies.ts +732 -750
  121. package/src/config/cooldowns.ts +16 -0
  122. package/src/config/providers.ts +13 -1
  123. package/src/config/resources.ts +13 -80
  124. package/src/config/security.ts +2 -38
  125. package/src/config/spec.ts +16 -23
  126. package/src/console/console-router.ts +7 -7
  127. package/src/console/domain-registration.ts +19 -12
  128. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  129. package/src/console/providers/catalog/contracts.ts +19 -0
  130. package/src/console/providers/catalog/provider-operations.ts +38 -0
  131. package/src/console/providers/catalog/routes.ts +14 -0
  132. package/src/console/providers/catalog/store-helpers.ts +326 -0
  133. package/src/console/providers/catalog/store.ts +1292 -1563
  134. package/src/console/quota/account-quota-global.ts +2 -2
  135. package/src/console/quota/account-quota-shared.ts +1 -1
  136. package/src/console/quota/account-quota-tenant.ts +35 -12
  137. package/src/console/quota/quota-view.ts +2 -2
  138. package/src/console/remote-support/contracts.ts +3 -1
  139. package/src/console/remote-support/service.ts +115 -6
  140. package/src/console/routing/pools/contracts.ts +41 -1
  141. package/src/console/routing/pools/operations.ts +183 -4
  142. package/src/console/routing/pools/relay-deploy.ts +17 -4
  143. package/src/console/routing/pools/routes.ts +30 -0
  144. package/src/console/routing/pools/store.ts +98 -8
  145. package/src/main.ts +16 -2
  146. package/src/network/outbound-fetch.ts +226 -365
  147. package/src/network/pool/agent.ts +8 -0
  148. package/src/network/pool/pool-state.ts +7 -0
  149. package/src/network/pool/selector.ts +110 -2
  150. package/src/network/pool-cooldown-store.ts +252 -0
  151. package/src/network/pool-health-machine.ts +5 -3
  152. package/src/network/pool-health.ts +36 -9
  153. package/src/observability/event-query.ts +57 -0
  154. package/src/observability/metrics.ts +15 -5
  155. package/src/observability/runtime-metrics.ts +30 -15
  156. package/src/persistence/health-event-flush.ts +2 -0
  157. package/src/persistence/schema.ts +19 -0
  158. package/src/persistence/usage-flush.ts +8 -3
  159. package/src/protocol/canonical-model.ts +1 -1
  160. package/src/protocol/dsml.ts +377 -0
  161. package/src/protocol/gateway-error.ts +0 -1
  162. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  163. package/src/protocol/request/chat.ts +7 -7
  164. package/src/protocol/request/codex.ts +2 -2
  165. package/src/protocol/request/gemini.ts +1 -1
  166. package/src/protocol/request/messages.ts +6 -6
  167. package/src/protocol/request/responses.ts +7 -7
  168. package/src/protocol/response/dsml.ts +12 -133
  169. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  170. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  171. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  172. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  173. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  174. package/src/{transport → protocol}/translation/search.ts +1 -1
  175. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  176. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  177. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  178. package/src/protocol/upstream-parsing.ts +176 -0
  179. package/src/providers/adapters/compatible-adapter.ts +11 -2
  180. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  181. package/src/providers/default-registry.ts +21 -5
  182. package/src/providers/discovery/probe-phases.ts +16 -3
  183. package/src/providers/discovery/probing-service.ts +4 -1
  184. package/src/providers/integrations/anthropic.ts +1 -1
  185. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  186. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  187. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  188. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  189. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  190. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  191. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  192. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  193. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  195. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  196. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  197. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  198. package/src/providers/integrations/claude/claude.ts +1 -1
  199. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  200. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  201. package/src/providers/integrations/codex/codex.ts +1 -1
  202. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  203. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  204. package/src/providers/integrations/factory/factory.ts +1 -1
  205. package/src/providers/integrations/github/github.ts +1 -1
  206. package/src/providers/integrations/grok/grok.ts +1 -1
  207. package/src/providers/integrations/inception/provider.ts +91 -0
  208. package/src/providers/integrations/inferhub.ts +1 -1
  209. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  210. package/src/providers/integrations/mistral.ts +193 -19
  211. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  212. package/src/providers/integrations/opencode/prompt.ts +111 -0
  213. package/src/providers/integrations/opencode/provider.ts +101 -23
  214. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  215. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  216. package/src/providers/integrations/qoder/qoder.ts +1641 -884
  217. package/src/providers/integrations/shared/install-id.ts +2 -1
  218. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  219. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  220. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  221. package/src/providers/integrations/trae/trae.ts +106 -23
  222. package/src/providers/operations/account-error-classification.ts +1660 -0
  223. package/src/providers/operations/account-health-service.ts +621 -2097
  224. package/src/providers/operations/account-reset-service.ts +2 -0
  225. package/src/providers/provider-contracts.ts +0 -2
  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/abuse.ts +17 -0
  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 +224 -217
  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 +59 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +0 -18
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +0 -6
  250. package/src/transport/guards/request-context.ts +18 -91
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -95
  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 +41 -31
  257. package/src/transport/routing/router.ts +2 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/workers/buddy-growth-tasks.ts +346 -425
  260. package/src/workers/daily-checkin.ts +17 -6
  261. package/src/workers/quota-refresh-sweep.ts +2 -2
  262. package/src/workers/scheduler.ts +17 -1
  263. package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
  264. package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
  265. package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
  266. package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
  267. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js +0 -1
  268. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
  269. package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
  270. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js +0 -1
  271. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
  272. package/dist/dashboard/assets/LoginPage-DoOUthbG.js +0 -1
  273. package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
  274. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  275. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
  276. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
  277. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js +0 -3
  278. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
  279. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  280. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  281. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js +0 -1
  282. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
  283. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js +0 -7
  284. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
  285. package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
  286. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
  287. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
  288. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js +0 -1
  289. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
  290. package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
  291. package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
  292. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
  293. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
  294. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  295. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  296. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  297. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  298. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  299. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  300. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  301. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  302. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  303. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  304. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  305. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  306. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  307. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  308. package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  310. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  311. package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
  312. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  313. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  314. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  315. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  316. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  317. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  318. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  323. package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
  324. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  325. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  326. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  327. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  328. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  329. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  330. package/src/console/admin/model-abuse/contracts.ts +0 -38
  331. package/src/console/admin/model-abuse/routes.ts +0 -97
  332. package/src/security/model-abuse.ts +0 -704
  333. package/src/transport/guards/concurrency-guard.ts +0 -125
  334. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-BaWd0puM.js} +0 -0
  335. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -3,7 +3,7 @@ import type { Elysia } from "elysia";
3
3
  import { and, eq, isNull } from "drizzle-orm";
4
4
  import { adminAuditLog, providerAccounts } from "../../persistence/schema";
5
5
  import { requireGlobalAdmin } from "../shared/errors";
6
- import { GLOBAL_QUOTA_LENS, invalidateQuotaCache } from "./cache";
6
+ import { GLOBAL_QUOTA_LENS, invalidateQuotaCache } from "../../providers/quota/cache";
7
7
  import { invalidateCredentialCache } from "../../providers/operations/provider-credential-service";
8
8
  import {
9
9
  QUOTA_REFRESH_TIMEOUT_MS,
@@ -12,7 +12,7 @@ import {
12
12
  signalFetch,
13
13
  timeoutSignal,
14
14
  type QuotaRefreshDeps,
15
- } from "./refresh";
15
+ } from "../../providers/quota/refresh";
16
16
  import { sanitizeGlobalAccount } from "./quota-view";
17
17
  import {
18
18
  ACCOUNT_STATUSES,
@@ -1,7 +1,7 @@
1
1
  import type { ConsoleAccessResolver } from "../auth/access";
2
2
  import type { AuditRecorder } from "../auth/audit-recorder";
3
3
  import { markClineApiKeyCredential } from "../../providers/integrations/cline/cline-quota";
4
- import { type QuotaRefreshDeps } from "./refresh";
4
+ import { type QuotaRefreshDeps } from "../../providers/quota/refresh";
5
5
  import type { AccountTestEgressDeps } from "./account-test-egress";
6
6
 
7
7
  /**
@@ -14,7 +14,7 @@ import {
14
14
  getCachedQuotaEntries,
15
15
  getCachedQuotaEntry,
16
16
  invalidateQuotaCache,
17
- } from "./cache";
17
+ } from "../../providers/quota/cache";
18
18
  import {
19
19
  QUOTA_REFRESH_TIMEOUT_MS,
20
20
  refreshAccountQuota,
@@ -22,7 +22,7 @@ import {
22
22
  timeoutSignal,
23
23
  type QuotaRefreshDeps,
24
24
  type QuotaRefreshFailureCode,
25
- } from "./refresh";
25
+ } from "../../providers/quota/refresh";
26
26
  import { selectAccountTestEgress } from "./account-test-egress";
27
27
  import type { ValidatedFetch } from "../../network/outbound-fetch";
28
28
  import type { ProviderId } from "../../providers/provider-contracts";
@@ -39,6 +39,10 @@ import {
39
39
  type BuddyActivityReportResult,
40
40
  type DailyCheckinResult,
41
41
  } from "../../providers/integrations/buddy/buddy-checkin";
42
+ import {
43
+ fetchQoderDailyCheckin,
44
+ isQoderCheckinProvider,
45
+ } from "../../providers/integrations/qoder/qoder-checkin";
42
46
  import { buddyAccountUid } from "../../providers/integrations/buddy/buddy-oauth-shared";
43
47
  import {
44
48
  supportsAccountReset,
@@ -99,12 +103,20 @@ function toBuddyUsageView(
99
103
  };
100
104
  }
101
105
 
102
- /** One account's check-in through the buddy billing facade. */
106
+ /** One account's check-in through the buddy billing facade (Qoder uses its own surface). */
103
107
  async function runAccountCheckin(
104
108
  account: { id: string; providerId: string },
105
109
  credential: string,
106
110
  forceClaim: boolean,
107
111
  ): Promise<DailyCheckinResult> {
112
+ if (isQoderCheckinProvider(account.providerId)) {
113
+ return fetchQoderDailyCheckin({
114
+ providerId: account.providerId,
115
+ accountId: account.id,
116
+ credential,
117
+ fetcher: globalThis.fetch,
118
+ });
119
+ }
108
120
  return fetchDailyCheckin({
109
121
  providerId: account.providerId,
110
122
  accountId: account.id,
@@ -153,10 +165,14 @@ async function runDailyGrowthForAccount(
153
165
  }
154
166
 
155
167
  // Report leg: needs a uid, which only a JWT carries, so an opaque API key
156
- // fails closed instead of reporting under another account's identity.
168
+ // fails closed instead of reporting under another account's identity. Qoder
169
+ // has no activity-report leg — its daily grant is purely the check-in.
170
+ const qoderAccount = isQoderCheckinProvider(String(providerId));
157
171
  const userId = buddyAccountUid(credential);
158
172
  let report: { ok: boolean; message: string };
159
- if (userId === undefined || userId.length === 0) {
173
+ if (qoderAccount) {
174
+ report = { ok: true, message: "Qoder has no activity-report leg" };
175
+ } else if (userId === undefined || userId.length === 0) {
160
176
  report = { ok: false, message: "Report needs an OAuth account (no uid in this credential)" };
161
177
  } else {
162
178
  try {
@@ -180,13 +196,20 @@ async function runDailyGrowthForAccount(
180
196
 
181
197
  let checkin: { ok: boolean; message: string };
182
198
  try {
183
- const result = await fetchDailyCheckin({
184
- providerId,
185
- accountId: account.id,
186
- credential,
187
- forceClaim: true,
188
- fetcher,
189
- });
199
+ const result = qoderAccount
200
+ ? await fetchQoderDailyCheckin({
201
+ providerId: String(providerId),
202
+ accountId: account.id,
203
+ credential,
204
+ fetcher,
205
+ })
206
+ : await fetchDailyCheckin({
207
+ providerId,
208
+ accountId: account.id,
209
+ credential,
210
+ forceClaim: true,
211
+ fetcher,
212
+ });
190
213
  checkin =
191
214
  result.state === "error"
192
215
  ? { ok: false, message: result.error ?? "Daily check-in failed" }
@@ -1,14 +1,14 @@
1
1
  import { providerAccounts } from "../../persistence/schema";
2
2
  import type { ProviderQuotaResult } from "../../providers/quota/quota-contracts";
3
3
  import { log } from "../../observability/logger";
4
- import { GLOBAL_QUOTA_LENS } from "./cache";
4
+ import { GLOBAL_QUOTA_LENS } from "../../providers/quota/cache";
5
5
  import {
6
6
  QUOTA_REFRESH_TIMEOUT_MS,
7
7
  refreshAccountQuota,
8
8
  signalFetch,
9
9
  type QuotaRefreshDeps,
10
10
  type QuotaRefreshTarget,
11
- } from "./refresh";
11
+ } from "../../providers/quota/refresh";
12
12
 
13
13
  /** Shape returned for quota data on dashboard account responses. */
14
14
  export interface QuotaRefreshView {
@@ -21,7 +21,9 @@ export type RemoteSupportTool = (typeof REMOTE_SUPPORT_TOOLS)[number];
21
21
  export type RemoteSupportBlocker =
22
22
  | "not_installed"
23
23
  | "unsupported_platform"
24
- | "launch_failed";
24
+ | "launch_failed"
25
+ /** The publisher launched but has not published an address yet. */
26
+ | "not_ready";
25
27
 
26
28
  export interface RemoteSupportStatus {
27
29
  /** Which publisher is up, or `null` when neither is. */
@@ -186,9 +186,95 @@ async function waitForUrl(dir: string, tool: RemoteSupportTool): Promise<string
186
186
  * live hostname comes from the daemon's own status. The JSON shape has drifted
187
187
  * across releases (`DNSName` at the root in some, nested under `Self` in
188
188
  * others), so both locations are probed and the first hit wins.
189
+ *
190
+ * `status` is probed as well as `serve status`: the latter answers `{}` until
191
+ * the node has actually joined the tailnet, which is exactly when the operator
192
+ * is staring at a "started" publisher with no link. `status` carries
193
+ * `Self.DNSName` and `BackendState` throughout, so it names the node while
194
+ * serve is still coming up.
189
195
  */
190
196
  function tailscaleUrlFromDaemon(): string | null {
197
+ const name = tailscaleDnsName();
198
+ return name === null ? null : `https://${name}`;
199
+ }
200
+
201
+ /**
202
+ * Why a publisher that started has not published an address.
203
+ *
204
+ * For Tailscale the answer is almost always that the node is not on the
205
+ * tailnet yet — still starting, or never logged in — and both are the
206
+ * operator's move, not the gateway's. Naming which one turns a dead end into
207
+ * something to do.
208
+ */
209
+ function notReadyReason(tool: RemoteSupportTool): string {
210
+ if (tool !== "tailscale") return `${tool} started but has not published an address yet.`;
211
+ const state = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", windowsHide: true });
212
+ let backend: string | null = null;
213
+ let authUrl: string | null = null;
214
+ try {
215
+ const bag = JSON.parse(state.stdout) as Record<string, unknown>;
216
+ backend = typeof bag["BackendState"] === "string" ? (bag["BackendState"] as string) : null;
217
+ authUrl = typeof bag["AuthURL"] === "string" && (bag["AuthURL"] as string).length > 0 ? (bag["AuthURL"] as string) : null;
218
+ } catch {
219
+ // An unreadable status still leaves the generic message below.
220
+ }
221
+ if (authUrl !== null) return `Tailscale needs to be logged in. Open ${authUrl} once, then start the tunnel again.`;
222
+ if (backend !== null && backend !== "Running") {
223
+ return `Tailscale is not connected yet (state: ${backend}). Wait for it to finish starting, then start the tunnel again.`;
224
+ }
225
+ return "Tailscale has no tailnet address yet. Run `tailscale up` once, then start the tunnel again.";
226
+ }
227
+
228
+ /** This node's MagicDNS name, or `null` when the daemon has none yet. */
229
+ function tailscaleDnsName(): string | null {
230
+ for (const args of [
231
+ ["serve", "status", "--json"],
232
+ ["status", "--json"],
233
+ ] as const) {
234
+ const name = dnsNameFromStatus(args);
235
+ if (name !== null) return name;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Whether `tailscale serve` is actually publishing anything.
242
+ *
243
+ * `serve status --json` is `{}` when nothing is served and gains `Web`
244
+ * (handlers) or `TCP` entries when something is. The node being online says
245
+ * nothing about this: stopping the tunnel leaves the node up and empties the
246
+ * status, which is why an online node alone was read as "still active".
247
+ */
248
+ export function tailscaleServing(): boolean {
191
249
  const result = spawnSync("tailscale", ["serve", "status", "--json"], { encoding: "utf8", windowsHide: true });
250
+ if (result.status !== 0) return false;
251
+ let parsed: unknown;
252
+ try {
253
+ parsed = JSON.parse(result.stdout);
254
+ } catch {
255
+ return false;
256
+ }
257
+ return serveStatusHasHandler(parsed);
258
+ }
259
+
260
+ /**
261
+ * Whether a parsed `serve status` body publishes anything.
262
+ *
263
+ * Split from the spawn so the shape rules are testable without a daemon: `{}`
264
+ * is the empty answer, and any entry under `Web` or `TCP` is a live one.
265
+ */
266
+ export function serveStatusHasHandler(parsed: unknown): boolean {
267
+ const bag: Record<string, unknown> =
268
+ typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
269
+ for (const key of ["Web", "TCP"]) {
270
+ const entries = bag[key];
271
+ if (typeof entries === "object" && entries !== null && Object.keys(entries).length > 0) return true;
272
+ }
273
+ return false;
274
+ }
275
+
276
+ function dnsNameFromStatus(args: readonly string[]): string | null {
277
+ const result = spawnSync("tailscale", [...args], { encoding: "utf8", windowsHide: true });
192
278
  if (result.status !== 0) return null;
193
279
  let parsed: unknown;
194
280
  try {
@@ -198,12 +284,24 @@ function tailscaleUrlFromDaemon(): string | null {
198
284
  }
199
285
  const bag: Record<string, unknown> =
200
286
  typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
287
+ // Only `status` can prove the node is up — `serve status` carries no node
288
+ // state. A stopped node still reports its last DNS name, and publishing that
289
+ // as a live link is worse than publishing nothing: the operator opens an
290
+ // address that cannot resolve.
291
+ if (args[0] === "status") {
292
+ const self = selfOf(bag);
293
+ const online = self === null ? null : self["Online"];
294
+ if (online === false) return null;
295
+ }
201
296
  const direct = typeof bag["DNSName"] === "string" ? (bag["DNSName"] as string) : null;
202
- const self =
203
- typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
297
+ const self = selfOf(bag);
204
298
  const nested = self && typeof self["DNSName"] === "string" ? (self["DNSName"] as string) : null;
205
- const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "");
206
- return hostname && hostname.length > 0 ? `https://${hostname}` : null;
299
+ const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "").replace(/\.$/, "");
300
+ return hostname && hostname.length > 0 ? hostname : null;
301
+ }
302
+
303
+ function selfOf(bag: Record<string, unknown>): Record<string, unknown> | null {
304
+ return typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
207
305
  }
208
306
 
209
307
  /**
@@ -281,7 +379,11 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
281
379
  // Tailscale runs as a system service — its serve daemon owns its own
282
380
  // lifecycle, so a pid file is not reliable. Ask the daemon.
283
381
  if (tool === "tailscale") {
284
- const url = tailscaleUrlFromDaemon();
382
+ // Both halves are required. A node that is up but serving nothing is
383
+ // not publishing an address, and reporting it as active left the
384
+ // toggle on and a stale link in place after the operator stopped it —
385
+ // `serve status` goes back to `{}` but the node stays `Online`.
386
+ const url = tailscaleServing() ? tailscaleUrlFromDaemon() : null;
285
387
  if (url) return { tool: "tailscale", url };
286
388
  continue;
287
389
  }
@@ -348,7 +450,14 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
348
450
  installed: await detectInstalled(),
349
451
  };
350
452
  }
351
- return await readStatus();
453
+ const status = await readStatus();
454
+ // A publisher that started but published no address is the state that
455
+ // used to look like a silent success: the UI showed the tool as active
456
+ // with no link and no reason. Say what is missing instead.
457
+ if (status.active !== null && status.url === null) {
458
+ return { ...status, blocker: "not_ready", message: notReadyReason(status.active) };
459
+ }
460
+ return status;
352
461
  },
353
462
 
354
463
  async stop(tool) {
@@ -221,7 +221,7 @@ export type PoolStatus = (typeof POOL_STATUSES)[number];
221
221
  * direct callers against the same list (mirrors `POOL_STATUSES`). Values must
222
222
  * stay aligned with the `pool_routing_strategy` enum in `schema.ts`.
223
223
  */
224
- export const POOL_ROUTING_STRATEGIES = ["least_loaded", "round_robin"] as const;
224
+ export const POOL_ROUTING_STRATEGIES = ["least_loaded", "round_robin", "random"] as const;
225
225
  export type PoolRoutingStrategyValue = (typeof POOL_ROUTING_STRATEGIES)[number];
226
226
 
227
227
  export interface PoolStrategySetting {
@@ -351,12 +351,22 @@ export interface NetworkPoolStore {
351
351
  list(tenantId: string): Promise<readonly NetworkPoolRecord[]>;
352
352
  get(tenantId: string, poolId: string): Promise<NetworkPoolRecord | undefined>;
353
353
  create(record: NetworkPoolRecord): Promise<void>;
354
+ /** Inserts many rows in one statement; returns how many landed. */
355
+ createMany?(records: readonly NetworkPoolRecord[]): Promise<number>;
354
356
  update(
355
357
  tenantId: string,
356
358
  poolId: string,
357
359
  patch: Partial<NetworkPoolRecord>,
358
360
  ): Promise<NetworkPoolRecord | undefined>;
361
+ /** Marks many rows `deleted`; returns how many matched. */
362
+ updateManyStatus?(
363
+ tenantId: string,
364
+ poolIds: readonly string[],
365
+ status: NetworkPoolRecord["status"],
366
+ ): Promise<number>;
359
367
  delete(tenantId: string, poolId: string): Promise<boolean>;
368
+ /** Deletes many rows in one statement; returns how many matched. */
369
+ deleteMany?(tenantId: string, poolIds: readonly string[]): Promise<number>;
360
370
  healthCheck(tenantId: string, poolId: string): Promise<HealthCheckResult>;
361
371
  /** Measures download throughput through the pool; never throws for a slow
362
372
  * or refused transfer — a failed measurement is itself the result. */
@@ -379,6 +389,36 @@ export interface NetworkPoolStore {
379
389
  ): Promise<PoolProviderProbeResult>;
380
390
  listHealthEvents(tenantId: string, poolId: string): Promise<readonly PoolHealthEvent[]>;
381
391
  recover(tenantId: string, poolId: string): Promise<boolean>;
392
+ /**
393
+ * Drops the durable provider-cooldown markers on one pool row — the mirror
394
+ * the selector restores from at boot. The in-memory/Redis state is cleared
395
+ * separately by the selector; this keeps the two from drifting after a
396
+ * restart. `onlyBlocked` limits the drop to retired-egress markers, mirroring
397
+ * `clearProviderBlockedOnly`: a 429 probe may prove an address was never
398
+ * retired but must not release its live cooldown. Optional (and a no-op in
399
+ * tests without a backing row store).
400
+ */
401
+ dropProviderCooldownMarker?(
402
+ tenantId: string,
403
+ poolId: string,
404
+ providerId: string,
405
+ onlyBlocked: boolean,
406
+ ): Promise<void>;
407
+ /** Drops every provider's marker on one row (disable/replace paths). */
408
+ dropAllProviderCooldownMarkers?(tenantId: string, poolId: string): Promise<void>;
409
+ /**
410
+ * Writes a probe-measured 429 into the durable mirror and the pool's health
411
+ * history. The probe is the only writer that can *flag* a cooldown from the
412
+ * control plane — its 429 is a fresh measurement of the same condition a
413
+ * dispatch-side 429 records, so it lands in both stores and appears as
414
+ * activity. Optional; absent in tests without a backing row store.
415
+ */
416
+ recordProviderProbeCooldown?(
417
+ tenantId: string,
418
+ poolId: string,
419
+ providerId: string,
420
+ marker: { until: number; reason: string; blocked?: boolean },
421
+ ): Promise<void>;
382
422
  /** Per-tenant pool-group selection strategy; an absent row reads as
383
423
  * `DEFAULT_POOL_STRATEGY`. */
384
424
  getStrategy(tenantId: string): Promise<PoolStrategySetting>;
@@ -2,6 +2,8 @@
2
2
  // behind the routes. Depends only on the contracts and the shared helpers;
3
3
  // `routes.ts` is the only HTTP surface.
4
4
  import { ConsoleDomainError, requireTenantScope } from "../../shared/errors";
5
+ import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
6
+ import { resolvePoolProviderRateLimitCooldownMs } from "../../../config/cooldowns";
5
7
  import type { PoolHealthEvent } from "../../../network/pool-health-machine";
6
8
  import type { AccessDecision } from "../../../security/access-control";
7
9
  import {
@@ -28,6 +30,7 @@ import {
28
30
  type NetworkPoolConfig,
29
31
  type NetworkPoolRecord,
30
32
  type NetworkPoolResponse,
33
+ type PoolStatus,
31
34
  type PoolBatchProbeResult,
32
35
  type PoolSpeedTestResult,
33
36
  type PoolStrategySetting,
@@ -194,6 +197,130 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
194
197
  }
195
198
  return { created, skipped };
196
199
  },
200
+ async createPoolBatch(
201
+ access: AccessDecision | undefined,
202
+ requests: readonly CreateNetworkPoolRequest[],
203
+ ): Promise<{ created: NetworkPoolResponse[]; failed: number }> {
204
+ const a = requireTenantScope(access, "dashboard:write");
205
+ let failed = 0;
206
+ const records: NetworkPoolRecord[] = [];
207
+ for (const request of requests) {
208
+ try {
209
+ const normalizedConfig = normalizePoolConfig(request.config);
210
+ const endpoint = request.endpoint;
211
+ validateEndpoint(request.kind, endpoint, config.ssrfPolicy);
212
+ validateTransportConfig(request.kind, normalizedConfig);
213
+ validatePoolLimits(request.maxInflight, request.weight);
214
+ records.push({
215
+ id: crypto.randomUUID(),
216
+ kind: request.kind,
217
+ endpoint,
218
+ maxInflight: request.maxInflight ?? 10,
219
+ weight: request.weight ?? 100,
220
+ status: "active",
221
+ inflight: 0,
222
+ consecutiveFailures: 0,
223
+ tenantId: a.tenantId,
224
+ createdAt: new Date().toISOString(),
225
+ ...(request.label === undefined ? {} : { label: request.label }),
226
+ ...(Object.keys(normalizedConfig).length > 0 ? { config: normalizedConfig } : {}),
227
+ ...(request.credential === undefined ? {} : { credential: request.credential }),
228
+ ...(request.credential !== undefined ? { hasCredential: true } : {}),
229
+ ...(typeof request.quotaBytes === "number" ? { quotaBytes: request.quotaBytes } : {}),
230
+ ...(request.family === undefined ? {} : { family: request.family }),
231
+ });
232
+ } catch {
233
+ failed += 1;
234
+ }
235
+ }
236
+ // All rows land in one statement: a long paste was one round trip per
237
+ // proxy, which made a 40-line import take minutes.
238
+ let created: NetworkPoolResponse[] = [];
239
+ if (records.length > 0) {
240
+ if (config.store.createMany) {
241
+ await config.store.createMany(records);
242
+ } else {
243
+ for (const record of records) await config.store.create(record);
244
+ }
245
+ created = records.map((record) => sanitizePoolResponse(record));
246
+ await config.auditSink?.record({
247
+ access: a,
248
+ action: "network_pool.created",
249
+ target: created[0]!.id,
250
+ detail: { count: created.length, failed },
251
+ });
252
+ await config.snapshotInvalidator?.invalidate();
253
+ }
254
+ return { created, failed };
255
+ },
256
+ async deletePoolBatch(
257
+ access: AccessDecision | undefined,
258
+ poolIds: readonly string[],
259
+ ): Promise<{ deleted: number; failed: number }> {
260
+ const a = requireTenantScope(access, "dashboard:write");
261
+ let ok = 0;
262
+ if (config.store.deleteMany) {
263
+ ok = await config.store.deleteMany(a.tenantId, poolIds);
264
+ } else {
265
+ for (const poolId of poolIds) {
266
+ try {
267
+ if (await config.store.delete(a.tenantId, poolId)) ok += 1;
268
+ } catch {
269
+ // Missing rows are not failures for a bulk delete.
270
+ }
271
+ }
272
+ }
273
+ // Cooldown markers and cached agents are best-effort here: the rows are
274
+ // already gone, so a failed cleanup must not report a failed delete.
275
+ await Promise.all(
276
+ poolIds.map(async (poolId) => {
277
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId).catch(() => undefined);
278
+ await config.poolSelector?.clearPoolCooldowns(poolId).catch(() => undefined);
279
+ }),
280
+ );
281
+ await config.auditSink?.record({
282
+ access: a,
283
+ action: "network_pool.deleted",
284
+ target: poolIds[0] ?? "",
285
+ detail: { count: ok, failed: poolIds.length - ok },
286
+ });
287
+ if (ok > 0) await config.snapshotInvalidator?.invalidate();
288
+ return { deleted: ok, failed: poolIds.length - ok };
289
+ },
290
+ async setPoolStatusBatch(
291
+ access: AccessDecision | undefined,
292
+ poolIds: readonly string[],
293
+ status: PoolStatus,
294
+ ): Promise<{ updated: number }> {
295
+ const a = requireTenantScope(access, "dashboard:write");
296
+ let updated = 0;
297
+ if (config.store.updateManyStatus) {
298
+ updated = await config.store.updateManyStatus(a.tenantId, poolIds, status);
299
+ } else {
300
+ for (const poolId of poolIds) {
301
+ try {
302
+ if (await config.store.update(a.tenantId, poolId, { status })) updated += 1;
303
+ } catch {
304
+ // Missing rows are not failures for a bulk status change.
305
+ }
306
+ }
307
+ }
308
+ // Any mutation can invalidate the cached dial agent; the batch releases
309
+ // each selected pool's agent so the next dispatch rebuilds fresh.
310
+ await Promise.all(
311
+ poolIds.map(async (poolId) => {
312
+ await config.poolAgentReleaser?.releasePool(poolId, a.tenantId).catch(() => undefined);
313
+ }),
314
+ );
315
+ await config.auditSink?.record({
316
+ access: a,
317
+ action: "network_pool.updated",
318
+ target: poolIds[0] ?? "",
319
+ detail: { status, count: updated },
320
+ });
321
+ if (updated > 0) await config.snapshotInvalidator?.invalidate();
322
+ return { updated };
323
+ },
197
324
  async createPool(
198
325
  access: AccessDecision | undefined,
199
326
  request: CreateNetworkPoolRequest,
@@ -255,6 +382,15 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
255
382
  validateEndpoint(effectiveKind, effectiveEndpoint, config.ssrfPolicy);
256
383
  validateTransportConfig(effectiveKind, normalizedConfig);
257
384
  request = { ...request, endpoint: effectiveEndpoint, config: normalizedConfig };
385
+ // A changed endpoint is a different egress address, so its cooldowns
386
+ // — like its probe verdict — no longer describe this pool. Drop the
387
+ // durable markers with the volatile ones, or a replaced proxy would
388
+ // come back online and immediately be skipped because the previous
389
+ // address's flags were restored at boot.
390
+ if (request.endpoint !== undefined && request.endpoint !== existing.endpoint) {
391
+ await config.poolSelector?.clearPoolCooldowns(poolId).catch(() => undefined);
392
+ await config.store.dropAllProviderCooldownMarkers?.(a.tenantId, poolId);
393
+ }
258
394
  }
259
395
  const updated = await config.store.update(
260
396
  a.tenantId,
@@ -380,18 +516,61 @@ export function createNetworkPoolOperations(config: NetworkPoolConfig) {
380
516
  // still picked a red one": the red was a colour, not a decision.
381
517
  //
382
518
  // `blocked` retires the egress for this provider, so the next dispatch
383
- // skips it. Anything else clears a previous block, because the address is
519
+ // skips it. `allowed` clears a previous block, because the address is
384
520
  // serving again — an operator who replaced a proxy must not have to find
385
521
  // a separate button to un-retire it.
522
+ //
523
+ // `blocked` retires the egress — but only for a provider whose limits
524
+ // follow the address. `flagProviderBlocked` refuses everyone else, so the
525
+ // verdict reported back is what actually happened, not what was asked.
386
526
  if (result.status === "blocked") {
387
- await config.poolSelector.flagProviderBlocked(
527
+ const entry = await config.poolSelector.flagProviderBlocked(
388
528
  poolId,
389
529
  providerId,
390
530
  "Upstream refused this egress address",
391
531
  );
392
- return { ...result, blockedPool: true };
532
+ if (entry.blocked === true) {
533
+ // The retirement claim lands in the mirror and the health history
534
+ // with its provider named, like every other verdict — an enforced
535
+ // state the operator cannot see is the "colour, not a decision"
536
+ // failure mode again.
537
+ await config.store.recordProviderProbeCooldown?.(a.tenantId, poolId, providerId, {
538
+ until: entry.until,
539
+ reason: entry.reason,
540
+ blocked: true,
541
+ });
542
+ }
543
+ return { ...result, blockedPool: entry.blocked === true };
544
+ }
545
+ // A measured success releases the address — in rotation and in the
546
+ // durable mirror the next boot restores from.
547
+ if (result.status === "allowed") {
548
+ await config.poolSelector.clearProviderCooldown(poolId, providerId);
549
+ await config.store.dropProviderCooldownMarker?.(a.tenantId, poolId, providerId, false);
550
+ return { ...result, blockedPool: false };
551
+ }
552
+ // `throttled` (429) proves the address is *serving* — its rate limiter
553
+ // answered — so a stale "retired" claim is corrected. And the throttle
554
+ // itself is a measurement: the egress just demonstrated it is out of
555
+ // allowance, which is exactly when dispatch should skip it **and** the
556
+ // operator sees why. Flag the same (configured) window a dispatch-side
557
+ // 429 would, in rotation memory *and* in the durable mirror, plus the
558
+ // health event the activity panel lists. Only a 429 gap was leaving a
559
+ // rate-limited pool invisible and unflagged.
560
+ if (result.status === "throttled") {
561
+ await config.poolSelector.clearProviderBlockedOnly(poolId, providerId);
562
+ await config.store.dropProviderCooldownMarker?.(a.tenantId, poolId, providerId, true);
563
+ if (providerRateLimitIsIpScoped(providerId)) {
564
+ const durationMs = resolvePoolProviderRateLimitCooldownMs();
565
+ const reason = "Operator probe measured a provider 429";
566
+ await config.poolSelector.flagProviderCooldown(poolId, providerId, durationMs, reason);
567
+ await config.store.recordProviderProbeCooldown?.(a.tenantId, poolId, providerId, {
568
+ until: Date.now() + durationMs,
569
+ reason,
570
+ });
571
+ }
393
572
  }
394
- await config.poolSelector.clearProviderCooldown(poolId, providerId);
573
+ // `unreachable` learned nothing about the address, so nothing changes.
395
574
  return { ...result, blockedPool: false };
396
575
  },
397
576
  /**
@@ -63,7 +63,14 @@ const handler = {
63
63
  }
64
64
  try {
65
65
  const response = await fetch(targetUrl, init);
66
- return new Response(response.body, { status: response.status, headers: response.headers });
66
+ // Forward the body verbatim. Content-encoding/content-length must be
67
+ // stripped: the relay's own runtime already decoded the stream it hands
68
+ // us, so re-sending the original gzip headers makes the caller decompress
69
+ // twice (ZlibError). The body bytes are already plaintext here.
70
+ const headers = new Headers(response.headers);
71
+ headers.delete("content-encoding");
72
+ headers.delete("content-length");
73
+ return new Response(response.body, { status: response.status, headers });
67
74
  } catch (error) {
68
75
  return new Response(JSON.stringify({ error: String((error && error.message) || error) }), {
69
76
  status: 502,
@@ -217,10 +224,14 @@ async function deployVercel(
217
224
  // PATCH is used rather than POST /v1/projects because the project already
218
225
  // exists once the first deployment lands; the first deploy below creates it,
219
226
  // and this call is what pins its region before that deploy runs its build.
227
+ //
228
+ // `ssoProtection: null` disables Vercel Authentication (SSO) on the project.
229
+ // Without it every deployment is protected and answers `302 → vercel.com/
230
+ // sso-api` to anonymous callers — the relay URL exists but is not public.
220
231
  await fetchImpl(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectName)}`, {
221
232
  method: "PATCH",
222
233
  headers: auth,
223
- body: JSON.stringify({ serverlessFunctionRegion: region }),
234
+ body: JSON.stringify({ serverlessFunctionRegion: region, ssoProtection: null }),
224
235
  }).catch(() => undefined);
225
236
  const deployRes = await fetchImpl("https://api.vercel.com/v13/deployments", {
226
237
  method: "POST",
@@ -243,11 +254,13 @@ async function deployVercel(
243
254
  throw new GatewayError("platform_unavailable", deployRes.status, `Vercel deploy failed: ${await readError(deployRes)}`);
244
255
  // Pin the region again after the deploy created the project: the PATCH above
245
256
  // is a no-op on first deploy (the project does not exist yet), and a redeploy
246
- // is what Vercel requires for a region change to take effect anyway.
257
+ // is what Vercel requires for a region change to take effect anyway. The SSO
258
+ // protection reset is repeated for the same reason — the first PATCH had no
259
+ // project to act on.
247
260
  await fetchImpl(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectName)}`, {
248
261
  method: "PATCH",
249
262
  headers: auth,
250
- body: JSON.stringify({ serverlessFunctionRegion: region }),
263
+ body: JSON.stringify({ serverlessFunctionRegion: region, ssoProtection: null }),
251
264
  }).catch(() => undefined);
252
265
  await fetchImpl("https://api.vercel.com/v13/deployments", {
253
266
  method: "POST",