cartethyia 2.5.10 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -7,10 +7,9 @@ import type { UsageRecord } from "../../protocol/canonical-model";
7
7
  import { classifyUpstreamFailure } from "../failure-policy";
8
8
  import type { AdmissionLease } from "../../security/admission/contracts";
9
9
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
10
- import { TelemetryPayloadCapture } from "../../observability/payload-capture";
10
+ import { resolvePayloadCapture, TelemetryPayloadCapture, truncateUtf8 } from "../../observability/payload-capture";
11
11
  import { payloadDirectory } from "../../observability/payload-store";
12
12
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
13
- import type { TelemetryPayloadDepth } from "../../console/settings/contracts";
14
13
  import type { ProxyRequestOutcome, ProxyRequestState } from "../request/state";
15
14
  import type { PayloadCaptureState } from "../../observability/payload-capture";
16
15
  import { finalizeRequestTelemetry } from "../guards/error-lifecycle";
@@ -22,126 +21,7 @@ import {
22
21
  recordPoolDispatchOutcome,
23
22
  } from "../../network/pool-health-machine";
24
23
  import { ProxyConnectRefusalError } from "../../network/pool/agent";
25
- import {
26
- payloadCaptureKeepsBodies,
27
- payloadRetentionMs,
28
- resolvePayloadCapture,
29
- } from "../../observability/payload-capture";
30
-
31
- /**
32
- * True when the tenant keeps response bodies (mode `full`). The dispatch
33
- * path calls this once per attempt to decide whether the provider-response
34
- * tee is worth its clone + bounded read; `none`/`metadata` skip the body.
35
- */
36
- export async function shouldCaptureProviderBody(
37
- db: CartethyiaDatabase,
38
- tenantId: string | null,
39
- ): Promise<boolean> {
40
- return payloadCaptureKeepsBodies(db, tenantId);
41
- }
42
-
43
- /**
44
- * Per-stage cap for the provider request/response tee at one capture depth.
45
- *
46
- * The tee is what the drawer's "Provider Request" and "Provider Response"
47
- * panels read, so its cap is the most a provider panel can ever show. It used
48
- * to be a flat 256 KiB at every depth: on a large request the translated
49
- * provider body was clipped to 256 KiB before the shared record budget ever
50
- * ran, so a tenant who raised the depth to Maximum — 16 MiB — still saw a
51
- * quarter-megabyte provider request and an empty provider response. Derived
52
- * from the same depth tiers as the record budget, so raising the depth now
53
- * raises both.
54
- *
55
- * Capped at a quarter of the depth's total budget: four panels share it, and
56
- * one of them must never be able to spend the whole thing.
57
- */
58
- export function providerCaptureMaxBytes(depth?: TelemetryPayloadDepth): number {
59
- if (depth === "moderate") return 2 * 1024 * 1024;
60
- if (depth === "maximum") return 4 * 1024 * 1024;
61
- return 256 * 1024;
62
- }
63
-
64
- /**
65
- * Same answer as {@link shouldCaptureProviderBody}, memoized on the request.
66
- *
67
- * The preference is a tenant setting, so it cannot change between attempts of
68
- * one request. Resolving it per attempt meant a request with several attempts
69
- * paid for it repeatedly, and the per-attempt answer could in principle differ
70
- * from the one the terminal capture then acted on. Passing `state` lets every
71
- * attempt of a request share one resolution.
72
- */
73
- export async function shouldCaptureProviderBodyForRequest(
74
- db: CartethyiaDatabase,
75
- tenantId: string | null,
76
- state: { payloadCapturePreference?: unknown },
77
- ): Promise<boolean> {
78
- if (state.payloadCapturePreference === undefined) {
79
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
80
- }
81
- const resolved = state.payloadCapturePreference as
82
- | { readonly ok: true; readonly mode: string }
83
- | { readonly ok: false };
84
- return resolved.ok === true && resolved.mode === "full";
85
- }
86
-
87
- /**
88
- * The tenant's capture depth, memoized on the request like the mode above.
89
- *
90
- * Read *after* {@link shouldCaptureProviderBodyForRequest} has populated the
91
- * memo, so the two answers come from one preference read. It sizes the
92
- * provider tee: a tenant who raised the depth is asking for more of every
93
- * panel, and a flat cap would answer with the same clipped body regardless.
94
- */
95
- export async function resolveCaptureDepthForRequest(
96
- db: CartethyiaDatabase,
97
- tenantId: string | null,
98
- state: { payloadCapturePreference?: unknown },
99
- ): Promise<TelemetryPayloadDepth> {
100
- if (state.payloadCapturePreference === undefined) {
101
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
102
- }
103
- const resolved = state.payloadCapturePreference as
104
- | { readonly ok: true; readonly depth: TelemetryPayloadDepth }
105
- | { readonly ok: false };
106
- return resolved.ok === true ? resolved.depth : "minimum";
107
- }
108
-
109
- /**
110
- * The tenant's chosen payload retention, memoized on the request like the mode
111
- * and depth above — and resolved *after* one of them has populated the memo, so
112
- * all three come from one preference read.
113
- *
114
- * Falls back to the deployment default when the read failed, which is the same
115
- * answer the capture path reaches on its own; returning it here keeps the
116
- * caller from having to know the fallback exists.
117
- */
118
- export async function resolveCaptureRetentionForRequest(
119
- db: CartethyiaDatabase,
120
- tenantId: string | null,
121
- state: { payloadCapturePreference?: unknown },
122
- ): Promise<number> {
123
- if (state.payloadCapturePreference === undefined) {
124
- state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
125
- }
126
- const resolved = state.payloadCapturePreference as
127
- | { readonly ok: true; readonly retentionMs: number }
128
- | { readonly ok: false };
129
- return resolved.ok === true ? resolved.retentionMs : payloadRetentionMs();
130
- }
131
-
132
- /**
133
- * Combined body cap for one capture depth. Fixed size tiers: every depth
134
- * stores all four drawer panels (client request, translated provider
135
- * request, raw provider response, final client response) — only the cap
136
- * changes, so raising the depth never changes *what* is visible, only how
137
- * much of large bodies survives. Moderate and maximum spike CPU/memory while
138
- * active; minimum is light enough to leave on.
139
- */
140
- function captureDepthMaxBytes(depth: TelemetryPayloadDepth): number {
141
- if (depth === "moderate") return 8 * 1024 * 1024;
142
- if (depth === "maximum") return 16 * 1024 * 1024;
143
- return 1 * 1024 * 1024;
144
- }
24
+ import { captureDepthMaxBytes, providerCaptureMaxBytes } from "./capture-policy";
145
25
 
