cartethyia 2.7.9 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +3 -2
  2. package/dist/dashboard/assets/{BannedPage-BxoG1MrL.js → BannedPage-Cc0vKpMG.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-ixwgKk53.js → CliToolDetailPage-Bqj2aa1T.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-BmttNV3U.js → CliToolsPage-CXsaOAO2.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BE3qwwfC.js → CombosPage-BeLpJ4ma.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CRZdmMIX.js → ConsoleLogPage-DnV0uZ3m.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-Dv_FJ6Er.js → CustomizationPage-CBxAjo63.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  16. package/dist/dashboard/assets/{InjectSystemPromptPanel-BOoO8rpz.js → InjectSystemPromptPanel-BWFLECmz.js} +2 -2
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-ChJuNNVN.js → LoginPage-CgoPc5B9.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  20. package/dist/dashboard/assets/{ModelPicker-BZAJtPg1.js → ModelPicker-D9bFQern.js} +1 -1
  21. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-BE40s3u5.js → OverviewPage-B_p4tL8k.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +8 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProviderIcon-CQ6bPUf_.js → ProviderIcon-D_0PiZtg.js} +1 -1
  27. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  30. package/dist/dashboard/assets/{ProxyPage-BJ87Pp0U.js → ProxyPage-BwNC_-Cx.js} +1 -1
  31. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-C5HPWqEv.js → SettingsPage-CctTga2C.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  36. package/dist/dashboard/assets/{SetupPage-CFNcATJJ.js → SetupPage-febLU4r1.js} +1 -1
  37. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-YoD4tY5l.js → StudioPage-M6fvR5Wz.js} +1 -1
  39. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  40. package/dist/dashboard/assets/{UsagePage-DxjaAhb_.js → UsagePage-nyeuRqz9.js} +1 -1
  41. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BTlkq_Uk.js → api-keys-BFOlvrJC.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  44. package/dist/dashboard/assets/app-Dx2QgsNj.css +2 -0
  45. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-CqTLw5e7.js +1 -0
  47. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DdbT_7Am.js → badge-C83EqLB0.js} +1 -1
  49. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-CsGN-nbm.js → banner-D1xn7pIe.js} +1 -1
  51. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-mgZ_TLLZ.js → card-DU3Lq5Lm.js} +1 -1
  53. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-A1-v8-03.js → cli-tools-Dw1U_drK.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-CJ4Zty89.js → clipboard-button-CEL5Sg_J.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-B5L2jPQ_.js → dialog-BjFR5UHy.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-DkjUouAj.js → drawer-HvBW0AjI.js} +1 -1
  61. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BSu3gFlt.js +4 -0
  63. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-B-LVLV6o.js → entry-CECVELyy.js} +1 -1
  65. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-CAhdYl3B.js → entry-DYYofQ7W.js} +1 -1
  67. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  68. package/dist/dashboard/assets/{formatters-CrsdLVsn.js → formatters-DLW4i8zK.js} +1 -1
  69. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-BU64Lqvp.js → index-1-fCqe64.js} +2 -2
  71. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-B2aDLyyg.js → inline-DGvU2oLK.js} +1 -1
  73. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BCVkdR9d.js → input-iNnO1X2K.js} +1 -1
  75. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-DNcAdMK-.js → layout-0q8UGypb.js} +1 -1
  77. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-DgrIH0hw.js → list-row-C0w2bQjJ.js} +1 -1
  79. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-Cae01iu8.js → live-M5aJ_xCa.js} +1 -1
  81. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  82. package/dist/dashboard/assets/{network-CRib7H_2.js → network-BKeQGT3K.js} +1 -1
  83. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-BKQE8Xpz.js → numbers-CivAk3da.js} +1 -1
  85. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  86. package/dist/dashboard/assets/{pagination-ecPvG_8g.js → pagination-CpzvI5ep.js} +1 -1
  87. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  88. package/dist/dashboard/assets/providers/zro.svg +3 -0
  89. package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
  90. package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
  91. package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-Ckm1UXuC.js → routing-BFQX2VBc.js} +1 -1
  93. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-ChoFWyKz.js → select-CXbck0jG.js} +2 -2
  95. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  96. package/dist/dashboard/assets/{settings-CKjFPlKW.js → settings-C_XvMHh9.js} +1 -1
  97. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  98. package/dist/dashboard/assets/{stack-DWCwU3fh.js → stack-DggRiCxE.js} +1 -1
  99. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  100. package/dist/dashboard/assets/{state-Cg3VL5J_.js → state-ClFzfbYp.js} +1 -1
  101. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  102. package/dist/dashboard/assets/{switch-Db70BMix.js → switch-CTIFxKqS.js} +1 -1
  103. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  104. package/dist/dashboard/assets/{useMutation-BqdE0LuM.js → useMutation-BClG3yWj.js} +1 -1
  105. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/dashboard/index.html.gz +0 -0
  108. package/dist/dashboard/precompressed-assets.json +54 -53
  109. package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
  110. package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
  111. package/package.json +1 -1
  112. package/src/boot/app.ts +10 -13
  113. package/src/boot/dependencies.ts +1 -1
  114. package/src/boot/flush.ts +20 -0
  115. package/src/boot/lifecycle.ts +7 -5
  116. package/src/config/routing.ts +26 -0
  117. package/src/config/spec-metadata.ts +72 -0
  118. package/src/config/spec.ts +20 -0
  119. package/src/console/api-keys/contracts.ts +12 -1
  120. package/src/console/api-keys/routes.ts +40 -28
  121. package/src/console/auth/account-reset.ts +7 -9
  122. package/src/console/backup/contracts.ts +4 -0
  123. package/src/console/backup/export-endpoint.ts +7 -5
  124. package/src/console/cli-tools/service.ts +6 -12
  125. package/src/console/console-router-guards.ts +23 -16
  126. package/src/console/domain-registration.ts +0 -2
  127. package/src/console/inferhub/normalize.ts +24 -28
  128. package/src/console/inferhub/types.ts +3 -0
  129. package/src/console/ip-bans/routes.ts +7 -13
  130. package/src/console/observability/store.ts +0 -53
  131. package/src/console/providers/catalog/contracts.ts +2 -2
  132. package/src/console/providers/catalog/discover-constants.ts +6 -0
  133. package/src/console/providers/catalog/routes.ts +1 -0
  134. package/src/console/providers/catalog/store.ts +21 -16
  135. package/src/console/quota/account-quota-global.ts +14 -24
  136. package/src/console/quota/account-quota-shared.ts +49 -0
  137. package/src/console/quota/account-quota-tenant.ts +6 -18
  138. package/src/console/quota/quota-view.ts +19 -3
  139. package/src/console/routing/pools/operations.ts +31 -46
  140. package/src/console/shared/errors.ts +33 -6
  141. package/src/main.ts +2 -3
  142. package/src/network/pool/byte-accounting.ts +3 -2
  143. package/src/network/pool/selector.ts +182 -39
  144. package/src/network/pool-cooldown-store.ts +89 -14
  145. package/src/network/pool-health.ts +49 -61
  146. package/src/network/rate-limit-verdict.ts +1 -1
  147. package/src/network/ssrf.ts +6 -6
  148. package/src/observability/event-log.ts +7 -361
  149. package/src/observability/performance-metrics.ts +9 -0
  150. package/src/observability/runtime-metrics.ts +14 -9
  151. package/src/observability/telemetry-buffer.ts +15 -25
  152. package/src/observability/telemetry-reconciliation.ts +9 -37
  153. package/src/observability/telemetry-row.ts +125 -0
  154. package/src/persistence/db-mode.ts +4 -1
  155. package/src/persistence/dialect.ts +18 -0
  156. package/src/persistence/migrate.ts +6 -3
  157. package/src/persistence/schema.ts +8 -0
  158. package/src/persistence/ttl-cache.ts +20 -2
  159. package/src/protocol/codecs.ts +1 -1
  160. package/src/protocol/dsml.ts +34 -86
  161. package/src/protocol/markup-stream-parser.ts +195 -0
  162. package/src/protocol/reasoning-text.ts +19 -0
  163. package/src/protocol/request/chat.ts +10 -10
  164. package/src/protocol/request/gemini.ts +8 -2
  165. package/src/protocol/request/messages.ts +3 -4
  166. package/src/protocol/response/chat.ts +12 -20
  167. package/src/protocol/response/omp-tool-call-markup.ts +43 -86
  168. package/src/protocol/response/responses.ts +18 -2
  169. package/src/protocol/stop-reason.ts +23 -1
  170. package/src/protocol/think-split.ts +112 -0
  171. package/src/protocol/translation/dialect/qwen.ts +26 -58
  172. package/src/providers/adapters/compatible-adapter.ts +71 -12
  173. package/src/providers/authentication/oauth-client.ts +1 -1
  174. package/src/providers/authentication/oauth-device-flow.ts +98 -8
  175. package/src/providers/authentication/oauth-refresh-service.ts +30 -49
  176. package/src/providers/configured-provider.ts +148 -0
  177. package/src/providers/default-registry.ts +61 -36
  178. package/src/providers/discovery/probe-phases.ts +14 -4
  179. package/src/providers/discovery/probing-service.ts +15 -2
  180. package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
  181. package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
  182. package/src/providers/integrations/antigravity/antigravity-protocol.ts +5 -1
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +173 -17
  184. package/src/providers/integrations/buddy/buddy-identity.ts +19 -6
  185. package/src/providers/integrations/buddy/capabilities.ts +2 -2
  186. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +3 -0
  187. package/src/providers/integrations/buddy/codebuddy/provider.ts +4 -0
  188. package/src/providers/integrations/buddy/codebuddy/shared.ts +37 -22
  189. package/src/providers/integrations/claude/claude-quota.ts +14 -17
  190. package/src/providers/integrations/cline/cline.ts +2 -2
  191. package/src/providers/integrations/commandcode/quota.ts +22 -28
  192. package/src/providers/integrations/cursor/cursor-auth.ts +0 -8
  193. package/src/providers/integrations/cursor/cursor-exec.ts +9 -2
  194. package/src/providers/integrations/cursor/cursor-request.ts +26 -12
  195. package/src/providers/integrations/cursor/cursor-shared.ts +7 -4
  196. package/src/providers/integrations/cursor/cursor.ts +89 -40
  197. package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
  198. package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
  199. package/src/providers/integrations/devin/protocol/devin-proto.ts +1 -27
  200. package/src/providers/integrations/factory/factory-oauth.ts +17 -44
  201. package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
  202. package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
  203. package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
  204. package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
  205. package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
  206. package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
  207. package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
  208. package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
  209. package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
  210. package/src/providers/integrations/freebuff/freebuff.ts +682 -0
  211. package/src/providers/integrations/grok/grok-oauth.ts +48 -45
  212. package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
  213. package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
  214. package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +9 -9
  215. package/src/providers/integrations/hermes/index.ts +2 -0
  216. package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
  217. package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
  218. package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
  219. package/src/providers/integrations/kiro/kiro.ts +15 -19
  220. package/src/providers/integrations/ollamacloud/index.ts +2 -0
  221. package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
  222. package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
  223. package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
  224. package/src/providers/integrations/perch/perch-credential.ts +25 -0
  225. package/src/providers/integrations/perch/perch-protocol.ts +0 -3
  226. package/src/providers/integrations/perch/perch-quota.ts +2 -16
  227. package/src/providers/integrations/perch/perch.ts +3 -13
  228. package/src/providers/integrations/qoder/claim-qoder-credits.ts +18 -59
  229. package/src/providers/integrations/qoder/qoder-adapter.ts +1 -12
  230. package/src/providers/integrations/qoder/qoder-auth.ts +101 -29
  231. package/src/providers/integrations/qoder/qoder-catalog.ts +20 -5
  232. package/src/providers/integrations/qoder/qoder-chat.ts +7 -21
  233. package/src/providers/integrations/qoder/qoder-checkin.ts +4 -4
  234. package/src/providers/integrations/qoder/qoder-grpc.ts +0 -1
  235. package/src/providers/integrations/qoder/qoder-ide.ts +0 -107
  236. package/src/providers/integrations/qoder/qoder.ts +0 -1
  237. package/src/providers/integrations/trae/trae-protocol.ts +9 -5
  238. package/src/providers/integrations/trae/trae-quota.ts +6 -28
  239. package/src/providers/integrations/trae/trae-realm.ts +38 -0
  240. package/src/providers/integrations/trae/trae.ts +7 -24
  241. package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
  242. package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
  243. package/src/providers/integrations/zro/zro.ts +204 -0
  244. package/src/providers/operations/account-error-classification.ts +199 -307
  245. package/src/providers/operations/account-health-service.ts +82 -1
  246. package/src/providers/operations/account-refresh-status.ts +22 -37
  247. package/src/providers/operations/account-tier.ts +15 -16
  248. package/src/providers/operations/client-version-resolver.ts +1 -1
  249. package/src/providers/operations/client-versions.ts +5 -51
  250. package/src/providers/operations/cooldown-rules.ts +11 -16
  251. package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
  252. package/src/providers/operations/provider-catalog-service.ts +1 -1
  253. package/src/providers/operations/provider-credential-service.ts +102 -32
  254. package/src/providers/operations/provider-rules/github.ts +48 -0
  255. package/src/providers/operations/provider-rules/grok.ts +33 -0
  256. package/src/providers/operations/provider-rules/inception.ts +49 -0
  257. package/src/providers/operations/provider-rules/index.ts +32 -0
  258. package/src/providers/operations/provider-rules/perch.ts +37 -0
  259. package/src/providers/operations/provider-rules/qoder.ts +70 -0
  260. package/src/providers/operations/provider-rules/trae.ts +81 -0
  261. package/src/providers/provider-contracts.ts +4 -2
  262. package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
  263. package/src/providers/provider-metadata.ts +40 -10
  264. package/src/providers/provider-probe.ts +15 -1
  265. package/src/providers/provider-registry.ts +42 -33
  266. package/src/providers/quota/quota-limit-windows.ts +16 -5
  267. package/src/providers/quota/quota-support.ts +1 -1
  268. package/src/providers/quota/quota-window-parser.ts +90 -18
  269. package/src/providers/quota/refresh.ts +75 -18
  270. package/src/security/api-key-auth-cache.ts +1 -1
  271. package/src/security/api-key-auth.ts +6 -2
  272. package/src/transport/dispatch/attempt-finalize.ts +14 -13
  273. package/src/transport/dispatch/attempt-loop.ts +71 -17
  274. package/src/transport/dispatch/generation.ts +2 -8
  275. package/src/transport/dispatch/native-shared.ts +25 -0
  276. package/src/transport/dispatch/proxy-request.ts +8 -46
  277. package/src/transport/dispatch/responses-compact.ts +2 -8
  278. package/src/transport/dispatch/streaming/finalize.ts +9 -6
  279. package/src/transport/dispatch/streaming/stream.ts +8 -36
  280. package/src/transport/dispatch/systemone.ts +2 -8
  281. package/src/transport/dispatch/token-count.ts +2 -6
  282. package/src/transport/dispatch/upstream.ts +63 -0
  283. package/src/transport/drain-endpoint.ts +31 -4
  284. package/src/transport/failure-policy.ts +16 -30
  285. package/src/transport/guards/body-policy.ts +7 -1
  286. package/src/transport/guards/error-lifecycle.ts +13 -1
  287. package/src/transport/guards/request-context.ts +22 -14
  288. package/src/transport/request/preparer.ts +81 -71
  289. package/src/transport/routing/bounded-lru.ts +14 -0
  290. package/src/transport/routing/drain-reserve.ts +129 -0
  291. package/src/transport/routing/plan-gate.ts +7 -1
  292. package/src/transport/routing/round-robin.ts +60 -0
  293. package/src/transport/routing/route-catalog.ts +123 -111
  294. package/src/transport/routing/route-model.ts +51 -9
  295. package/src/transport/routing/router.ts +393 -32
  296. package/src/transport/surface/adapters.ts +11 -2
  297. package/src/transport/surface/chat/adapter.ts +2 -2
  298. package/src/transport/surface/chat/encode.ts +47 -4
  299. package/src/transport/surface/chat/parse.ts +2 -12
  300. package/src/transport/surface/completion.ts +44 -33
  301. package/src/transport/surface/content-parts.ts +21 -0
  302. package/src/transport/surface/messages/adapter.ts +7 -1
  303. package/src/transport/surface/messages/encode.ts +11 -3
  304. package/src/transport/surface/messages/parse.ts +2 -1
  305. package/src/transport/surface/messages/stream.ts +33 -17
  306. package/src/transport/surface/responses/encode.ts +22 -5
  307. package/src/transport/surface/responses/parse.ts +3 -11
  308. package/dist/dashboard/assets/BannedPage-BxoG1MrL.js.gz +0 -0
  309. package/dist/dashboard/assets/CliToolDetailPage-ixwgKk53.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolsPage-BmttNV3U.js.gz +0 -0
  311. package/dist/dashboard/assets/CombosPage-BE3qwwfC.js.gz +0 -0
  312. package/dist/dashboard/assets/ConsoleLogPage-CRZdmMIX.js.gz +0 -0
  313. package/dist/dashboard/assets/CustomizationPage-Dv_FJ6Er.js.gz +0 -0
  314. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js +0 -1
  315. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js.gz +0 -0
  316. package/dist/dashboard/assets/InjectSystemPromptPanel-BOoO8rpz.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-ChJuNNVN.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-BZAJtPg1.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-BE40s3u5.js.gz +0 -0
  320. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js +0 -8
  321. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderIcon-CQ6bPUf_.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js +0 -1
  324. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-C5HPWqEv.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-CFNcATJJ.js.gz +0 -0
  330. package/dist/dashboard/assets/StudioPage-YoD4tY5l.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-DxjaAhb_.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-BTlkq_Uk.js.gz +0 -0
  333. package/dist/dashboard/assets/app-BX2nnKqs.css +0 -2
  334. package/dist/dashboard/assets/app-BX2nnKqs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-CndthXFQ.js +0 -1
  336. package/dist/dashboard/assets/assert-CndthXFQ.js.gz +0 -0
  337. package/dist/dashboard/assets/badge-DdbT_7Am.js.gz +0 -0
  338. package/dist/dashboard/assets/banner-CsGN-nbm.js.gz +0 -0
  339. package/dist/dashboard/assets/card-mgZ_TLLZ.js.gz +0 -0
  340. package/dist/dashboard/assets/cli-tools-A1-v8-03.js.gz +0 -0
  341. package/dist/dashboard/assets/clipboard-button-CJ4Zty89.js.gz +0 -0
  342. package/dist/dashboard/assets/dialog-B5L2jPQ_.js.gz +0 -0
  343. package/dist/dashboard/assets/drawer-DkjUouAj.js.gz +0 -0
  344. package/dist/dashboard/assets/entry-B-LVLV6o.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-CAhdYl3B.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-D2xD0Tmd.js +0 -4
  347. package/dist/dashboard/assets/entry-D2xD0Tmd.js.gz +0 -0
  348. package/dist/dashboard/assets/formatters-CrsdLVsn.js.gz +0 -0
  349. package/dist/dashboard/assets/index-BU64Lqvp.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-B2aDLyyg.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BCVkdR9d.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-DNcAdMK-.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-DgrIH0hw.js.gz +0 -0
  354. package/dist/dashboard/assets/live-Cae01iu8.js.gz +0 -0
  355. package/dist/dashboard/assets/network-CRib7H_2.js.gz +0 -0
  356. package/dist/dashboard/assets/numbers-BKQE8Xpz.js.gz +0 -0
  357. package/dist/dashboard/assets/pagination-ecPvG_8g.js.gz +0 -0
  358. package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
  359. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  360. package/dist/dashboard/assets/routing-Ckm1UXuC.js.gz +0 -0
  361. package/dist/dashboard/assets/select-ChoFWyKz.js.gz +0 -0
  362. package/dist/dashboard/assets/settings-CKjFPlKW.js.gz +0 -0
  363. package/dist/dashboard/assets/stack-DWCwU3fh.js.gz +0 -0
  364. package/dist/dashboard/assets/state-Cg3VL5J_.js.gz +0 -0
  365. package/dist/dashboard/assets/switch-Db70BMix.js.gz +0 -0
  366. package/dist/dashboard/assets/useMutation-BqdE0LuM.js.gz +0 -0
  367. package/src/providers/bundled-discovery-providers.ts +0 -98
  368. package/src/providers/configured-openai-providers.ts +0 -120
  369. package/src/providers/operations/model-discovery-cache.ts +0 -34
  370. package/src/providers/operations/probe-fetch.ts +0 -15
  371. package/src/providers/operations/provider-version-cache.ts +0 -30
  372. package/src/providers/provider-capabilities.ts +0 -33
  373. package/src/providers/provider-credential-envelope.ts +0 -43
  374. /package/dist/dashboard/assets/{app-B7kIWFm5.js → app-DIXLjojg.js} +0 -0
  375. /package/dist/dashboard/assets/{app-B7kIWFm5.js.gz → app-DIXLjojg.js.gz} +0 -0
