cartethyia 2.7.2 → 2.7.4

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 (405) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
  10. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
  40. package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
  41. package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-a159k0ub.css +2 -0
  43. package/dist/dashboard/assets/app-a159k0ub.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-DbRA8EG2.js +15 -0
  47. package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
  49. package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
  51. package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
  53. package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
  54. package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
  55. package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
  59. package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
  61. package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
  63. package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
  65. package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
  67. package/dist/dashboard/assets/entry-Daz0lJ-W.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-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
  71. package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
  75. package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
  77. package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
  79. package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
  81. package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
  83. package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
  84. package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
  85. package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
  86. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  87. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  88. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
  89. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  90. package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
  91. package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
  92. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
  93. package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
  94. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
  95. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  96. package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
  97. package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
  98. package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
  99. package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
  100. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
  101. package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
  102. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  103. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  104. package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
  105. package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
  106. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
  107. package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
  108. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
  109. package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
  110. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
  111. package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
  112. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
  113. package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
  114. package/dist/dashboard/index.html +3 -2
  115. package/dist/dashboard/index.html.gz +0 -0
  116. package/dist/dashboard/precompressed-assets.json +118 -115
  117. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  118. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
  119. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  123. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  124. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  125. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  126. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  127. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  128. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
  129. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  130. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  131. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  132. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  133. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  134. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  135. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  136. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  137. package/package.json +2 -1
  138. package/src/boot/app.ts +9 -8
  139. package/src/config/network.ts +7 -3
  140. package/src/console/api-keys/bucket-spend.ts +7 -7
  141. package/src/console/api-keys/contracts.ts +475 -410
  142. package/src/console/api-keys/routes.ts +211 -23
  143. package/src/console/audit/routes.ts +3 -3
  144. package/src/console/auth/account-reset.ts +4 -4
  145. package/src/console/auth/session.ts +3 -3
  146. package/src/console/backup/contracts.ts +2 -0
  147. package/src/console/backup/export-endpoint.ts +14 -17
  148. package/src/console/backup/routes.ts +43 -11
  149. package/src/console/backup/service.ts +11 -6
  150. package/src/console/backup/store.ts +384 -88
  151. package/src/console/cli-tools/routes.ts +3 -3
  152. package/src/console/console-router-guards.ts +2 -2
  153. package/src/console/console-router.ts +4 -4
  154. package/src/console/dashboard-assets.ts +16 -1
  155. package/src/console/data-management/routes.ts +3 -3
  156. package/src/console/domain-registration.ts +4 -2
  157. package/src/console/observability/contracts.ts +4 -0
  158. package/src/console/observability/live.ts +3 -3
  159. package/src/console/observability/logs.ts +3 -3
  160. package/src/console/observability/performance.ts +3 -3
  161. package/src/console/observability/routes.ts +3 -3
  162. package/src/console/observability/store.ts +16 -16
  163. package/src/console/providers/catalog/contracts.ts +43 -22
  164. package/src/console/providers/catalog/provider-operations.ts +17 -2
  165. package/src/console/providers/catalog/routes.ts +3 -3
  166. package/src/console/providers/catalog/store-helpers.ts +349 -326
  167. package/src/console/providers/catalog/store.ts +1523 -1292
  168. package/src/console/providers/detail/routes.ts +3 -3
  169. package/src/console/providers/oauth/routes.ts +3 -3
  170. package/src/console/quota/account-quota-tenant.ts +34 -2
  171. package/src/console/quota/account-quota.ts +4 -1
  172. package/src/console/quota/account-test-egress.ts +4 -4
  173. package/src/console/quota/native-identity.ts +189 -0
  174. package/src/console/remote-support/contracts.ts +12 -6
  175. package/src/console/remote-support/service.ts +2 -2
  176. package/src/console/routing/model/routes.ts +3 -3
  177. package/src/console/routing/pools/contracts.ts +11 -8
  178. package/src/console/routing/pools/operations.ts +14 -7
  179. package/src/console/routing/pools/probe-verdict.ts +8 -3
  180. package/src/console/routing/pools/relay-deploy.ts +9 -9
  181. package/src/console/routing/pools/routes.ts +3 -3
  182. package/src/console/routing/pools/store.ts +21 -1
  183. package/src/console/settings/routes.ts +3 -3
  184. package/src/console/share/share-router.ts +51 -18
  185. package/src/console/share/share-stats.ts +158 -49
  186. package/src/console/share/share-usage.ts +24 -7
  187. package/src/console/shared/read-through-cache.ts +38 -2
  188. package/src/console/system-prompt/contracts.ts +102 -0
  189. package/src/console/system-prompt/routes.ts +165 -0
  190. package/src/console/updates/routes.ts +7 -4
  191. package/src/main.ts +11 -0
  192. package/src/network/outbound-fetch.ts +75 -4
  193. package/src/network/pool/resolver.ts +39 -39
  194. package/src/network/pool-cooldown-store.ts +71 -16
  195. package/src/network/pool-health-machine.ts +21 -1
  196. package/src/network/rate-limit-verdict.ts +68 -0
  197. package/src/network/ssrf.ts +4 -4
  198. package/src/observability/event-query.ts +437 -22
  199. package/src/observability/telemetry-status.ts +1 -1
  200. package/src/persistence/db-pg.ts +42 -0
  201. package/src/persistence/db-sqlite.ts +7 -2
  202. package/src/persistence/readiness.ts +18 -6
  203. package/src/persistence/schema.ts +162 -1
  204. package/src/persistence/stores/api-key-store.ts +849 -581
  205. package/src/persistence/stores/share-store.ts +65 -13
  206. package/src/persistence/stores/telemetry-store.ts +58 -2
  207. package/src/persistence/system-prompt-store.ts +104 -0
  208. package/src/protocol/codecs.ts +6 -6
  209. package/src/protocol/gateway-error.ts +6 -3
  210. package/src/protocol/image-generation.ts +2 -2
  211. package/src/protocol/reasoning.ts +65 -1
  212. package/src/protocol/request/chat.ts +1 -0
  213. package/src/protocol/response/chat.ts +2 -2
  214. package/src/protocol/response/codex.ts +1 -1
  215. package/src/protocol/response/gemini.ts +1 -1
  216. package/src/protocol/response/messages.ts +5 -5
  217. package/src/protocol/response/responses.ts +2 -2
  218. package/src/protocol/sse-decoder.ts +14 -3
  219. package/src/protocol/translation/capabilities.ts +2 -2
  220. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  221. package/src/providers/adapters/base-adapter.ts +1 -1
  222. package/src/providers/adapters/compatible-adapter.ts +2 -2
  223. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  224. package/src/providers/configured-provider.ts +1 -1
  225. package/src/providers/discovery/probe-phases.ts +2 -2
  226. package/src/providers/discovery/probing-service.ts +12 -12
  227. package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
  228. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  229. package/src/providers/integrations/anthropic.ts +4 -11
  230. package/src/providers/integrations/antigravity/antigravity.ts +33 -47
  231. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
  232. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  233. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  234. package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
  235. package/src/providers/integrations/buddy/buddy-media.ts +21 -17
  236. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  237. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
  238. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  239. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  240. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  241. package/src/providers/integrations/claude/claude.ts +3 -10
  242. package/src/providers/integrations/cline/cline.ts +8 -1
  243. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  244. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  245. package/src/providers/integrations/codex/codex.ts +14 -15
  246. package/src/providers/integrations/commandcode/commandcode.ts +4 -4
  247. package/src/providers/integrations/commandcode/quota.ts +5 -1
  248. package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
  249. package/src/providers/integrations/cursor/cursor.ts +15 -15
  250. package/src/providers/integrations/factory/factory.ts +4 -4
  251. package/src/providers/integrations/gemini.ts +11 -11
  252. package/src/providers/integrations/github/github.ts +26 -7
  253. package/src/providers/integrations/grok/grok.ts +2 -6
  254. package/src/providers/integrations/hermes.ts +5 -1
  255. package/src/providers/integrations/inferhub.ts +4 -11
  256. package/src/providers/integrations/kilo/kilo.ts +4 -0
  257. package/src/providers/integrations/kimi/kimi.ts +4 -11
  258. package/src/providers/integrations/kiro/kiro.ts +10 -10
  259. package/src/providers/integrations/muse/muse.ts +6 -9
  260. package/src/providers/integrations/openrouter/provider.ts +4 -0
  261. package/src/providers/integrations/perplexity.ts +2 -2
  262. package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
  263. package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
  264. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
  265. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  266. package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
  267. package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
  268. package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
  269. package/src/providers/integrations/qoder/qoder.ts +192 -24
  270. package/src/providers/integrations/tokenharbor.ts +2 -2
  271. package/src/providers/integrations/trae/trae.ts +6 -9
  272. package/src/providers/integrations/zcode/oauth.ts +1 -1
  273. package/src/providers/operations/account-error-classification.ts +6 -8
  274. package/src/providers/operations/account-health-service.ts +14 -16
  275. package/src/providers/operations/client-versions.ts +37 -22
  276. package/src/providers/operations/provider-catalog-service.ts +4 -4
  277. package/src/providers/operations/provider-credential-service.ts +16 -4
  278. package/src/providers/operations/upstream-deadline.ts +3 -3
  279. package/src/providers/provider-contracts.ts +55 -3
  280. package/src/providers/provider-credential-resolver.ts +1 -1
  281. package/src/providers/provider-registry.ts +4 -4
  282. package/src/providers/quota/quota-contracts.ts +24 -10
  283. package/src/providers/quota/refresh.ts +2 -2
  284. package/src/providers/search/search-provider.ts +5 -5
  285. package/src/security/abuse.ts +4 -4
  286. package/src/security/access-control.ts +19 -0
  287. package/src/security/admission/reasons.ts +17 -17
  288. package/src/security/api-key-auth.ts +18 -5
  289. package/src/shared/coerce.ts +19 -0
  290. package/src/transport/dispatch/attempt-finalize.ts +4 -3
  291. package/src/transport/dispatch/attempt-loop.ts +10 -7
  292. package/src/transport/dispatch/empty-stream.ts +6 -6
  293. package/src/transport/dispatch/generation.ts +9 -9
  294. package/src/transport/dispatch/leases.ts +8 -8
  295. package/src/transport/dispatch/proxy-request.ts +38 -9
  296. package/src/transport/dispatch/responses-compact.ts +11 -11
  297. package/src/transport/dispatch/retry-policy.ts +4 -67
  298. package/src/transport/dispatch/stream-bridge.ts +1 -1
  299. package/src/transport/dispatch/streaming/finalize.ts +9 -8
  300. package/src/transport/dispatch/streaming/stream.ts +16 -1
  301. package/src/transport/dispatch/systemone.ts +8 -8
  302. package/src/transport/dispatch/token-count.ts +9 -9
  303. package/src/transport/dispatch/upstream.ts +10 -0
  304. package/src/transport/dispatch/websearch.ts +6 -6
  305. package/src/transport/drain-endpoint.ts +2 -2
  306. package/src/transport/failure-policy.ts +13 -6
  307. package/src/transport/guards/auth-guard.ts +6 -5
  308. package/src/transport/guards/body-policy.ts +4 -4
  309. package/src/transport/guards/error-lifecycle.ts +18 -3
  310. package/src/transport/guards/guard-errors.ts +7 -3
  311. package/src/transport/guards/headroom-guard.ts +1 -1
  312. package/src/transport/guards/pipeline.ts +6 -6
  313. package/src/transport/guards/readiness-guard.ts +16 -2
  314. package/src/transport/guards/request-context.ts +3 -3
  315. package/src/transport/request/preparer.ts +37 -21
  316. package/src/transport/request/state.ts +4 -7
  317. package/src/transport/request/system-prompt-injection.ts +89 -0
  318. package/src/transport/request/tenant-preferences.ts +43 -0
  319. package/src/transport/routing/admission.ts +13 -3
  320. package/src/transport/routing/route-catalog.ts +104 -2
  321. package/src/transport/routing/route-model.ts +60 -7
  322. package/src/transport/routing/router.ts +306 -256
  323. package/src/transport/shutdown-notice.ts +19 -7
  324. package/src/transport/surface/completion.ts +1 -1
  325. package/src/transport/surface/messages/encode.ts +1 -1
  326. package/src/workers/daily-checkin.ts +2 -0
  327. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  328. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  329. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  330. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  331. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  332. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  333. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
  334. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  335. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  336. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  337. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  338. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  339. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  340. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  341. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  342. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  343. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  344. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  345. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  346. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  347. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  348. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
  349. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  350. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
  351. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  352. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  353. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  354. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  355. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  356. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
  357. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  358. package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
  359. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  360. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  361. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  362. package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
  363. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  364. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  365. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  366. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  367. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
  368. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  369. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  370. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  371. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  372. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  373. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  374. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  375. package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
  376. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  377. package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
  378. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  379. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  380. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  381. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  382. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  383. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  384. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  385. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  386. package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
  387. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  388. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  389. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  390. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  391. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  392. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  393. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  394. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  395. package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
  396. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  397. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  398. package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
  399. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  400. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  401. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  402. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  403. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  404. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
  405. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
