cartethyia 2.7.1 → 2.7.3

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 (263) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/cli-tools/contracts.ts +10 -4
  133. package/src/console/dashboard-assets.ts +16 -1
  134. package/src/console/domain-registration.ts +2 -0
  135. package/src/console/quota/account-quota-tenant.ts +8 -1
  136. package/src/console/quota/account-quota.ts +4 -1
  137. package/src/console/quota/native-identity.ts +189 -0
  138. package/src/console/remote-support/contracts.ts +12 -6
  139. package/src/console/remote-support/service.ts +2 -2
  140. package/src/console/share/share-router.ts +39 -6
  141. package/src/console/share/share-stats.ts +64 -25
  142. package/src/console/share/share-usage.ts +24 -7
  143. package/src/console/system-prompt/contracts.ts +102 -0
  144. package/src/console/system-prompt/routes.ts +165 -0
  145. package/src/main.ts +11 -0
  146. package/src/network/outbound-fetch.ts +281 -226
  147. package/src/network/pool/agent.ts +7 -0
  148. package/src/observability/event-query.ts +118 -7
  149. package/src/persistence/db-pg.ts +42 -0
  150. package/src/persistence/schema.ts +102 -0
  151. package/src/persistence/stores/api-key-store.ts +753 -581
  152. package/src/persistence/stores/share-store.ts +65 -13
  153. package/src/persistence/stores/telemetry-store.ts +57 -1
  154. package/src/persistence/system-prompt-store.ts +104 -0
  155. package/src/protocol/reasoning.ts +65 -1
  156. package/src/protocol/request/chat.ts +1 -0
  157. package/src/providers/default-registry.ts +1 -0
  158. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  159. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  160. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  161. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  162. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  163. package/src/providers/integrations/cline/cline.ts +7 -0
  164. package/src/providers/integrations/github/github.ts +20 -1
  165. package/src/providers/integrations/hermes.ts +5 -1
  166. package/src/providers/integrations/kilo/kilo.ts +4 -0
  167. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  168. package/src/providers/integrations/openrouter/provider.ts +4 -0
  169. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  170. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  171. package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
  172. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  173. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  174. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  175. package/src/providers/integrations/qoder/qoder.ts +1375 -1641
  176. package/src/providers/operations/account-error-classification.ts +1682 -1660
  177. package/src/providers/operations/account-health-service.ts +619 -621
  178. package/src/providers/operations/client-versions.ts +37 -22
  179. package/src/providers/operations/provider-catalog-service.ts +11 -1
  180. package/src/providers/provider-contracts.ts +15 -2
  181. package/src/security/access-control.ts +19 -0
  182. package/src/security/api-key-auth.ts +14 -1
  183. package/src/security/cli-client-fingerprint.ts +9 -2
  184. package/src/transport/dispatch/empty-stream.ts +224 -224
  185. package/src/transport/dispatch/proxy-request.ts +31 -2
  186. package/src/transport/dispatch/streaming/stream.ts +16 -1
  187. package/src/transport/dispatch/upstream.ts +10 -0
  188. package/src/transport/failure-policy.ts +33 -0
  189. package/src/transport/request/preparer.ts +17 -1
  190. package/src/transport/request/system-prompt-injection.ts +89 -0
  191. package/src/transport/request/tenant-preferences.ts +43 -0
  192. package/src/transport/routing/admission.ts +13 -3
  193. package/src/transport/routing/route-catalog.ts +41 -12
  194. package/src/transport/routing/route-model.ts +53 -0
  195. package/src/transport/routing/router.ts +306 -228
  196. package/src/transport/surface/responses/encode.ts +2 -3
  197. package/src/transport/surface/responses/parse.ts +49 -7
  198. package/src/types/undici.d.ts +33 -0
  199. package/src/workers/daily-checkin.ts +2 -0
  200. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  201. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
  202. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  203. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
  204. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  205. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  206. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  207. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
  208. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  209. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
  210. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  211. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  212. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  213. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
  214. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  215. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  216. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  217. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
  218. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  219. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
  220. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  221. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  222. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  223. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
  224. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  225. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  226. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  227. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  228. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  229. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  230. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  231. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  232. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  233. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  234. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  235. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  236. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  237. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  238. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  239. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  240. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  241. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  242. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  243. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  244. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  245. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  246. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  247. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  248. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  249. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  250. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  251. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  252. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  253. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  254. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  255. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  256. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  257. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  258. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  259. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  260. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  261. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  262. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  263. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -1,224 +1,224 @@