146
26
  /**
147
27
  * What the client received for a failed terminal attempt, in the same public
@@ -186,21 +66,24 @@ async function readBoundedBody(response: Response, maxBytes: number): Promise<st
186
66
  const reader = response.body.getReader();
187
67
  const decoder = new TextDecoder();
188
68
  // Collect decoded chunks and join once: `+=` per chunk reallocates the
189
- // prefix (O(n^2)). Truncation stays at the same decoded-char boundary.
69
+ // prefix (O(n^2)). The budget is UTF-8 **bytes** — the same basis the record
70
+ // budget and the request-line slice use — measured per decoded chunk, with
71
+ // the final chunk trimmed by truncateUtf8 so a code point is never split.
190
72
  const parts: string[] = [];
191
- let total = 0;
73
+ let totalBytes = 0;
192
74
  try {
193
- while (total < maxBytes) {
75
+ while (totalBytes < maxBytes) {
194
76
  const { done, value } = await reader.read();
195
77
  if (done) break;
196
78
  const chunk = decoder.decode(value, { stream: true });
197
- if (total + chunk.length > maxBytes) {
198
- parts.push(chunk.slice(0, maxBytes - total));
199
- total = maxBytes;
79
+ const chunkBytes = Buffer.byteLength(chunk, "utf8");
80
+ if (totalBytes + chunkBytes > maxBytes) {
81
+ parts.push(truncateUtf8(chunk, maxBytes - totalBytes));
82
+ totalBytes = maxBytes;
200
83
  break;
201
84
  }
202
85
  parts.push(chunk);
203
- total += chunk.length;
86
+ totalBytes += chunkBytes;
204
87
  }
205
88
  } catch {
206
89
  // Best-effort capture must never fail the request path.
@@ -339,11 +222,16 @@ export function captureProviderExchange(
339
222
  method: init?.method ?? "GET",
340
223
  url,
341
224
  ...(headers === undefined ? {} : { headers }),
342
- // Capped the same way the response is. A capture is a diagnostic, and an
343
- // unbounded translated request body per attempt — up to `maxAttempts` of
344
- // them, all alive until the loop returns — costs more than it can tell
345
- // an operator.
346
- ...(rawBody === null ? {} : { body: parseCapturedBody(rawBody.slice(0, maxBytes)) }),
225
+ // `captureBody=false` is the `none`/`metadata` path: record that a body
226
+ // was sent and how big it was, without slicing or parsing it. The old
227
+ // shape sliced up to `maxBytes` of the translated body and ran it through
228
+ // `JSON.parse` for every attempt — content a metadata-mode tenant had
229
+ // explicitly declined to retain, paid on every dispatch.
230
+ ...(rawBody === null
231
+ ? {}
232
+ : captureBody
233
+ ? { body: parseCapturedBody(truncateUtf8(rawBody, maxBytes)) }
234
+ : { body_present: true, body_bytes: Buffer.byteLength(rawBody, "utf8") }),
347
235
  };
348
236
  const response = await inner(input, init);
349
237
  sink.response =
@@ -426,7 +314,7 @@ async function captureTerminalPayload(
426
314
  note("captured_metadata");
427
315
  return;
428
316
  }
429
- await new TelemetryPayloadCapture(db).capture({
317
+ const metadataCapture = await new TelemetryPayloadCapture(db).capture({
430
318
  tenantId,
431
319
  requestId,
432
320
  requestBody: null,
@@ -436,13 +324,13 @@ async function captureTerminalPayload(
436
324
  scope: "tenant",
437
325
  tenantOptIn: true,
438
326
  });
439
- note("captured_metadata");
327
+ note(metadataCapture.payloadCaptureState ?? "captured_metadata");
440
328
  return;
441
329
  }
442
330
  const maxBytes = captureDepthMaxBytes(depth);
443
331
  const providerRequest = providerCapture?.request ?? null;
444
332
  const providerResponse = await resolvedProviderResponse(providerCapture);
445
- await new TelemetryPayloadCapture(db).capture({
333
+ const terminalCapture = await new TelemetryPayloadCapture(db).capture({
446
334
  tenantId,
447
335
  requestId,
448
336
  requestBody: requestBody ?? null,
@@ -455,7 +343,7 @@ async function captureTerminalPayload(
455
343
  scope: "tenant",
456
344
  tenantOptIn: true,
457
345
  });
458
- note("captured");
346
+ note(terminalCapture.payloadCaptureState ?? "captured");
459
347
  } catch (error) {
460
348
  note("failed_write");
461
349
  reportCaptureFailure(error);
@@ -33,7 +33,13 @@ import type { NetworkPoolSelector } from "../../network/pool/selector";
33
33
  import type { ProxyRequestState } from "../request/state";
34
34
  import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
35
35
  import { flagPoolBlocked, flagPoolCooldown } from "../../network/pool-health";
36
- import { completeAttempt, errorClientResponseBody, estimatedUsage, type ProviderExchangeCapture } from "./attempt-finalize";
36
+ import {
37
+ completeAttempt,
38
+ errorClientResponseBody,
39
+ estimatedUsage,
40
+ type AttemptCompletion,
41
+ type ProviderExchangeCapture,
42
+ } from "./attempt-finalize";
37
43
  import { repriceUsage } from "../../providers/provider-usage";
38
44
  import { isFreeTierIpBlock, isOAuthCredentialInvalidated, shouldCooldownPool } from "./retry-policy";
39
45
  import { drainAbortReason } from "../shutdown-notice";
@@ -92,6 +98,50 @@ function buildMemberOrdinals(
92
98
  return ordinals;
93
99
  }
94
100
 
101
+ /** Build the common failure payload; the call site supplies only attempt-specific decisions. */
102
+ function failureCompletion(input: {
103
+ readonly state: ProxyRequestState;
104
+ readonly deps: AttemptLoopDeps;
105
+ readonly candidate?: RouteCandidate | undefined;
106
+ readonly status: AttemptCompletion["status"];
107
+ readonly errorCategory?: string;
108
+ readonly errorOrigin?: string;
109
+ readonly error: unknown;
110
+ readonly tenantId: string | null;
111
+ readonly terminal: boolean;
112
+ readonly networkPoolId?: string | undefined;
113
+ readonly lease?: AdmissionLease | undefined;
114
+ readonly commitUsage?: AttemptCompletion["commitUsage"];
115
+ readonly providerCapture?: ProviderExchangeCapture | undefined;
116
+ readonly includeClientResponse: boolean;
117
+ }): AttemptCompletion {
118
+ const { state, deps, candidate } = input;
119
+ return {
120
+ status: input.status,
121
+ providerId: candidate?.provider_id ?? "",
122
+ ...(candidate?.provider_account_id ? { accountId: candidate.provider_account_id } : {}),
123
+ ...(candidate?.provider_account_label ? { accountLabel: candidate.provider_account_label } : {}),
124
+ ...(candidate?.model_id === undefined ? {} : { modelId: candidate.model_id }),
125
+ ...(input.networkPoolId === undefined ? {} : { networkPoolId: input.networkPoolId }),
126
+ ...(input.errorCategory === undefined ? {} : { errorCategory: input.errorCategory }),
127
+ ...(input.errorOrigin === undefined ? {} : { errorOrigin: input.errorOrigin }),
128
+ error: input.error,
129
+ ...(input.lease === undefined ? {} : { lease: input.lease }),
130
+ ...(input.commitUsage === undefined ? {} : { commitUsage: input.commitUsage }),
131
+ terminal: input.terminal,
132
+ tenantId: input.tenantId,
133
+ ingressBody: state.ingressBody,
134
+ responseBody: null,
135
+ ...(input.includeClientResponse && input.status !== "cancelled"
136
+ ? { clientResponseText: errorClientResponseBody(input.error) }
137
+ : {}),
138
+ ...(input.providerCapture === undefined ? {} : { providerCapture: input.providerCapture }),
139
+ db: deps.db,
140
+ ...(deps.telemetryBuffer === undefined ? {} : { telemetryBuffer: deps.telemetryBuffer }),
141
+ ...(deps.snapshotService === undefined ? {} : { snapshotService: deps.snapshotService }),
142
+ };
143
+ }
144
+
95
145
  /** Route-specific preconditions resolved for one candidate before its leases are taken. */