@@ -622,19 +622,20 @@ export function nativeCompletionBase(input: {
622
622
  | "tokensPerSec"
623
623
  > {
624
624
  return {
625
- providerId: input.providerId,
626
- modelId: input.modelId,
627
- tenantId: input.tenantId,
628
- ingressBody: input.ingressBody,
629
- terminal: true,
630
- ...(input.accountId === undefined ? {} : { accountId: input.accountId }),
631
- ...(input.accountLabel === undefined ? {} : { accountLabel: input.accountLabel }),
632
- ...(input.networkPoolId === undefined ? {} : { networkPoolId: input.networkPoolId }),
633
- ...(input.lease === undefined ? {} : { lease: input.lease }),
634
- db: input.db,
635
- ...(input.telemetryBuffer === undefined ? {} : { telemetryBuffer: input.telemetryBuffer }),
636
- ...(input.snapshotService === undefined ? {} : { snapshotService: input.snapshotService }),
637
- ...(input.providerCapture === undefined ? {} : { providerCapture: input.providerCapture }),
625
+ ...completionContext({
626
+ providerId: input.providerId,
627
+ modelId: input.modelId,
628
+ tenantId: input.tenantId,
629
+ ingressBody: input.ingressBody,
630
+ ...(input.providerCapture === undefined ? {} : { providerCapture: input.providerCapture }),
631
+ db: input.db,
632
+ ...(input.accountId === undefined ? {} : { accountId: input.accountId }),
633
+ ...(input.accountLabel === undefined ? {} : { accountLabel: input.accountLabel }),
634
+ ...(input.networkPoolId === undefined ? {} : { networkPoolId: input.networkPoolId }),
635
+ ...(input.lease === undefined ? {} : { lease: input.lease }),
636
+ ...(input.telemetryBuffer === undefined ? {} : { telemetryBuffer: input.telemetryBuffer }),
637
+ ...(input.snapshotService === undefined ? {} : { snapshotService: input.snapshotService }),
638
+ }),
638
639
  ...(input.usage === undefined ? {} : { usage: input.usage }),
639
640
  ...(input.commitUsage === undefined ? {} : { commitUsage: input.commitUsage }),
640
641
  };
@@ -32,7 +32,7 @@ import { metrics } from "../../observability/metrics";
32
32
  import type { NetworkPoolSelector } from "../../network/pool/selector";
33
33
  import type { ProxyRequestState } from "../request/state";
34
34
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
35
- import { flagPoolBlocked, flagPoolCooldown } from "../../network/pool-health";
35
+ import { flagPoolOnEgressFailure } from "../../network/pool-health";
36
36
  import {
37
37
  completeAttempt,
38
38
  errorClientResponseBody,
@@ -61,6 +61,41 @@ function comboCandidateKey(candidate: RouteCandidate): string {
61
61
  ].join(":");
62
62
  }
63
63
 
64
+ /**
65
+ * Extra dials allowed for a mid-response upstream socket drop when the
66
+ * candidate list is exhausted (single-account providers). One extra dial
67
+ * recovers the transient reset; more would stack latency on a dying origin.
68
+ */
69
+ const MAX_SOCKET_DROP_RETRIES = 1;
70
+
71
+ /**
72
+ * Normalizes a raw (non-GatewayError) failure before it escapes the loop.
73
+ *
74
+ * A transport-level throw — e.g. Bun's `TypeError` with
75
+ * `code: "ECONNRESET"` when the upstream socket dies mid-response — carries a
76
+ * `.code` of its own. The error middleware reads that `.code` for Elysia
77
+ * built-in errors, so a raw socket drop leaked to the client as
78
+ * `{"code":"ECONNRESET","status":400}` with origin `cartethyia`: the wrong
79
+ * code, the wrong status, and the wrong blame. The classifier already knows
80
+ * what this is (`transport_unavailable`, 502, upstream) — materialize it as a
81
+ * real GatewayError so the client and the captured Client Response see the
82
+ * same stable envelope every other upstream transport failure gets.
83
+ */
84
+ function normalizeEscapeError(error: unknown, signal: AbortSignal): unknown {
85
+ if (error instanceof GatewayError) return error;
86
+ const terminal = classifyTerminalOutcome(error, signal);
87
+ if (terminal.errorCategory === "transport_unavailable") {
88
+ return new GatewayError(
89
+ "transport_unavailable",
90
+ 502,
91
+ "The upstream closed the connection mid-response (request {requestId}). Retry; if it keeps happening, the account or pool is unstable. The connection ghosted us — come back in a moment 👻",
92
+ {},
93
+ "upstream",
94
+ );
95
+ }
96
+ return error;
97
+ }
98
+
64
99
  /**
65
100
  * True when the rejection is about the API key or the gateway's own policy,
66
101
  * not about the provider account that was dialled.
@@ -321,7 +356,7 @@ export async function runAttemptLoop<TResult, TAdapter>(
321
356
  status: terminal.status === "cancelled" ? "cancelled" : "failed",
322
357
  });
323
358
  }
324
- throw lastError;
359
+ throw normalizeEscapeError(lastError, state.abortController.signal);
325
360
  }
326
361
  }
327
362
  let lease: AdmissionLease | undefined;
@@ -381,6 +416,13 @@ export async function runAttemptLoop<TResult, TAdapter>(
381
416
  // names the real cause.
382
417
  const drain = drainAbortReason(state.abortController.signal.reason);
383
418
  if (drain !== undefined) error = drain;
419
+ // Normalize a raw transport throw (e.g. Bun's `TypeError` with
420
+ // `code: "ECONNRESET"` when the upstream socket dies mid-response) into
421
+ // the stable `transport_unavailable`/502/upstream GatewayError **before**
422
+ // classification, so the telemetry row, the captured Client Response, and
423
+ // the escaped error all agree — and the raw `.code` never leaks to the
424
+ // client as a fake Elysia built-in code (see `normalizeEscapeError`).
425
+ error = normalizeEscapeError(error, state.abortController.signal);
384
426
  lastError = error;
385
427
  // A drain is a *server* close, not a client cancel: it must be recorded
386
428
  // as a failure with the shutdown code, and it is never retryable.
@@ -411,21 +453,33 @@ export async function runAttemptLoop<TResult, TAdapter>(
411
453
  // the pool excluded. Both flag helpers write the local entry
412
454
  // synchronously, so the ordering holds even though the Redis mirror is
413
455
  // fire-and-forget.
414
- if (networkPoolId && deps.poolSelector && error instanceof GatewayError) {
415
- if (rateLimitedPool) {
416
- flagPoolCooldown(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
417
- } else if (blockedPool) {
418
- // The address itself is refused, not the request: retire the egress
419
- // so the next attempt leaves by a different one instead of retrying
420
- // a proxy the upstream has already flagged.
421
- flagPoolBlocked(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
422
- }
423
- }
456
+ flagPoolOnEgressFailure(
457
+ deps.poolSelector,
458
+ deps.db,
459
+ networkPoolId,
460
+ candidate.provider_id,
461
+ error,
462
+ egressVerdict,
463
+ );
464
+ // A mid-response upstream socket drop (`transport_unavailable`) is the one
465
+ // retryable failure that receives an extra dial even when the candidate
466
+ // list is exhausted: an origin that closes the connection before the
467
+ // response is often transient (Trae's edge resets under concurrency),
468
+ // and the fresh dial recovers it — verified live 2026-11-09, 7/7
469
+ // recovered on the first retry. Without this, a single-account provider
470
+ // (the common case) tripped `index === candidates.length - 1` on the very
471
+ // first attempt and the client saw the raw socket error. The retry is
472
+ // bounded to one extra dial per request, never unbounded.
473
+ const memberAttempts = attemptsByMember.get(memberKey) ?? 0;
474
+ const socketDropRetryLeft =
475
+ terminal.errorCategory === "transport_unavailable" &&
476
+ !cancelled &&
477
+ memberAttempts < MAX_SOCKET_DROP_RETRIES;
424
478
  const terminalAttempt =
425
479
  cancelled ||
426
480
  isClientScopedRejection(error) ||
427
481
  !isRetryableFailure(error) ||
428
- (index === candidates.length - 1 && !poolScopedFailure);
482
+ ((index === candidates.length - 1 && !poolScopedFailure) && !socketDropRetryLeft);
429
483
  // Track only the most recent fired exchange; the array this replaced
430
484
  // accumulated every attempt's capture until the loop returned.
431
485
  if (providerCapture.request !== null) lastFiredCapture = providerCapture;
@@ -474,7 +528,7 @@ export async function runAttemptLoop<TResult, TAdapter>(
474
528
  // A 403 can be a deterministic provider policy rejection, not an
475
529
  // invalid OAuth credential. Only the evidence-based classifier above
476
530
  // may trigger a refresh.
477
- const candidateKey = `${candidate.provider_id}:${candidate.provider_account_id ?? "global"}:${candidate.model_id}:${candidate.endpoint}`;
531
+ const candidateKey = comboCandidateKey(candidate);
478
532
  const authInvalidated = isOAuthCredentialInvalidated(error);
479
533
  if (
480
534
  !cancelled &&
@@ -499,7 +553,7 @@ export async function runAttemptLoop<TResult, TAdapter>(
499
553
  }
500
554
  if (terminalAttempt) {
501
555
  metrics.proxy_requests_total.inc(1, { status: cancelled ? "cancelled" : "failed" });
502
- throw error;
556
+ throw normalizeEscapeError(error, state.abortController.signal);
503
557
  }
504
558
  // A failed combo member stays flagged for this request. Move it behind
505
559
  // every candidate that has not failed yet, so the next attempt cannot
@@ -542,7 +596,7 @@ export async function runAttemptLoop<TResult, TAdapter>(
542
596
  // as the sleep's own error would leave `proxy_requests_total` missing
543
597
  // the request.
544
598
  metrics.proxy_requests_total.inc(1, { status: "cancelled" });
545
- throw lastError;
599
+ throw normalizeEscapeError(lastError, state.abortController.signal);
546
600
  }
547
601
  }
548
602
  }
@@ -577,5 +631,5 @@ export async function runAttemptLoop<TResult, TAdapter>(
577
631
  }));
578
632
  }
579
633
  metrics.proxy_requests_total.inc(1, { status: "failed" });
580
- throw lastError;
634
+ throw normalizeEscapeError(lastError, state.abortController.signal);
581
635
  }
@@ -27,6 +27,7 @@ import { completeAttempt, estimatedUsage, nativeCompletionBase } from "./attempt
27
27
  import { runAttemptLoop } from "./attempt-loop";
28
28
  import { captureProviderRateLimit } from "./ratelimit-capture";
29
29
  import {
30
+ forwardUpstreamResponse,
30
31
  hasScope,
31
32
  jsonResponse,
32
33
  networkFetchContext,
@@ -133,14 +134,7 @@ async function passThrough(
133
134
  responseBody: null,
134
135
  });
135
136
  metrics.proxy_requests_total.inc(1, { status: "completed" });
136
- return new Response(upstream.body, {
137
- status: upstream.status,
138
- headers: {
139
- "content-type": upstream.headers.get("content-type") ?? "text/event-stream",
140
- "cache-control": "no-store",
141
- "x-request-id": state.requestId,
142
- },
143
- });
137
+ return forwardUpstreamResponse(upstream, state.requestId, "text/event-stream");
144
138
  }
145
139
 
146
140
  /** Builds the handler for one generation surface. */
@@ -71,6 +71,31 @@ export function jsonResponse(payload: unknown, status: number, requestId: string
71
71
  });
72
72
  }
73
73
 
74
+ /**
75
+ * Forwards an upstream response body verbatim with the shared native-response
76
+ * header set.
77
+ *
78
+ * The native handlers (System One, responses-compact, token-count,
79
+ * generation's passThrough) each spelled the same three headers by hand; the
80
+ * copies drifted at least once (generation's JSON builder lost `cache-control`).
81
+ * Status and body come from the upstream untouched; only the content-type
82
+ * default differs per route (SSE vs JSON) and is passed per call.
83
+ */
84
+ export function forwardUpstreamResponse(
85
+ response: Response,
86
+ requestId: string,
87
+ defaultContentType = "application/json",
88
+ ): Response {
89
+ return new Response(response.body, {
90
+ status: response.status,
91
+ headers: {
92
+ "content-type": response.headers.get("content-type") ?? defaultContentType,
93
+ "cache-control": "no-store",
94
+ "x-request-id": requestId,
95
+ },
96
+ });
97
+ }
98
+
74
99
  /**
75
100
  * Strips a thinking suffix (`model(high)`) from the model name and rebuilds
76
101
  * the body with the bare model when it changed. Returns the bare model and
@@ -11,7 +11,7 @@ import { chatAdapter } from "../surface/chat/adapter";
11
11
  import { responsesAdapter } from "../surface/responses/adapter";
12
12
  import { messagesAdapter } from "../surface/messages/adapter";
13
13
  import { completionAdapter } from "../surface/completion";
14
- import { forwardedRequestHeaders, proxySuccessHeaders, buildUpstreamDispatchContext, reportedModelFrom } from "./upstream";
14
+ import { forwardedRequestHeaders, proxySuccessHeaders, buildUpstreamDispatchContext, buildDispatchNetworkBindings, reportedModelFrom } from "./upstream";
15
15
  import { resolvePromptCacheKey } from "../../providers/operations/session-resolution";
16
16
  import {
17
17
  applyTenantPreferences,
@@ -21,18 +21,12 @@ import {
21
21
  import { metrics } from "../../observability/metrics";
22
22
  import { repriceUsage } from "../../providers/provider-usage";
23
23
  import {
24
- captureProviderExchange,
25
24
  completeAttempt,
26
25
  completionContext,
27
26
  estimatedUsage,
28
27
  parseCapturedBody,
29
28
  terminalFailure,
30
29
  } from "./attempt-finalize";
31
- import {
32
- providerCaptureMaxBytes,
33
- resolveCaptureDepthForRequest,
34
- shouldCaptureProviderBodyForRequest,
35
- } from "./capture-policy";
36
30
  import { runAttemptLoop } from "./attempt-loop";
37
31
  import { projectForRoute, routeCapabilitiesFor } from "../../protocol/translation/capabilities";
38
32
  import { dispatchStreamingAttempt } from "./streaming/stream";
@@ -264,45 +258,13 @@ export async function handleProviderProxyRequest(
264
258
  ...(tenantSystemPromptText === undefined ? {} : { tenantSystemPrompt: tenantSystemPromptText }),
265
259
  ...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
266
260
  ...(providerCapture ? { providerCapture } : {}),
267
- ...(deps.networkBindingFactory
268
- ? {
269
- outboundFetch: captureProviderExchange(
270
- deps.networkBindingFactory.fetch(
271
- networkPoolId,
272
- prepared.authorization.snapshot.tenant_id,
273
- ),
274
- providerCapture,
275
- await shouldCaptureProviderBodyForRequest(
276
- deps.db,
277
- prepared.authorization.snapshot.tenant_id,
278
- state,
279
- ),
280
- providerCaptureMaxBytes(
281
- await resolveCaptureDepthForRequest(
282
- deps.db,
283
- prepared.authorization.snapshot.tenant_id,
284
- state,
285
- ),
286
- ),
287
- ),
288
- }
289
- : {}),
290
- ...(deps.networkBindingFactory
291
- ? {
292
- outboundAttachmentFetch: deps.networkBindingFactory.fetch(
293
- networkPoolId,
294
- prepared.authorization.snapshot.tenant_id,
295
- ),
296
- }
297
- : {}),
298
- ...(deps.networkBindingFactory
299
- ? {
300
- outboundHttp2: deps.networkBindingFactory.http2Bidi(
301
- networkPoolId,
302
- prepared.authorization.snapshot.tenant_id,
303
- ),
304
- }
305
- : {}),
261
+ ...(await buildDispatchNetworkBindings({
262
+ deps,
263
+ networkPoolId,
264
+ tenantId: prepared.authorization.snapshot.tenant_id,
265
+ providerCapture,
266
+ state,
267
+ })),
306
268
  ...(leaseOwnership === undefined ? {} : { leaseOwnership }),
307
269
  ...(continuationScope === undefined ? {} : { continuationScope }),
308
270
  });
@@ -4,6 +4,7 @@ import type { CodexCompactAdapter } from "../../providers/integrations/codex/cod
4
4
  import { completeAttempt, estimatedUsage, nativeCompletionBase } from "./attempt-finalize";
5
5
  import { runAttemptLoop } from "./attempt-loop";
6
6
  import {
7
+ forwardUpstreamResponse,
7
8
  networkFetchContext,
8
9
  requireRecordBody,
9
10
  resolveAttemptCredential,
@@ -143,14 +144,7 @@ export function createResponsesCompactHandler(deps: ResponsesCompactHandlerDeps)
143
144
  responseBody: null,
144
145
  });
145
146
  metrics.proxy_requests_total.inc(1, { status: "completed" });
146
- return new Response(response.body, {
147
- status: response.status,
148
- headers: {
149
- "content-type": response.headers.get("content-type") ?? "application/json",
150
- "cache-control": "no-store",
151
- "x-request-id": state.requestId,
152
- },
153
- });
147
+ return forwardUpstreamResponse(response, state.requestId);
154
148
  },
155
149
  });
156
150
  };
@@ -23,7 +23,7 @@ import type { AdmissionLease } from "../../../security/admission/contracts";
23
23
  import type { Reservation } from "../../routing/route-model";
24
24
  import { repriceUsage } from "../../../providers/provider-usage";
25
25
  import { metrics } from "../../../observability/metrics";
26
- import { flagPoolBlocked, flagPoolCooldown } from "../../../network/pool-health";
26
+ import { flagPoolOnEgressFailure } from "../../../network/pool-health";
27
27
  import { classifyEgressFailure } from "../../../network/rate-limit-verdict";
28
28
  import { releaseAttemptLeases } from "../leases";
29
29
  import type { AttemptLeaseOwnership } from "../leases";
@@ -581,11 +581,14 @@ export class StreamFinalizer {
581
581
  this.streamSettled = true;
582
582
  if (candidate.networkPoolId && !cancelled && deps.poolSelector && err instanceof GatewayError) {
583
583
  const egressVerdict = classifyEgressFailure(err, candidate.providerId);
584
- if (egressVerdict.kind === "cooldown") {
585
- flagPoolCooldown(deps.poolSelector, deps.db, candidate.networkPoolId, candidate.providerId, err);
586
- } else if (egressVerdict.kind === "blocked") {
587
- flagPoolBlocked(deps.poolSelector, deps.db, candidate.networkPoolId, candidate.providerId, err);
588
- }
584
+ flagPoolOnEgressFailure(
585
+ deps.poolSelector,
586
+ deps.db,
587
+ candidate.networkPoolId,
588
+ candidate.providerId,
589
+ err,
590
+ egressVerdict,
591
+ );
589
592
  }
590
593
  if (!cancelled) metrics.proxy_requests_total.inc(1, { status: "failed" });
591
594
  else metrics.proxy_requests_total.inc(1, { status: "cancelled" });
@@ -43,17 +43,11 @@ import type { AdmissionLease } from "../../../security/admission/contracts";
43
43
  import type { NetworkPoolSelector } from "../../../network/pool/selector";
44
44
  import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
45
45
  import type { ProviderExchangeCapture } from "../attempt-finalize";
46
- import { captureProviderExchange } from "../attempt-finalize";
47
- import {
48
- providerCaptureMaxBytes,
49
- resolveCaptureDepthForRequest,
50
- resolveCaptureModeForRequest,
51
- shouldCaptureProviderBodyForRequest,
52
- } from "../capture-policy";
46
+ import { resolveCaptureModeForRequest } from "../capture-policy";
53
47
  import { resolveTenantImageClampMaxBytes, readTenantSystemPromptsForDispatch } from "../../request/tenant-preferences";
54
48
  import { resolveTenantSystemPrompt } from "../../request/system-prompt-injection";
55
49
  import { isWrappedInjectionProvider } from "../../../console/system-prompt/contracts";
56
- import { proxySuccessHeaders, buildUpstreamDispatchContext } from "../upstream";
50
+ import { proxySuccessHeaders, buildUpstreamDispatchContext, buildDispatchNetworkBindings } from "../upstream";
57
51
  import type { AttemptLeaseOwnership } from "../leases";
58
52
  import type { ProviderContinuationScope } from "../../../providers/provider-contracts";
59
53
  import { resolveStreamFirstChunkTimeoutMs, resolveStreamStallTimeoutMs, resolveUpstreamTimeoutMs } from "../../../config/timeouts";
@@ -170,18 +164,13 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
170
164
  continuationScope,
171
165
  } = input;
172
166
 
173
- const outboundFetch = deps.networkBindingFactory?.fetch(
167
+ const bindings = await buildDispatchNetworkBindings({
168
+ deps,
174
169
  networkPoolId,
175
- prepared.authorization.snapshot.tenant_id,
176
- );
177
- const captureBody = outboundFetch
178
- ? await shouldCaptureProviderBodyForRequest(deps.db, prepared.authorization.snapshot.tenant_id, state)
179
- : false;
180
- const captureDepth = await resolveCaptureDepthForRequest(
181
- deps.db,
182
- prepared.authorization.snapshot.tenant_id,
170
+ tenantId: prepared.authorization.snapshot.tenant_id,
171
+ providerCapture,
183
172
  state,
184
- );
173
+ });
185
174
  // Resolved once, before the pipeline exists: the pipeline's diagnostics are
186
175
  // gated on it from construction, so `none`/`metadata` tenants never pay the
187
176
  // transcript serialization or the client-response copy for any event.
@@ -190,14 +179,6 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
190
179
  prepared.authorization.snapshot.tenant_id,
191
180
  state,
192
181
  );
193
- const wrappedOutboundFetch = outboundFetch
194
- ? captureProviderExchange(
195
- outboundFetch,
196
- providerCapture,
197
- captureBody,
198
- providerCaptureMaxBytes(captureDepth),
199
- )
200
- : undefined;
201
182
  // Same memoized ceiling the non-streaming path resolves, read before the
202
183
  // dispatch context is built so a stream gets the same limit as a normal
203
184
  // request rather than silently falling back to the deployment default.
@@ -237,16 +218,7 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
237
218
  return text === undefined ? {} : { tenantSystemPrompt: text };
238
219
  })()),
239
220
  ...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
240
- ...(wrappedOutboundFetch ? { outboundFetch: wrappedOutboundFetch } : {}),
241
- ...(outboundFetch ? { outboundAttachmentFetch: outboundFetch } : {}),
242
- ...(deps.networkBindingFactory
243
- ? {
244
- outboundHttp2: deps.networkBindingFactory.http2Bidi(
245
- networkPoolId,
246
- prepared.authorization.snapshot.tenant_id,
247
- ),
248
- }
249
- : {}),
221
+ ...bindings,
250
222
  ...(leaseOwnership === undefined ? {} : { leaseOwnership }),
251
223
  ...(continuationScope === undefined ? {} : { continuationScope }),
252
224
  ...(providerCapture ? { providerCapture } : {}),
@@ -4,6 +4,7 @@ import { metrics } from "../../observability/metrics";
4
4
  import { estimatedUsage, nativeCompletionBase, completeAttempt } from "./attempt-finalize";
5
5
  import { runAttemptLoop } from "./attempt-loop";
6
6
  import {
7
+ forwardUpstreamResponse,
7
8
  networkFetchContext,
8
9
  requireRecordBody,
9
10
  resolveAttemptCredential,
@@ -164,14 +165,7 @@ export function createSystemoneHandler(deps: SystemoneHandlerDeps) {
164
165
  responseBody: null,
165
166
  });
166
167
  metrics.proxy_requests_total.inc(1, { status: "completed" });
167
- return new Response(response.body, {
168
- status: response.status,
169
- headers: {
170
- "content-type": response.headers.get("content-type") ?? "application/json",
171
- "cache-control": "no-store",
172
- "x-request-id": state.requestId,
173
- },
174
- });
168
+ return forwardUpstreamResponse(response, state.requestId);
175
169
  },
176
170
  });
177
171
  };
@@ -43,7 +43,7 @@ import type { PreparedNativeRequest } from "../request/preparer";
43
43
  import { completeAttempt, estimatedUsage, nativeCompletionBase } from "./attempt-finalize";
44
44
  import { finalizeRequestTelemetry } from "../guards/error-lifecycle";
45
45
  import { runAttemptLoop } from "./attempt-loop";
46
- import { hasScope, networkFetchContext, requireRecordBody, resolveAttemptCredential } from "./native-shared";
46
+ import { forwardUpstreamResponse, hasScope, networkFetchContext, requireRecordBody, resolveAttemptCredential } from "./native-shared";
47
47
 
48
48
  import type { GatewayHandlerDeps } from "./handler-deps";
49
49
 
@@ -189,11 +189,7 @@ export function createTokenCountHandler(deps: TokenCountHandlerDeps) {
189
189
 
190
190
  // Verbatim: the client reads the provider's own number, and the
191
191
  // provider's own error when it refuses to produce one.
192
- const contentType = upstream.headers.get("content-type") ?? "application/json";
193
- return new Response(upstream.body, {
194
- status: upstream.status,
195
- headers: { "content-type": contentType, "cache-control": "no-store", "x-request-id": state.requestId },
196
- });
192
+ return forwardUpstreamResponse(upstream, state.requestId);
197
193
  },
198
194
  });
199
195
  };
@@ -2,6 +2,15 @@ import type { CanonicalEvent } from "../../protocol/canonical-model";
2
2
  import type { AttemptLeaseOwnership, ValidatedOutboundFetch, ValidatedOutboundHttp2Bidi } from "../../providers/provider-contracts";
3
3
  import type { ProviderCaptureExchange, ProviderContinuationScope } from "../../providers/provider-contracts";
4
4
  import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
5
+ import type { ValidatedNetworkBindingFactory } from "../../network/pool/resolver";
6
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
7
+ import { captureProviderExchange, type ProviderExchangeCapture } from "./attempt-finalize";
8
+ import {
9
+ providerCaptureMaxBytes,
10
+ resolveCaptureDepthForRequest,
11
+ shouldCaptureProviderBodyForRequest,
12
+ type CapturePreferenceState,
13
+ } from "./capture-policy";
5
14
 
6
15
  const FORWARDED_REQUEST_HEADERS = new Set([
7
16
  "user-agent",
@@ -34,6 +43,60 @@ export function proxySuccessHeaders(state: { requestId: string }): Record<string
34
43
  };
35
44
  }
36
45
 
46
+ /** The three validated egress bindings shared by every dispatch attempt. */
47
+ export interface DispatchNetworkBindings {
48
+ readonly outboundFetch?: ValidatedOutboundFetch;
49
+ readonly outboundAttachmentFetch?: ValidatedOutboundFetch;
50
+ readonly outboundHttp2?: ValidatedOutboundHttp2Bidi;
51
+ }
52
+
53
+ /**
54
+ * Builds the outbound network bindings for one attempt's dispatch context,
55
+ * shared by the non-streaming proxy path and the streaming branch.
56
+ *
57
+ * Both previously assembled the same three objects by hand (wrapped fetch,
58
+ * attachment fetch, HTTP/2 bidi), which drifted in the capture wrap. The
59
+ * capture-body gating is preserved exactly: the DB-backed capture-preference
60
+ * read is skipped when no network binding exists (`outboundFetch ? ... :
61
+ * false`), so a request that can never tee a provider exchange does not pay
62
+ * for the preference read. The reads are memoized on the request state, so
63
+ * resolving them here costs no extra DB reads.
64
+ */
65
+ export async function buildDispatchNetworkBindings(input: {
66
+ readonly deps: {
67
+ readonly networkBindingFactory?: ValidatedNetworkBindingFactory | undefined;
68
+ readonly db: CartethyiaDatabase;
69
+ };
70
+ readonly networkPoolId: string | undefined;
71
+ readonly tenantId: string | null;
72
+ readonly providerCapture: ProviderExchangeCapture;
73
+ readonly state: CapturePreferenceState;
74
+ }): Promise<DispatchNetworkBindings> {
75
+ const factory = input.deps.networkBindingFactory;
76
+ if (factory === undefined) return {};
77
+ const outboundFetch = factory.fetch(input.networkPoolId, input.tenantId ?? undefined);
78
+ const captureBody = await shouldCaptureProviderBodyForRequest(
79
+ input.deps.db,
80
+ input.tenantId,
81
+ input.state,
82
+ );
83
+ const captureDepth = await resolveCaptureDepthForRequest(
84
+ input.deps.db,
85
+ input.tenantId,
86
+ input.state,
87
+ );
88
+ return {
89
+ outboundFetch: captureProviderExchange(
90
+ outboundFetch,
91
+ input.providerCapture,
92
+ captureBody,
93
+ providerCaptureMaxBytes(captureDepth),
94
+ ),
95
+ outboundAttachmentFetch: factory.fetch(input.networkPoolId, input.tenantId ?? undefined),
96
+ outboundHttp2: factory.http2Bidi(input.networkPoolId, input.tenantId ?? undefined),
97
+ };
98
+ }
99
+
37
100
  export function buildUpstreamDispatchContext(input: {
38
101
  readonly credential: unknown;
39
102
  readonly deadline: number;
@@ -50,14 +50,41 @@ export function tokenMatches(provided: string | null, expected: string): boolean
50
50
  return a.length === b.length && timingSafeEqual(a, b);
51
51
  }
52
52
 
53
+ interface LoopbackAdminGate {
54
+ /** Peer address of the request, resolved by the composition root. */
55
+ readonly peer: string | null;
56
+ /** Shared secret; the route is registered only when this is set. */
57
+ readonly token: string;
58
+ /** `policy_rejected` (403) message, preserved per surface. */
59
+ readonly policyRejectedMessage: string;
60
+ /** `authentication_failed` (401) message, preserved per surface. */
61
+ readonly authenticationFailedMessage: string;
62
+ }
63
+
64
+ /**
65
+ * The shared security gate for host-local admin operations: the request must
66
+ * come from loopback, then it must carry the drain token compared in constant
67
+ * time. The peer check is independent of the secret, so a leaked token alone
68
+ * cannot reach a remote surface. The peer refusal comes first, then the token,
69
+ * with the codes and messages preserved verbatim per caller.
70
+ */
71
+ export function requireLoopbackAdmin(request: Request, gate: LoopbackAdminGate): void {
72
+ if (!isLoopbackPeer(gate.peer))
73
+ throw new GatewayError("policy_rejected", 403, gate.policyRejectedMessage);
74
+ if (!tokenMatches(request.headers.get("x-drain-token"), gate.token))
75
+ throw new GatewayError("authentication_failed", 401, gate.authenticationFailedMessage);
76
+ }
77
+
53
78
  export function createDrainHandler(
54
79
  deps: DrainEndpointDeps,
55
80
  ): (context: { request: Request }) => Promise<Response> {
56
81
  return async ({ request }) => {
57
- if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
58
- throw new GatewayError("policy_rejected", 403, "drain is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂");
59
- if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
60
- throw new GatewayError("authentication_failed", 401, "invalid drain token (request {requestId}). Wrong key, right door — check the token 🔑");
82
+ requireLoopbackAdmin(request, {
83
+ peer: deps.resolvePeerAddress(request),
84
+ token: deps.token,
85
+ policyRejectedMessage: "drain is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂",
86
+ authenticationFailedMessage: "invalid drain token (request {requestId}). Wrong key, right door — check the token 🔑",
87
+ });
61
88
  // Acknowledge first, then drain: the response must be on the wire before the
62
89
  // shutdown path starts closing sockets, or the caller sees a dropped
63
90
  // connection instead of a 202.