1
- /**
2
- * Empty-stream replay + pre-start failover.
3
- *
4
- * When a provider returns HTTP 200 but the response body decodes to zero
5
- * meaningful events, the request is replayed byte-identical (same headers,
6
- * same body) up to `maxEmptyReplays` times with exponential backoff. If all
7
- * replays on one endpoint fail, the helper advances to the next endpoint in
8
- * the caller-supplied ordered list.
9
- *
10
- * The helper is transport-layer: it owns fetch, watchdog, backoff, and
11
- * endpoint rotation. The caller owns SSE decoding via the `DrainStream`
12
- * callback and supplies materialized headers + serialized body so replays
13
- * are byte-identical.
14
- *
15
- * Design: the replay count, backoff, and the ordered endpoint list are the
16
- * caller's contract; this helper owns fetch, watchdog, and rotation so a
17
- * provider that answers 200 with no events is retried on the next endpoint
18
- * rather than surfacing an empty body to the client.
19
- */
20
- import { GatewayError } from "../../protocol/gateway-error";
21
- import {
22
- fallbackRetryDelayMs,
23
- MIN_RETRY_BUDGET_MS,
24
- sleep as abortableSleep,
25
- } from "../failure-policy";
26
-
27
- // ─── Public types ──────────────────────────────────────────────────────────
28
-
29
- /** What the caller's drain observed. Counts only dispatched data events:
30
- * comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
31
- export interface StreamTally {
32
- readonly dataEvents: number;
33
- readonly meaningful: boolean;
34
- readonly thoughtOnly: boolean;
35
- }
36
-
37
- /** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
38
- export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
39
-
40
- /** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
41
- export type EmptyStreamFetch = (
42
- url: string,
43
- init: { headers: Record<string, string>; body: string; signal: AbortSignal },
44
- ) => Promise<Response>;
45
-
46
- /**
47
- * Retry-delay policy.
48
- *
49
- * Delays come from `fallbackRetryDelayMs` (capped exponential with full
50
- * jitter), whose base and cap are operator-tunable. There is deliberately no
51
- * per-caller base: the local formula this replaced was uncapped and
52
- * unjittered, so a provider returning 200-with-no-events could drive an
53
- * unbounded wait, and concurrent clients could resynchronize their retries.
54
- */
55
- export interface EmptyStreamPolicy {
56
- readonly maxEmptyReplays?: number;
57
- readonly firstEventTimeoutMs?: number;
58
- readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
59
- }
60
-
61
- export interface EmptyStreamResult {
62
- readonly response: Response;
63
- readonly tally: StreamTally;
64
- readonly endpoint: string;
65
- readonly replaysUsed: number;
66
- }
67
-
68
- // ─── Defaults ──────────────────────────────────────────────────────────────
69
-
70
- const DEFAULT_MAX_REPLAYS = 2;
71
- const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
72
-
73
-
74
- // ─── Helpers ───────────────────────────────────────────────────────────────
75
-
76
- /** Pure predicate: zero data events, or data events with nothing meaningful. */
77
- export function isEmptyStream(tally: StreamTally): boolean {
78
- return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
79
- }
80
-
81
-
82
-
83
- // ─── Main function ─────────────────────────────────────────────────────────
84
-
85
- /**
86
- * Replays the byte-identical request while responses drain empty,
87
- * failing over across ordered endpoints pre-start.
88
- *
89
- * Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
90
- * GatewayError (deadline_exceeded, 504, upstream) when a first event never
91
- * arrives within the window, and GatewayError (transport_closed, 499) on
92
- * client abort.
93
- */
94
- export async function fetchWithEmptyStreamRecovery(
95
- endpoints: readonly string[],
96
- request: { headers: Record<string, string>; body: string },
97
- deps: {
98
- fetch: EmptyStreamFetch;
99
- drain: DrainStream;
100
- signal: AbortSignal;
101
- deadlineMs: number;
102
- policy?: EmptyStreamPolicy;
103
- },
104
- ): Promise<EmptyStreamResult> {
105
- const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
106
- const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
107
- const sleep = deps.policy?.sleep ?? abortableSleep;
108
-
109
- if (endpoints.length === 0) {
110
- throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
111
- }
112
-
113
- for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
114
- const endpoint = endpoints[endpointIndex]!;
115
- const isLastEndpoint = endpointIndex === endpoints.length - 1;
116
- const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
117
-
118
- for (let replay = 0; replay <= replaysAllowed; replay++) {
119
- // Check abort before each fetch
120
- if (deps.signal.aborted) {
121
- throw new GatewayError("transport_closed", 499, "Aborted");
122
- }
123
-
124
- // Check deadline floor
125
- const now = Date.now();
126
- const remaining = deps.deadlineMs - now;
127
- if (remaining < MIN_RETRY_BUDGET_MS) {
128
- throw new GatewayError(
129
- "platform_unavailable",
130
- 502,
131
- "Deadline exhausted before empty-stream replay",
132
- {},
133
- "upstream",
134
- );
135
- }
136
-
137
- const fetchTimeout = Math.min(firstEventTimeout, remaining);
138
-
139
- let response: Response;
140
- try {
141
- response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
142
- } catch (error) {
143
- if (error instanceof GatewayError) throw error;
144
- break; // advance to next endpoint
145
- }
146
-
147
- // Drain the response
148
- let tally: StreamTally;
149
- try {
150
- tally = await deps.drain(response, deps.signal);
151
- } catch (error) {
152
- if (error instanceof GatewayError) throw error;
153
- break; // advance to next endpoint
154
- }
155
-
156
- // Check if empty
157
- if (!isEmptyStream(tally)) {
158
- // Success — return the response and tally
159
- return {
160
- response,
161
- tally,
162
- endpoint,
163
- replaysUsed: replay,
164
- };
165
- }
166
-
167
- // Empty — replay if we haven't exhausted this endpoint's budget
168
- if (replay < replaysAllowed) {
169
- const delay = fallbackRetryDelayMs(replay);
170
- try {
171
- await sleep(delay, deps.signal);
172
- } catch (error) {
173
- if (error instanceof GatewayError) throw error;
174
- throw error;
175
- }
176
- }
177
- }
178
- }
179
-
180
- // All endpoints exhausted
181
- throw new GatewayError(
182
- "platform_unavailable",
183
- 502,
184
- "All endpoints returned empty streams",
185
- {},
186
- "upstream",
187
- );
188
- }
189
-
190
- // ─── Internal helpers ──────────────────────────────────────────────────────
191
-
192
- async function fetchWithTimeout(
193
- fetch: EmptyStreamFetch,
194
- url: string,
195
- request: { headers: Record<string, string>; body: string },
196
- signal: AbortSignal,
197
- timeoutMs: number,
198
- ): Promise<Response> {
199
- // Race the fetch against a first-event timeout
200
- const timeoutPromise = new Promise<never>((_, reject) => {
201
- const timer = setTimeout(() => {
202
- // A first-event timeout is the gateway waiting on a silent upstream (e.g.
203
- // a provider that holds the request in a queue), not the client leaving.
204
- // It must surface as a deadline failure, never as a `transport_closed`
205
- // 499 — that code is reserved for genuine client cancellations and would
206
- // otherwise hide a real upstream wait behind "cancelled by client".
207
- reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
208
- }, timeoutMs);
209
- signal.addEventListener("abort", () => {
210
- clearTimeout(timer);
211
- reject(new GatewayError("transport_closed", 499, "Aborted"));
212
- }, { once: true });
213
- });
214
-
215
- try {
216
- return await Promise.race([
217
- fetch(url, { headers: request.headers, body: request.body, signal }),
218
- timeoutPromise,
219
- ]);
220
- } catch (error) {
221
- if (error instanceof GatewayError) throw error;
222
- throw error;
223
- }
224
- }
1
+ /**
2
+ * Empty-stream replay + pre-start failover.
3
+ *
4
+ * When a provider returns HTTP 200 but the response body decodes to zero
5
+ * meaningful events, the request is replayed byte-identical (same headers,
6
+ * same body) up to `maxEmptyReplays` times with exponential backoff. If all
7
+ * replays on one endpoint fail, the helper advances to the next endpoint in
8
+ * the caller-supplied ordered list.
9
+ *
10
+ * The helper is transport-layer: it owns fetch, watchdog, backoff, and
11
+ * endpoint rotation. The caller owns SSE decoding via the `DrainStream`
12
+ * callback and supplies materialized headers + serialized body so replays
13
+ * are byte-identical.
14
+ *
15
+ * Design: the replay count, backoff, and the ordered endpoint list are the
16
+ * caller's contract; this helper owns fetch, watchdog, and rotation so a
17
+ * provider that answers 200 with no events is retried on the next endpoint
18
+ * rather than surfacing an empty body to the client.
19
+ */
20
+ import { GatewayError } from "../../protocol/gateway-error";
21
+ import {
22
+ fallbackRetryDelayMs,
23
+ MIN_RETRY_BUDGET_MS,
24
+ sleep as abortableSleep,
25
+ } from "../failure-policy";
26
+
27
+ // ─── Public types ──────────────────────────────────────────────────────────
28
+
29
+ /** What the caller's drain observed. Counts only dispatched data events:
30
+ * comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
31
+ export interface StreamTally {
32
+ readonly dataEvents: number;
33
+ readonly meaningful: boolean;
34
+ readonly thoughtOnly: boolean;
35
+ }
36
+
37
+ /** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
38
+ export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
39
+
40
+ /** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
41
+ export type EmptyStreamFetch = (
42
+ url: string,
43
+ init: { headers: Record<string, string>; body: string; signal: AbortSignal },
44
+ ) => Promise<Response>;
45
+
46
+ /**
47
+ * Retry-delay policy.
48
+ *
49
+ * Delays come from `fallbackRetryDelayMs` (capped exponential with full
50
+ * jitter), whose base and cap are operator-tunable. There is deliberately no
51
+ * per-caller base: the local formula this replaced was uncapped and
52
+ * unjittered, so a provider returning 200-with-no-events could drive an
53
+ * unbounded wait, and concurrent clients could resynchronize their retries.
54
+ */
55
+ export interface EmptyStreamPolicy {
56
+ readonly maxEmptyReplays?: number;
57
+ readonly firstEventTimeoutMs?: number;
58
+ readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
59
+ }
60
+
61
+ export interface EmptyStreamResult {
62
+ readonly response: Response;
63
+ readonly tally: StreamTally;
64
+ readonly endpoint: string;
65
+ readonly replaysUsed: number;
66
+ }
67
+
68
+ // ─── Defaults ──────────────────────────────────────────────────────────────
69
+
70
+ const DEFAULT_MAX_REPLAYS = 2;
71
+ const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
72
+
73
+
74
+ // ─── Helpers ───────────────────────────────────────────────────────────────
75
+
76
+ /** Pure predicate: zero data events, or data events with nothing meaningful. */
77
+ export function isEmptyStream(tally: StreamTally): boolean {
78
+ return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
79
+ }
80
+
81
+
82
+
83
+ // ─── Main function ─────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Replays the byte-identical request while responses drain empty,
87
+ * failing over across ordered endpoints pre-start.
88
+ *
89
+ * Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
90
+ * GatewayError (deadline_exceeded, 504, upstream) when a first event never
91
+ * arrives within the window, and GatewayError (transport_closed, 499) on
92
+ * client abort.
93
+ */
94
+ export async function fetchWithEmptyStreamRecovery(
95
+ endpoints: readonly string[],
96
+ request: { headers: Record<string, string>; body: string },
97
+ deps: {
98
+ fetch: EmptyStreamFetch;
99
+ drain: DrainStream;
100
+ signal: AbortSignal;
101
+ deadlineMs: number;
102
+ policy?: EmptyStreamPolicy;
103
+ },
104
+ ): Promise<EmptyStreamResult> {
105
+ const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
106
+ const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
107
+ const sleep = deps.policy?.sleep ?? abortableSleep;
108
+
109
+ if (endpoints.length === 0) {
110
+ throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
111
+ }
112
+
113
+ for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
114
+ const endpoint = endpoints[endpointIndex]!;
115
+ const isLastEndpoint = endpointIndex === endpoints.length - 1;
116
+ const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
117
+
118
+ for (let replay = 0; replay <= replaysAllowed; replay++) {
119
+ // Check abort before each fetch
120
+ if (deps.signal.aborted) {
121
+ throw new GatewayError("transport_closed", 499, "Aborted");
122
+ }
123
+
124
+ // Check deadline floor
125
+ const now = Date.now();
126
+ const remaining = deps.deadlineMs - now;
127
+ if (remaining < MIN_RETRY_BUDGET_MS) {
128
+ throw new GatewayError(
129
+ "platform_unavailable",
130
+ 502,
131
+ "Deadline exhausted before empty-stream replay",
132
+ {},
133
+ "upstream",
134
+ );
135
+ }
136
+
137
+ const fetchTimeout = Math.min(firstEventTimeout, remaining);
138
+
139
+ let response: Response;
140
+ try {
141
+ response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
142
+ } catch (error) {
143
+ if (error instanceof GatewayError) throw error;
144
+ break; // advance to next endpoint
145
+ }
146
+
147
+ // Drain the response
148
+ let tally: StreamTally;
149
+ try {
150
+ tally = await deps.drain(response, deps.signal);
151
+ } catch (error) {
152
+ if (error instanceof GatewayError) throw error;
153
+ break; // advance to next endpoint
154
+ }
155
+
156
+ // Check if empty
157
+ if (!isEmptyStream(tally)) {
158
+ // Success — return the response and tally
159
+ return {
160
+ response,
161
+ tally,
162
+ endpoint,
163
+ replaysUsed: replay,
164
+ };
165
+ }
166
+
167
+ // Empty — replay if we haven't exhausted this endpoint's budget
168
+ if (replay < replaysAllowed) {
169
+ const delay = fallbackRetryDelayMs(replay);
170
+ try {
171
+ await sleep(delay, deps.signal);
172
+ } catch (error) {
173
+ if (error instanceof GatewayError) throw error;
174
+ throw error;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // All endpoints exhausted
181
+ throw new GatewayError(
182
+ "platform_unavailable",
183
+ 502,
184
+ "All endpoints returned empty streams",
185
+ {},
186
+ "upstream",
187
+ );
188
+ }
189
+
190
+ // ─── Internal helpers ──────────────────────────────────────────────────────
191
+
192
+ async function fetchWithTimeout(
193
+ fetch: EmptyStreamFetch,
194
+ url: string,
195
+ request: { headers: Record<string, string>; body: string },
196
+ signal: AbortSignal,
197
+ timeoutMs: number,
198
+ ): Promise<Response> {
199
+ // Race the fetch against a first-event timeout
200
+ const timeoutPromise = new Promise<never>((_, reject) => {
201
+ const timer = setTimeout(() => {
202
+ // A first-event timeout is the gateway waiting on a silent upstream (e.g.
203
+ // a provider that holds the request in a queue), not the client leaving.
204
+ // It must surface as a deadline failure, never as a `transport_closed`
205
+ // 499 — that code is reserved for genuine client cancellations and would
206
+ // otherwise hide a real upstream wait behind "cancelled by client".
207
+ reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
208
+ }, timeoutMs);
209
+ signal.addEventListener("abort", () => {
210
+ clearTimeout(timer);
211
+ reject(new GatewayError("transport_closed", 499, "Aborted"));
212
+ }, { once: true });
213
+ });
214
+
215
+ try {
216
+ return await Promise.race([
217
+ fetch(url, { headers: request.headers, body: request.body, signal }),
218
+ timeoutPromise,
219
+ ]);
220
+ } catch (error) {
221
+ if (error instanceof GatewayError) throw error;
222
+ throw error;
223
+ }
224
+ }
@@ -1,6 +1,9 @@
1
1
  import { isBundledProviderId } from "../../providers/provider-contracts";
2
2
  import type { ProviderDispatchContext, ProviderId, ProviderAdapter } from "../../providers/provider-contracts";
3
3
  import { GatewayError } from "../../protocol/gateway-error";
4
+ import { isWrappedInjectionProvider } from "../../console/system-prompt/contracts";
5
+ import { resolveTenantSystemPrompt } from "../request/system-prompt-injection";
6
+ import { readTenantSystemPromptsForDispatch } from "../request/tenant-preferences";
4
7
  import type { CanonicalEvent } from "../../protocol/canonical-model";
5
8
  import { resolveCredentialForAccount } from "../../providers/operations/provider-credential-service";
6
9
  import type { ByokUpstreamHost } from "../../providers/operations/provider-catalog-service";
@@ -115,6 +118,26 @@ export async function handleProviderProxyRequest(
115
118
  if (!prepared)
116
119
  throw new GatewayError("admission_unavailable", 503, "The request was not prepared, so no route can be dialed. Resend the request.");
117
120
  const canonicalRequest = await applyTenantPreferences(prepared, deps.db);
121
+ // Tenant system-prompt injection, resolved once per request. For providers
122
+ // whose upstream validates the fixed system prompt as a channel identity
123
+ // (the buddy family), the text cannot ride the canonical `system` array —
124
+ // the adapter replaces it — so it travels through dispatch context and the
125
+ // adapter folds it into its fixed turn, wrapped in the gateway's markers.
126
+ // `override` degrades to wrapped append there: stripping the identity would
127
+ // have the upstream refuse the request outright.
128
+ const candidateForPrompt = prepared.candidate;
129
+ const tenantSystemPromptText = isWrappedInjectionProvider(candidateForPrompt.provider_id)
130
+ ? resolveTenantSystemPrompt(
131
+ await readTenantSystemPromptsForDispatch(
132
+ deps.db,
133
+ prepared.authorization.snapshot.tenant_id,
134
+ ),
135
+ {
136
+ providerId: candidateForPrompt.provider_id,
137
+ modelId: candidateForPrompt.model_id,
138
+ },
139
+ )?.text
140
+ : undefined;
118
141
  // Per-tenant timeout, applied as soon as the tenant is known. The deadline
119
142
  // was armed at admission with the deployment default; this re-arms it.
120
143
  await applyTenantDeadlineOverride(
@@ -153,8 +176,13 @@ export async function handleProviderProxyRequest(
153
176
  estimatedOutputTokens: prepared.estimatedOutputTokens,
154
177
  authorizationSnapshot: prepared.authorization.snapshot,
155
178
  // A continuation borrows the held Cursor Run: one upstream turn must not
156
- // occupy two account/pool concurrency slots.
157
- ...(prepared.continuationAffinity === undefined ? {} : { borrowsHeldSession: true }),
179
+ // occupy two account/pool concurrency slots. A rebaseable continuation
180
+ // (complete history, pinned account no longer routable) does NOT borrow —
181
+ // it starts a fresh Run on a newly selected account, so it leases like a
182
+ // normal request.
183
+ ...(prepared.continuationAffinity === undefined || prepared.continuationAffinity.rebaseable
184
+ ? {}
185
+ : { borrowsHeldSession: true }),
158
186
  },
159
187
  candidates: attemptCandidates,
160
188
  tenantId: prepared.authorization.tenantId,
@@ -231,6 +259,7 @@ export async function handleProviderProxyRequest(
231
259
  ? {}
232
260
  : { imageClampMaxBytes: state.imageClampMaxBytes }),
233
261
  ...(conversationAffinity ? { conversationAffinity } : {}),
262
+ ...(tenantSystemPromptText === undefined ? {} : { tenantSystemPrompt: tenantSystemPromptText }),
234
263
  ...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
235
264
  ...(deps.networkBindingFactory
236
265
  ? {
@@ -50,7 +50,9 @@ import {
50
50
  resolveCaptureModeForRequest,
51
51
  shouldCaptureProviderBodyForRequest,
52
52
  } from "../capture-policy";
53
- import { resolveTenantImageClampMaxBytes } from "../../request/tenant-preferences";
53
+ import { resolveTenantImageClampMaxBytes, readTenantSystemPromptsForDispatch } from "../../request/tenant-preferences";
54
+ import { resolveTenantSystemPrompt } from "../../request/system-prompt-injection";
55
+ import { isWrappedInjectionProvider } from "../../../console/system-prompt/contracts";
54
56
  import { proxySuccessHeaders, buildUpstreamDispatchContext } from "../upstream";
55
57
  import type { AttemptLeaseOwnership } from "../leases";
56
58
  import type { ProviderContinuationScope } from "../../../providers/provider-contracts";
@@ -217,6 +219,19 @@ export async function dispatchStreamingAttempt(input: StreamingDispatchInput): P
217
219
  ? {}
218
220
  : { imageClampMaxBytes: state.imageClampMaxBytes }),
219
221
  ...(conversationAffinity ? { conversationAffinity } : {}),
222
+ // Same wrapped-injection text the non-streaming path carries: providers
223
+ // whose upstream validates the fixed system prompt as a channel identity
224
+ // (the buddy family) receive the tenant prompt through dispatch context
225
+ // so the adapter can fold it into its fixed turn. `override` degrades to
226
+ // wrapped append there; stripping the identity would be refused outright.
227
+ ...(await (async () => {
228
+ if (!isWrappedInjectionProvider(candidate.provider_id)) return {};
229
+ const text = resolveTenantSystemPrompt(
230
+ await readTenantSystemPromptsForDispatch(deps.db, prepared.authorization.snapshot.tenant_id),
231
+ { providerId: candidate.provider_id, modelId: candidate.model_id },
232
+ )?.text;
233
+ return text === undefined ? {} : { tenantSystemPrompt: text };
234
+ })()),
220
235
  ...(candidate.user_agent === undefined ? {} : { userAgent: candidate.user_agent }),
221
236
  ...(wrappedOutboundFetch ? { outboundFetch: wrappedOutboundFetch } : {}),
222
237
  ...(outboundFetch ? { outboundAttachmentFetch: outboundFetch } : {}),
@@ -61,6 +61,13 @@ export function buildUpstreamDispatchContext(input: {
61
61
  * database handle.
62
62
  */
63
63
  readonly imageClampMaxBytes?: number;
64
+ /**
65
+ * Tenant system-prompt text resolved once per request (append/override
66
+ * injection). Carried through dispatch because the buddy adapters fold it
67
+ * into their fixed system turn after serialization — the canonical level
68
+ * cannot reach that turn.
69
+ */
70
+ readonly tenantSystemPrompt?: string;
64
71
  /** Opaque identity shared by retries of one logical gateway request. */
65
72
  readonly requestIdentity?: object;
66
73
  }): Record<string, unknown> {
@@ -101,6 +108,9 @@ export function buildUpstreamDispatchContext(input: {
101
108
  ...(input.imageClampMaxBytes === undefined
102
109
  ? {}
103
110
  : { image_clamp_max_bytes: input.imageClampMaxBytes }),
111
+ ...(input.tenantSystemPrompt === undefined
112
+ ? {}
113
+ : { tenant_system_prompt: input.tenantSystemPrompt }),
104
114
  ...(input.requestIdentity ? { request_identity: input.requestIdentity } : {}),
105
115
  } as Record<string, unknown>;
106
116
  }
@@ -2,6 +2,7 @@ import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "..
2
2
  import { isRecord } from "../protocol/codecs";
3
3
  import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
4
4
  import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
5
+ import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
5
6
 
6
7
  /**
7
8
  * How much of the upstream's error body is kept and surfaced.
@@ -232,6 +233,31 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
232
233
  // DNS failure. Failing the client request instead of failing over is the wrong
233
234
  // outcome for a whole class the table already names as platform-wide.
234
235
  const statusCode = error.status;
236
+ // Qoder answers an exhausted quota by closing the stream with an upstream 499
237
+ // (nginx client-close style). That is a refusal from the upstream — the
238
+ // gateway's own aborts are `transport_closed`, produced locally, and never
239
+ // carry a qoder providerId — so a 499 scoped to qoder is quota, not a
240
+ // malformed request. Without this it fell through to `invalid_request`
241
+ // (non-retryable, no cooldown) and the account was re-dialled on every
242
+ // request inside the exhausted window until a late 429 finally classified it.
243
+ if (
244
+ error.origin === "upstream" &&
245
+ statusCode === 499 &&
246
+ (providerId === "qoder" || providerId === "qodercn")
247
+ ) {
248
+ const cooldownMs = resolveAccountQuotaCooldownMs();
249
+ return {
250
+ retryable: true,
251
+ mutatesAccount: true,
252
+ category: "quota_exceeded",
253
+ scope: "account",
254
+ origin: error.origin,
255
+ ...(providerCode ? { providerCode } : {}),
256
+ ...(providerId ? { providerId } : {}),
257
+ cooldownMs,
258
+ statusCode,
259
+ };
260
+ }
235
261
  const retryableByStatus =
236
262
  statusCode === 401 ||
237
263
  statusCode === 403 ||
@@ -459,6 +485,13 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
459
485
  "model_context_window_exceeded",
460
486
  // InferHub's spelling for the same condition.
461
487
  "context_too_long",
488
+ // Trae answers an overflow on its streaming surface with the bare numeric
489
+ // code `4026` and the prose "We're sorry, your context length has exceeded
490
+ // the maximum limit." — no HTTP status, no symbolic code (verified live
491
+ // 2026-10: `event:error data:{"code":4026,"message":"..."}`). Without it
492
+ // the frame fell to the 502 `platform_unavailable` fallback and the client
493
+ // never saw the 413 it compacts on.
494
+ "4026",
462
495
  ]);
463
496
 
464
497
  /**