96
146
  interface PreparedAttempt<TAdapter> {
97
147
  readonly credential: ResolvedCredential;
@@ -170,9 +220,14 @@ export async function runAttemptLoop<TResult, TAdapter>(
170
220
  // sink reaches the payload store. When the terminal attempt fails before it
171
221
  // fires an outbound request (lease/acquire/prepare threw, or failover
172
222
  // exhausted), its sink is empty and the drawer reads "—" for a request that
173
- // DID reach an upstream on an earlier attempt. Keep the fired exchanges so
174
- // the terminal completion can fall back to the last one that has a request.
175
- const firedCaptures: ProviderExchangeCapture[] = [];
223
+ // DID reach an upstream on an earlier attempt. Only the LAST fired exchange
224
+ // is ever consulted — every consumer below falls back to the most recent
225
+ // capture that has a request — so this holds one reference, not the whole
226
+ // history: a request walking N attempts used to pin N provider exchanges
227
+ // (each holding its bounded provider tee) alive until the loop returned.
228
+ // Earlier attempts' captures are released as each new attempt fires; the
229
+ // payload store's own bounded writer is what bounds anything persisted.
230
+ let lastFiredCapture: ProviderExchangeCapture | undefined;
176
231
  const maxAttempts = resolveRouteMaxAttempts();
177
232
  // One combo member must not be able to spend the whole budget.
178
233
  //
@@ -222,36 +277,23 @@ export async function runAttemptLoop<TResult, TAdapter>(
222
277
  if (remainingMs < MIN_RETRY_BUDGET_MS) {
223
278
  if (lastError !== undefined && !state.completed) {
224
279
  const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
225
- const lastFired = [...firedCaptures]
226
- .reverse()
227
- .find((capture) => capture.request !== null);
228
- await completeAttempt(state, {
280
+ const lastFired =
281
+ lastFiredCapture?.request !== null && lastFiredCapture !== undefined
282
+ ? lastFiredCapture
283
+ : undefined;
284
+ await completeAttempt(state, failureCompletion({
285
+ state,
286
+ deps,
287
+ candidate,
229
288
  status: terminal.status,
230
- providerId: candidate.provider_id,
231
- ...(candidate.provider_account_id
232
- ? { accountId: candidate.provider_account_id }
233
- : {}),
234
- ...(candidate.provider_account_label
235
- ? { accountLabel: candidate.provider_account_label }
236
- : {}),
237
289
  errorCategory: terminal.errorCategory,
238
290
  errorOrigin: terminal.errorOrigin,
239
- modelId: candidate.model_id,
240
291
  error: lastError,
241
- terminal: true,
242
292
  tenantId: input.tenantId,
243
- ingressBody: state.ingressBody,
244
- responseBody: null,
245
- ...(terminal.status !== "cancelled"
246
- ? { clientResponseText: errorClientResponseBody(lastError) }
247
- : {}),
248
- // Same fallback the terminal catch uses: a request that did fire
249
- // upstream keeps that exchange in Request Detail.
250
- ...(lastFired ? { providerCapture: lastFired } : {}),
251
- db: deps.db,
252
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
253
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
254
- });
293
+ terminal: true,
294
+ providerCapture: lastFired,
295
+ includeClientResponse: true,
296
+ }));
255
297
  metrics.proxy_requests_total.inc(1, {
256
298
  status: terminal.status === "cancelled" ? "cancelled" : "failed",
257
299
  });
@@ -358,7 +400,9 @@ export async function runAttemptLoop<TResult, TAdapter>(
358
400
  cancelled ||
359
401
  !isRetryableFailure(error) ||
360
402
  (index === candidates.length - 1 && !poolScopedFailure);
361
- if (providerCapture.request !== null) firedCaptures.push(providerCapture);
403
+ // Track only the most recent fired exchange; the array this replaced
404
+ // accumulated every attempt's capture until the loop returned.
405
+ if (providerCapture.request !== null) lastFiredCapture = providerCapture;
362
406
  // The payload store only keeps the terminal attempt's exchange. When the
363
407
  // terminal attempt never fired (failover walked past the attempt that
364
408
  // did, or prepare/lease threw first), fall back to the last fired
@@ -367,50 +411,39 @@ export async function runAttemptLoop<TResult, TAdapter>(
367
411
  const terminalCapture =
368
412
  providerCapture.request !== null
369
413
  ? providerCapture
370
- : [...firedCaptures].reverse().find((capture) => capture.request !== null) ??
371
- providerCapture;
372
- await completeAttempt(state, {
414
+ : lastFiredCapture ?? providerCapture;
415
+ await completeAttempt(state, failureCompletion({
416
+ state,
417
+ deps,
418
+ candidate,
373
419
  status: terminal.status,
374
- providerId: candidate.provider_id,
375
- ...(candidate.provider_account_id ? { accountId: candidate.provider_account_id } : {}),
376
- ...(candidate.provider_account_label ? { accountLabel: candidate.provider_account_label } : {}),
377
420
  errorCategory: terminal.errorCategory,
378
421
  errorOrigin: terminal.errorOrigin,
379
- modelId: candidate.model_id,
380
- ...(networkPoolId ? { networkPoolId } : {}),
381
422
  error,
382
- ...(lease ? { lease } : {}),
423
+ tenantId: input.tenantId,
424
+ terminal: terminalAttempt,
425
+ networkPoolId,
426
+ lease,
383
427
  // A failed attempt may have sent bytes upstream, so it is not free —
384
428
  // but it must not be charged the full input estimate plus a full
385
429
  // output reserve either, because a request that walks N candidates
386
430
  // would then leave N estimates on the counters for one client request.
387
431
  // Commit the input estimate only: nothing was generated, and the
388
432
  // `finally` release then refunds the (uncommitted) output reserve.
389
- ...(!cancelled && bindingEstablished
390
- ? {
391
- commitUsage: repriceUsage(
392
- estimatedUsage(leaseSource.estimatedInputTokens, 0),
393
- candidate.provider_id,
394
- candidate.model_id,
395
- ),
396
- }
397
- : {}),
398
- terminal: terminalAttempt,
399
- tenantId: input.tenantId,
400
- ingressBody: state.ingressBody,
401
- responseBody: null,
433
+ commitUsage: !cancelled && bindingEstablished
434
+ ? repriceUsage(
435
+ estimatedUsage(leaseSource.estimatedInputTokens, 0),
436
+ candidate.provider_id,
437
+ candidate.model_id,
438
+ )
439
+ : undefined,
402
440
  // The client still receives the public error envelope on the wire —
403
441
  // store it so Request Detail's Client Response panel traces failures
404
442
  // exactly as the client saw them. Cancelled requests have no client
405
443
  // response (the caller is gone).
406
- ...(terminalAttempt && !cancelled
407
- ? { clientResponseText: errorClientResponseBody(error) }
408
- : {}),
409
444
  providerCapture: terminalCapture,
410
- db: deps.db,
411
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
412
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
413
- });
445
+ includeClientResponse: terminalAttempt,
446
+ }));
414
447
  // A 403 can be a deterministic provider policy rejection, not an
415
448
  // invalid OAuth credential. Only the evidence-based classifier above
416
449
  // may trigger a refresh.
@@ -495,36 +528,25 @@ export async function runAttemptLoop<TResult, TAdapter>(
495
528
  if (!state.completed) {
496
529
  const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
497
530
  const lastCandidate = candidates[Math.min(index, candidates.length - 1)];
498
- const terminalCapture = [...firedCaptures]
499
- .reverse()
500
- .find((capture) => capture.request !== null);
501
- await completeAttempt(state, {
531
+ const terminalCapture =
532
+ lastFiredCapture?.request !== null && lastFiredCapture !== undefined
533
+ ? lastFiredCapture
534
+ : undefined;
535
+ await completeAttempt(state, failureCompletion({
536
+ state,
537
+ deps,
538
+ candidate: lastCandidate,
502
539
  status: terminal.status,
503
- providerId: lastCandidate?.provider_id ?? "",
504
- ...(lastCandidate?.provider_account_id
505
- ? { accountId: lastCandidate.provider_account_id }
506
- : {}),
507
- ...(lastCandidate?.provider_account_label
508
- ? { accountLabel: lastCandidate.provider_account_label }
509
- : {}),
510
540
  errorCategory: terminal.errorCategory,
511
541
  errorOrigin: terminal.errorOrigin,
512
- ...(lastCandidate?.model_id === undefined ? {} : { modelId: lastCandidate.model_id }),
513
542
  error: lastError,
514
543
  terminal: true,
515
544
  tenantId: input.tenantId,
516
- ingressBody: state.ingressBody,
517
- responseBody: null,
518
545
  // The client receives the public error envelope on the wire — store it
519
546
  // so the Client Response panel traces the failure as the client saw it.
520
- ...(terminal.status === "cancelled"
521
- ? {}
522
- : { clientResponseText: errorClientResponseBody(lastError) }),
523
- ...(terminalCapture === undefined ? {} : { providerCapture: terminalCapture }),
524
- db: deps.db,
525
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
526
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
527
- });
547
+ providerCapture: terminalCapture,
548
+ includeClientResponse: true,
549
+ }));
528
550
  }
529
551
  metrics.proxy_requests_total.inc(1, { status: "failed" });
530
552
  throw lastError;
@@ -0,0 +1,78 @@
1
+ // Tenant payload-capture policy resolved once and shared by dispatch attempts.
2
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
3
+ import { payloadRetentionMs, resolvePayloadCapture } from "../../observability/payload-capture";
4
+ import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../../console/settings/contracts";
5
+
6
+ /** Per-stage body cap for the provider request/response tee. */
7
+ export function providerCaptureMaxBytes(depth?: TelemetryPayloadDepth): number {
8
+ if (depth === "moderate") return 2 * 1024 * 1024;
9
+ if (depth === "maximum") return 4 * 1024 * 1024;
10
+ return 1024 * 1024;
11
+ }
12
+
13
+ /** Resolve whether provider bodies should be retained, memoized on this request. */
14
+ export async function shouldCaptureProviderBodyForRequest(
15
+ db: CartethyiaDatabase,
16
+ tenantId: string | null,
17
+ state: { payloadCapturePreference?: unknown },
18
+ ): Promise<boolean> {
19
+ if (state.payloadCapturePreference === undefined) {
20
+ state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
21
+ }
22
+ const resolved = state.payloadCapturePreference as
23
+ | { readonly ok: true; readonly mode: string }
24
+ | { readonly ok: false };
25
+ return resolved.ok === true && resolved.mode === "full";
26
+ }
27
+
28
+ /** Resolve the tenant's capture mode once per request; failed reads fail closed. */
29
+ export async function resolveCaptureModeForRequest(
30
+ db: CartethyiaDatabase,
31
+ tenantId: string | null,
32
+ state: { payloadCapturePreference?: unknown },
33
+ ): Promise<TelemetryPayloadMode> {
34
+ if (state.payloadCapturePreference === undefined) {
35
+ state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
36
+ }
37
+ const resolved = state.payloadCapturePreference as
38
+ | { readonly ok: true; readonly mode: TelemetryPayloadMode }
39
+ | { readonly ok: false };
40
+ return resolved.ok === true ? resolved.mode : "none";
41
+ }
42
+
43
+ /** Resolve the tenant's capture depth once per request. */
44
+ export async function resolveCaptureDepthForRequest(
45
+ db: CartethyiaDatabase,
46
+ tenantId: string | null,
47
+ state: { payloadCapturePreference?: unknown },
48
+ ): Promise<TelemetryPayloadDepth> {
49
+ if (state.payloadCapturePreference === undefined) {
50
+ state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
51
+ }
52
+ const resolved = state.payloadCapturePreference as
53
+ | { readonly ok: true; readonly depth: TelemetryPayloadDepth }
54
+ | { readonly ok: false };
55
+ return resolved.ok === true ? resolved.depth : "minimum";
56
+ }
57
+
58
+ /** Resolve the tenant's payload retention once per request. */
59
+ export async function resolveCaptureRetentionForRequest(
60
+ db: CartethyiaDatabase,
61
+ tenantId: string | null,
62
+ state: { payloadCapturePreference?: unknown },
63
+ ): Promise<number> {
64
+ if (state.payloadCapturePreference === undefined) {
65
+ state.payloadCapturePreference = await resolvePayloadCapture(db, tenantId);
66
+ }
67
+ const resolved = state.payloadCapturePreference as
68
+ | { readonly ok: true; readonly retentionMs: number }
69
+ | { readonly ok: false };
70
+ return resolved.ok === true ? resolved.retentionMs : payloadRetentionMs();
71
+ }
72
+
73
+ /** Combined body cap for the four payload panels at one configured depth. */
74
+ export function captureDepthMaxBytes(depth: TelemetryPayloadDepth): number {
75
+ if (depth === "moderate") return 8 * 1024 * 1024;
76
+ if (depth === "maximum") return 16 * 1024 * 1024;
77
+ return 1 * 1024 * 1024;
78
+ }
@@ -32,11 +32,13 @@ import {
32
32
  completionContext,
33
33
  estimatedUsage,
34
34
  parseCapturedBody,
35
+ terminalFailure,
36
+ } from "./attempt-finalize";
37
+ import {
35
38
  providerCaptureMaxBytes,
36
39
  resolveCaptureDepthForRequest,
37
40
  shouldCaptureProviderBodyForRequest,
38
- terminalFailure,
39
- } from "./attempt-finalize";
41
+ } from "./capture-policy";
40
42
  import { runAttemptLoop } from "./attempt-loop";
41
43
  import { projectForRoute, routeCapabilitiesFor } from "../translation/capabilities";
42
44
  import { dispatchStreamingAttempt } from "./streaming/stream";