@@ -1,4 +1,4 @@
1
- import { and, eq, inArray } from "drizzle-orm";
1
+ import { and, eq, sql } from "drizzle-orm";
2
2
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
3
3
  import type { ApiKeyRecord } from "../../persistence/stores/api-key-store";
4
4
  import { CachedPreferencesReader, DrizzlePreferencesReader } from "../../persistence/tenant-preferences";
@@ -9,7 +9,7 @@ import { isGatewayError } from "../../observability/telemetry-status";
9
9
  import type { RedisClient } from "../../persistence/redis";
10
10
  import { cachedRead } from "../shared/read-through-cache";
11
11
  import { SHARE_STATS_CACHE_TTL_SECONDS } from "./share-stats";
12
- import { telemetryUsageTotals } from "../../persistence/schema";
12
+ import { apiKeys, telemetryUsageTotals } from "../../persistence/schema";
13
13
  import { shouldMaskClientIp } from "../shared/ip-privacy";
14
14
  import { utcDayStart } from "../shared/day";
15
15
 
@@ -66,9 +66,16 @@ export interface SharedKeyActivityDetail {
66
66
 
67
67
  /** Read-only share-recipient telemetry; payloads and credential material never cross this boundary. */
68
68
  export interface ShareActivityPort {
69
+ /**
70
+ * `familyTemplateKeyId` is the share template the children were issued from:
71
+ * the telemetry reads scope to the whole family through it (an `EXISTS`
72
+ * against `api_keys`, constant-wire) rather than through an id list, so the
73
+ * read is the same no matter how many keys the template has minted.
74
+ */
69
75
  getSharedKeySummaries(
70
76
  tenantId: string,
71
77
  children: readonly ApiKeyRecord[],
78
+ familyTemplateKeyId: string,
72
79
  ): Promise<readonly SharedKeySummary[]>;
73
80
  getSharedKeyDetail(tenantId: string, childKeyId: string): Promise<SharedKeyActivityDetail>;
74
81
  }
@@ -133,15 +140,25 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
133
140
  const { db, redis } = deps;
134
141
  const preferences = new CachedPreferencesReader(new DrizzlePreferencesReader(db));
135
142
  return {
136
- async getSharedKeySummaries(tenantId, children) {
143
+ async getSharedKeySummaries(tenantId, children, familyTemplateKeyId) {
137
144
  if (children.length === 0) return [];
138
- const childIds = children.map((child) => child.id);
139
145
  const todayStart = utcDayStart(new Date());
146
+ /**
147
+ * Family membership of the entity the totals/events row is attributed to.
148
+ * Primary predicate is the denormalized template column (so history
149
+ * survives key deletion); the `EXISTS` fallback covers rows written
150
+ * before the column existed.
151
+ */
152
+ const familyMembership = (entityId: typeof apiKeys.id | typeof telemetryUsageTotals.entityId) =>
153
+ sql`(${telemetryUsageTotals.familyTemplateKeyId} = ${familyTemplateKeyId} or (${telemetryUsageTotals.familyTemplateKeyId} is null and exists (select 1 from ${apiKeys} where ${apiKeys.id} = ${entityId} and (${apiKeys.id} = ${familyTemplateKeyId} or ${apiKeys.parentKeyId} = ${familyTemplateKeyId}))))`;
140
154
  const [todayEvents, lifetimeRows, hideClientIp] = await Promise.all([
141
155
  // Today's per-key totals as one GROUP BY instead of reading every
142
156
  // event of the family. `addEventUsage` needs per-event fields, so the
143
- // groups are projected into the same shape the loop consumes.
144
- querySqlEvents(db, { tenantId, apiKeyIds: childIds, since: todayStart, limit: MAX_SUMMARY_EVENTS }).then(
157
+ // groups are projected into the same shape the loop consumes. The
158
+ // family filter is the template key, not an id list: a family with
159
+ // tens of thousands of minted children must not expand them into bind
160
+ // parameters on every dashboard poll.
161
+ querySqlEvents(db, { tenantId, familyApiKeyId: familyTemplateKeyId, since: todayStart, limit: MAX_SUMMARY_EVENTS }).then(
145
162
  (page) => page.events,
146
163
  ),
147
164
  db
@@ -158,7 +175,7 @@ export function createShareUsagePort(deps: ShareUsagePortDeps): ShareActivityPor
158
175
  and(
159
176
  eq(telemetryUsageTotals.tenantId, tenantId),
160
177
  eq(telemetryUsageTotals.identityType, "api_key"),
161
- inArray(telemetryUsageTotals.entityId, childIds),
178
+ familyMembership(telemetryUsageTotals.entityId),
162
179
  ),
163
180
  ),
164
181
  shouldMaskClientIp(preferences, tenantId),
@@ -63,6 +63,34 @@ export function etagKey(key: string): string {
63
63
  return `${key}:etag`;
64
64
  }
65
65
 
66
+ /**
67
+ * In-memory fallback store used when no Redis client is configured (the
68
+ * embedded lite backend). Keyed by the same cache key, storing the value and
69
+ * its validator with a TTL. Bounded so a busy gateway cannot grow it without
70
+ * limit.
71
+ */
72
+ const LOCAL_CACHE_MAX_ENTRIES = 2000;
73
+ const localCache = new Map<string, { at: number; value: string; etag: string }>();
74
+
75
+ function localGet(key: string): { value: string; etag: string } | undefined {
76
+ const entry = localCache.get(key);
77
+ if (entry === undefined) return undefined;
78
+ if (Date.now() - entry.at >= READ_THROUGH_DEFAULT_TTL_SECONDS * 1000) {
79
+ localCache.delete(key);
80
+ return undefined;
81
+ }
82
+ return { value: entry.value, etag: entry.etag };
83
+ }
84
+
85
+ function localSet(key: string, value: string, etag: string): void {
86
+ if (localCache.size >= LOCAL_CACHE_MAX_ENTRIES) {
87
+ // Evict the oldest entry (Map iterates in insertion order).
88
+ const oldest = localCache.keys().next();
89
+ if (!oldest.done) localCache.delete(oldest.value);
90
+ }
91
+ localCache.set(key, { at: Date.now(), value, etag });
92
+ }
93
+
66
94
  /**
67
95
  * Reads through the cache and returns a strong validator alongside the value.
68
96
  *
@@ -74,7 +102,8 @@ export function etagKey(key: string): string {
74
102
  * With no cache configured (`client === undefined`) the value is computed and
75
103
  * the validator is still returned, so conditional requests keep working for
76
104
  * the length of the client's own polling loop; only the cross-instance sharing
77
- * is lost.
105
+ * is lost. A process-local Map serves the same TTL when Redis is absent, so a
106
+ * poller still gets cache hits.
78
107
  */
79
108
  export async function cachedReadWithEtag<T>(
80
109
  client: ReadThroughCacheClient | undefined,
@@ -83,8 +112,15 @@ export async function cachedReadWithEtag<T>(
83
112
  ttlSeconds: number = READ_THROUGH_DEFAULT_TTL_SECONDS,
84
113
  ): Promise<CachedResult<T>> {
85
114
  if (client === undefined) {
115
+ const entry = localGet(key);
116
+ if (entry !== undefined) {
117
+ return { value: JSON.parse(entry.value) as T, etag: entry.etag };
118
+ }
86
119
  const fresh = await compute();
87
- return { value: fresh, etag: etagFor(JSON.stringify(fresh) ?? "null") };
120
+ const bytes = JSON.stringify(fresh) ?? "null";
121
+ const etag = etagFor(bytes);
122
+ localSet(key, bytes, etag);
123
+ return { value: fresh, etag };
88
124
  }
89
125
  try {
90
126
  const hit = await client.get(key);
@@ -0,0 +1,102 @@
1
+ // Tenant system-prompt injection contracts.
2
+ //
3
+ // Shared by the console API, the persistence store, and the dispatch-path
4
+ // transform — all Elysia-free so the dashboard bundle can import the types.
5
+
6
+ /**
7
+ * One scope's prompt configuration. `enabled` is the master switch for the
8
+ * scope; `mode` decides how the text meets existing system content.
9
+ */
10
+ export interface PromptSetting {
11
+ readonly enabled: boolean;
12
+ readonly mode: PromptMode;
13
+ readonly text: string;
14
+ }
15
+
16
+ export const PROMPT_MODES = ["append", "override"] as const;
17
+ export type PromptMode = (typeof PROMPT_MODES)[number];
18
+
19
+ /** Per-provider entry: follow the tenant's global prompt, or carry a custom one. */
20
+ export interface ProviderPromptEntry {
21
+ readonly followGlobal: boolean;
22
+ /** Present and used only when `followGlobal` is false. */
23
+ readonly setting?: PromptSetting;
24
+ }
25
+
26
+ /** The full per-tenant configuration, one row in `tenant_system_prompts`. */
27
+ export interface TenantSystemPrompts {
28
+ /** `null` = never configured — distinct from an explicit disabled setting. */
29
+ readonly global: PromptSetting | null;
30
+ /** providerId → entry */
31
+ readonly providers: Readonly<Record<string, ProviderPromptEntry>>;
32
+ /** `${providerId}::${modelId}` → setting */
33
+ readonly models: Readonly<Record<string, PromptSetting>>;
34
+ }
35
+
36
+ /** Empty configuration: nothing configured anywhere. */
37
+ export const EMPTY_TENANT_SYSTEM_PROMPTS: TenantSystemPrompts = {
38
+ global: null,
39
+ providers: {},
40
+ models: {},
41
+ };
42
+
43
+ /** Maximum stored text per scope (5 MB). Enforced at the console API. */
44
+ export const SYSTEM_PROMPT_TEXT_CAP_BYTES = 5_000_000;
45
+
46
+ /**
47
+ * Marker wrapping tenant text injected into a provider wire whose upstream
48
+ * validates the system prompt as a channel identity (the buddy family). The
49
+ * fixed identity prompt stays the opener; the tenant text rides after it
50
+ * inside the same turn, framed so the provider's own parser is not confused.
51
+ */
52
+ export const SYSTEM_PROMPT_WRAPPER_PREFIX = "<cartethyia:system-prompt>";
53
+ export const SYSTEM_PROMPT_WRAPPER_SUFFIX = "</cartethyia:system-prompt>";
54
+
55
+ /** Maximum number of per-provider entries stored for one tenant. */
56
+ export const SYSTEM_PROMPT_PROVIDER_LIMIT = 256;
57
+
58
+ /** Maximum number of per-model entries stored for one tenant. */
59
+ export const SYSTEM_PROMPT_MODEL_LIMIT = 512;
60
+
61
+ /**
62
+ * Providers whose upstream validates the fixed system prompt as a channel
63
+ * identity. Their system turn must keep the fixed prompt as the opener, so
64
+ * tenant text is appended after it inside the same turn, wrapped in the
65
+ * gateway's prefix/suffix markers (see `SYSTEM_PROMPT_WRAPPER_PREFIX`).
66
+ * `override` mode is not honoured for these providers — it would strip the
67
+ * identity and the upstream would refuse the request.
68
+ */
69
+ export const WRAPPED_INJECTION_PROVIDERS: ReadonlySet<string> = new Set([
70
+ "cb", // CodeBuddy International
71
+ "cbcn", // CodeBuddy CN
72
+ "workbuddy",
73
+ ]);
74
+
75
+ /** Whether a provider's tenant text rides wrapped after the fixed identity prompt. */
76
+ export function isWrappedInjectionProvider(providerId: string): boolean {
77
+ return WRAPPED_INJECTION_PROVIDERS.has(providerId);
78
+ }
79
+
80
+ /** Model-scope key for the `models` map. */
81
+ export function systemPromptModelKey(providerId: string, modelId: string): string {
82
+ return `${providerId}::${modelId}`;
83
+ }
84
+
85
+ /** Parses a model-scope key; null when the key is malformed. */
86
+ export function parseSystemPromptModelKey(key: string): { providerId: string; modelId: string } | null {
87
+ const index = key.indexOf("::");
88
+ if (index <= 0 || index === key.length - 2) return null;
89
+ return { providerId: key.slice(0, index), modelId: key.slice(index + 2) };
90
+ }
91
+
92
+ /** True when the value is a well-formed prompt setting (shape only, not size). */
93
+ export function isPromptSetting(value: unknown): value is PromptSetting {
94
+ if (typeof value !== "object" || value === null) return false;
95
+ const candidate = value as Partial<PromptSetting>;
96
+ return (
97
+ typeof candidate.enabled === "boolean" &&
98
+ typeof candidate.mode === "string" &&
99
+ (PROMPT_MODES as readonly string[]).includes(candidate.mode) &&
100
+ typeof candidate.text === "string"
101
+ );
102
+ }
@@ -0,0 +1,165 @@
1
+ // Console API for tenant system-prompt injection.
2
+ //
3
+ // GET /system-prompt → the stored configuration
4
+ // PUT /system-prompt → replace the configuration wholesale
5
+ // DELETE /system-prompt → clear it entirely
6
+ //
7
+ // The PUT replaces the row: the modal edits the whole three-scope shape at
8
+ // once, so a PATCH surface would add states the UI never produces. Text is
9
+ // capped at 5 MB per scope and the provider/model maps are bounded, matching
10
+ // the store's own defensive limits.
11
+ import { Elysia, t } from "elysia";
12
+ import type { ConsoleAccessResolver } from "../auth/access";
13
+ import { ConsoleDomainError, consoleErrorHandler, requireTenantScope } from "../shared/errors";
14
+ import type { AuditSink } from "../audit/contracts";
15
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
16
+ import {
17
+ clearTenantSystemPrompts,
18
+ readTenantSystemPrompts,
19
+ writeTenantSystemPrompts,
20
+ } from "../../persistence/system-prompt-store";
21
+ import {
22
+ EMPTY_TENANT_SYSTEM_PROMPTS,
23
+ PROMPT_MODES,
24
+ SYSTEM_PROMPT_MODEL_LIMIT,
25
+ SYSTEM_PROMPT_PROVIDER_LIMIT,
26
+ SYSTEM_PROMPT_TEXT_CAP_BYTES,
27
+ isPromptSetting,
28
+ parseSystemPromptModelKey,
29
+ type PromptSetting,
30
+ type ProviderPromptEntry,
31
+ type TenantSystemPrompts,
32
+ } from "./contracts";
33
+
34
+ /** Route-group context injected by the composition root. */
35
+ export interface SystemPromptRoutesConfig {
36
+ readonly db: CartethyiaDatabase;
37
+ readonly accessResolver: ConsoleAccessResolver;
38
+ readonly auditSink?: AuditSink;
39
+ }
40
+
41
+ /** Validates one scope setting, including the 5 MB text cap. */
42
+ function validateSetting(value: unknown, scopeLabel: string): PromptSetting {
43
+ if (!isPromptSetting(value)) {
44
+ throw new ConsoleDomainError("invalid_request", 400, `${scopeLabel} is not a valid prompt setting`);
45
+ }
46
+ const bytes = Buffer.byteLength(value.text, "utf8");
47
+ if (bytes > SYSTEM_PROMPT_TEXT_CAP_BYTES) {
48
+ throw new ConsoleDomainError("payload_too_large", 413, `${scopeLabel} exceeds the 5 MB limit`);
49
+ }
50
+ if (!(PROMPT_MODES as readonly string[]).includes(value.mode)) {
51
+ throw new ConsoleDomainError("invalid_request", 400, `${scopeLabel} has an invalid mode`);
52
+ }
53
+ return value;
54
+ }
55
+
56
+ /** Validates the full PUT body into the stored shape. */
57
+ function validateBody(value: unknown): TenantSystemPrompts {
58
+ if (typeof value !== "object" || value === null) {
59
+ throw new ConsoleDomainError("invalid_request", 400, "Body must be an object");
60
+ }
61
+ const body = value as {
62
+ global?: unknown;
63
+ providers?: unknown;
64
+ models?: unknown;
65
+ };
66
+
67
+ const global =
68
+ body.global === null || body.global === undefined
69
+ ? null
70
+ : validateSetting(body.global, "global");
71
+
72
+ if (body.providers !== undefined && typeof body.providers !== "object") {
73
+ throw new ConsoleDomainError("invalid_request", 400, "providers must be an object");
74
+ }
75
+ const rawProviders = (body.providers ?? {}) as Record<string, unknown>;
76
+ const providerIds = Object.keys(rawProviders);
77
+ if (providerIds.length > SYSTEM_PROMPT_PROVIDER_LIMIT) {
78
+ throw new ConsoleDomainError(
79
+ "invalid_request",
80
+ 400,
81
+ `providers exceeds the ${SYSTEM_PROMPT_PROVIDER_LIMIT} entry limit`,
82
+ );
83
+ }
84
+ const providers: Record<string, ProviderPromptEntry> = {};
85
+ for (const providerId of providerIds) {
86
+ const entry = rawProviders[providerId];
87
+ if (typeof entry !== "object" || entry === null) {
88
+ throw new ConsoleDomainError("invalid_request", 400, `provider ${providerId} entry must be an object`);
89
+ }
90
+ const candidate = entry as Partial<ProviderPromptEntry>;
91
+ if (typeof candidate.followGlobal !== "boolean") {
92
+ throw new ConsoleDomainError("invalid_request", 400, `provider ${providerId} needs followGlobal`);
93
+ }
94
+ if (candidate.followGlobal) {
95
+ providers[providerId] = { followGlobal: true };
96
+ } else {
97
+ providers[providerId] = {
98
+ followGlobal: false,
99
+ setting: validateSetting(candidate.setting, `provider ${providerId}`),
100
+ };
101
+ }
102
+ }
103
+
104
+ if (body.models !== undefined && typeof body.models !== "object") {
105
+ throw new ConsoleDomainError("invalid_request", 400, "models must be an object");
106
+ }
107
+ const rawModels = (body.models ?? {}) as Record<string, unknown>;
108
+ const modelKeys = Object.keys(rawModels);
109
+ if (modelKeys.length > SYSTEM_PROMPT_MODEL_LIMIT) {
110
+ throw new ConsoleDomainError(
111
+ "invalid_request",
112
+ 400,
113
+ `models exceeds the ${SYSTEM_PROMPT_MODEL_LIMIT} entry limit`,
114
+ );
115
+ }
116
+ const models: Record<string, PromptSetting> = {};
117
+ for (const key of modelKeys) {
118
+ if (parseSystemPromptModelKey(key) === null) {
119
+ throw new ConsoleDomainError("invalid_request", 400, `model key ${key} is malformed`);
120
+ }
121
+ models[key] = validateSetting(rawModels[key], `model ${key}`);
122
+ }
123
+
124
+ return { global, providers, models };
125
+ }
126
+
127
+ /**
128
+ * The return type is inferred rather than annotated as `Elysia`: `.error()`
129
+ * widens the instance type with its handler's response envelope, so the
130
+ * annotated base type no longer matches the chained result (same reason the
131
+ * remote-support routes leave it inferred).
132
+ */
133
+ export function createSystemPromptRoutes(config: SystemPromptRoutesConfig) {
134
+ return new Elysia()
135
+ .error(consoleErrorHandler("System prompt settings failed"))
136
+ .get("/system-prompt", async ({ request }) => {
137
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:read");
138
+ return await readTenantSystemPrompts(config.db, access.tenantId);
139
+ })
140
+ .put(
141
+ "/system-prompt",
142
+ { body: t.Any() },
143
+ async ({ request, body }) => {
144
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:write");
145
+ const next = validateBody(body);
146
+ await writeTenantSystemPrompts(config.db, access.tenantId, next);
147
+ await config.auditSink?.record({
148
+ access,
149
+ action: "system_prompt.update",
150
+ target: "tenant_system_prompts",
151
+ detail: {
152
+ global: next.global !== null,
153
+ providers: Object.keys(next.providers).length,
154
+ models: Object.keys(next.models).length,
155
+ },
156
+ });
157
+ return next;
158
+ },
159
+ )
160
+ .delete("/system-prompt", async ({ request }) => {
161
+ const access = requireTenantScope(config.accessResolver(request), "dashboard:write");
162
+ await clearTenantSystemPrompts(config.db, access.tenantId);
163
+ return EMPTY_TENANT_SYSTEM_PROMPTS;
164
+ });
165
+ }
@@ -1,4 +1,4 @@
1
- import { Elysia } from "elysia";
1
+ import { Elysia, type AnyElysia } from "elysia";
2
2
  import { spawn } from "node:child_process";
3
3
  import { consoleErrorHandler, requireTenantScope } from "../shared/errors";
4
4
  import { consoleSseResponse, createConsoleSseStream } from "../observability/sse";
@@ -7,7 +7,10 @@ import type { ConsoleAccessResolver } from "../auth/access";
7
7
  import { fetchLatestRelease, type LatestRelease } from "./repository-updates";
8
8
  import { isNewerRelease } from "./version-compare";
9
9
 
10
- const MEMORY_SAMPLE_INTERVAL_MS = 10_000;
10
+ // Memory is sampled per visible tab: ten seconds per stream made a console
11
+ // with many open tabs wake up constantly for numbers nobody is reading. 30 s
12
+ // is still responsive for a memory warning while cutting that work by three.
13
+ const MEMORY_SAMPLE_INTERVAL_MS = 30_000;
11
14
  const UPDATE_SAMPLE_INTERVAL_MS = 60_000;
12
15
  const HIGH_MEMORY_PERCENT = 85;
13
16
  const HIGH_MEMORY_CLEAR_PERCENT = 80;
@@ -94,7 +97,7 @@ export function createUpdateRoutes(config: {
94
97
  readonly accessResolver: ConsoleAccessResolver;
95
98
  readonly observabilityStore: ObservabilityStore;
96
99
  readonly fetchRelease?: () => Promise<LatestRelease | null>;
97
- }): Elysia {
100
+ }): AnyElysia {
98
101
  const fetchRelease = config.fetchRelease ?? fetchLatestRelease;
99
102
  return new Elysia()
100
103
  .error(consoleErrorHandler("Update check failed"))
@@ -199,5 +202,5 @@ export function createUpdateRoutes(config: {
199
202
  };
200
203
  }),
201
204
  );
202
- }) as unknown as Elysia;
205
+ });
203
206
  }
package/src/main.ts CHANGED
@@ -169,6 +169,17 @@ if (boot) {
169
169
  globalThis.__cartethyiaSignalsRegistered = true;
170
170
  process.on("SIGINT", () => shutdown("SIGINT"));
171
171
  process.on("SIGTERM", () => shutdown("SIGTERM"));
172
+ // A rejected promise nothing awaited (a sweep, a cache warm, a read whose
173
+ // parameters a bug elsewhere ballooned) must not kill the listener: the
174
+ // default is fatal exit, which turned one query fault into a systemd
175
+ // restart loop. Logged and absorbed — the failure stays visible, the
176
+ // process stays up.
177
+ process.on("unhandledRejection", (reason) => {
178
+ log.error(
179
+ "[process] unhandled rejection (kept alive)",
180
+ reason instanceof Error ? reason : new Error(String(reason)),
181
+ );
182
+ });
172
183
  // An in-place update signals SIGUSR2 before swapping the image so the old
173
184
  // process drains with the `update` reason and its callers are told the
174
185
  // replacement is seconds away instead of seeing a generic shutdown. Not
@@ -1,7 +1,7 @@
1
1
  // Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
2
2
  import { GatewayError } from "../protocol/gateway-error";
3
- import { Agent as HttpAgent } from "node:http";
4
- import { Agent as HttpsAgent } from "node:https";
3
+ import { request as httpRequest, Agent as HttpAgent } from "node:http";
4
+ import { request as httpsRequest, Agent as HttpsAgent } from "node:https";
5
5
  import { Agent as UndiciAgent, fetch as undiciFetch, type UndiciRequestInit } from "undici";
6
6
  import { SocksProxyAgent } from "socks-proxy-agent";
7
7
  import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
@@ -167,6 +167,66 @@ async function dial(
167
167
  });
168
168
  }
169
169
 
170
+ /**
171
+ * One raw GET/HEAD dial that carries a body.
172
+ *
173
+ * The runtime-native fetch (`Bun`/undici) rejects `GET`/`HEAD` requests with a
174
+ * body — "request with GET/HEAD method cannot have body" — but some upstreams
175
+ * validate a signature over the body on a GET (Qoder's `/model/list` does).
176
+ * This is the Node `http`/`https` path for exactly that case. It runs only
177
+ * after the SSRF resolve/validate pass and only when no pool/relay agent owns
178
+ * the dial, so the security boundary is unchanged.
179
+ */
180
+ function nodeDial(url: URL, init: RequestInit): Promise<Response> {
181
+ return new Promise((resolve, reject) => {
182
+ const mod = url.protocol === "http:" ? httpRequest : httpsRequest;
183
+ const headers: Record<string, string> = {};
184
+ {
185
+ const h = new Headers(init.headers);
186
+ h.forEach((value, key) => {
187
+ headers[key] = value;
188
+ });
189
+ }
190
+ let payload: Buffer | undefined;
191
+ const body = init.body;
192
+ if (typeof body === "string") {
193
+ payload = Buffer.from(body, "utf8");
194
+ } else if (Buffer.isBuffer(body)) {
195
+ payload = body;
196
+ } else if (body instanceof Uint8Array) {
197
+ payload = Buffer.from(body);
198
+ } else if (body !== null && body !== undefined) {
199
+ reject(new TypeError("nodeDial only supports string/Buffer/Uint8Array bodies"));
200
+ return;
201
+ }
202
+ if (payload !== undefined) headers["content-length"] = String(payload.byteLength);
203
+ const req = mod(
204
+ url,
205
+ {
206
+ method: (init.method ?? "GET").toUpperCase(),
207
+ headers,
208
+ ...(init.signal === undefined ? {} : { signal: init.signal as AbortSignal }),
209
+ },
210
+ (res) => {
211
+ const chunks: Buffer[] = [];
212
+ res.on("data", (c: Buffer) => chunks.push(c));
213
+ res.on("end", () => {
214
+ resolve(
215
+ new Response(Buffer.concat(chunks), {
216
+ status: res.statusCode ?? 200,
217
+ ...(res.statusMessage === undefined ? {} : { statusText: res.statusMessage }),
218
+ headers: res.headers as unknown as HeadersInit,
219
+ }),
220
+ );
221
+ });
222
+ },
223
+ );
224
+ req.on("error", reject);
225
+ if (payload !== undefined) req.write(payload);
226
+ req.end();
227
+ });
228
+ }
229
+
170
230
  export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
171
231
  const policy = options.policy ?? {};
172
232
  const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
@@ -242,7 +302,18 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
242
302
  // serverless front door refuses (400).
243
303
  response = await dial(relay.url, relay.init, fetchFn, undefined);
244
304
  } else {
245
- response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
305
+ const method = (requestInit.method ?? "GET").toUpperCase();
306
+ const hasBody = requestInit.body !== undefined && requestInit.body !== null;
307
+ // The runtime fetch cannot carry a body on GET/HEAD, but Qoder's
308
+ // model-list endpoint requires a signed GET body. When no pool/relay
309
+ // owns the dial and no injected test fetch is present, dial through the
310
+ // Node http(s) client (post SSRF validation). Every other case keeps
311
+ // the existing fetch path.
312
+ if ((method === "GET" || method === "HEAD") && hasBody && !socksAgent && options.fetchFn === undefined) {
313
+ response = await nodeDial(url, requestInit);
314
+ } else {
315
+ response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
316
+ }
246
317
  }
247
318
  if (response.status < 300 || response.status >= 400) {
248
319
  trackNetworkCall(url.hostname, performance.now() - startedAt);
@@ -264,7 +335,7 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
264
335
  if (isCrossOriginRedirect && (response.status === 307 || response.status === 308)) {
265
336
  const method = (requestInit.method ?? "GET").toUpperCase();
266
337
  if (method !== "GET" && method !== "HEAD" && requestInit.body !== undefined) {
267
- throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked");
338
+ throw new GatewayError("invalid_request", 400, "cross-origin redirect with body blocked (request {requestId}). Check the shape of what you sent — then resend 🪞");
268
339
  }
269
340
  }
270
341
  url = nextUrl;