cartethyia 2.7.5 → 2.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/.env.example +4 -1
  2. package/README.md +1 -1
  3. package/dist/dashboard/assets/BannedPage-B1N603cM.js +1 -0
  4. package/dist/dashboard/assets/BannedPage-B1N603cM.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js +3 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-9q4_4NoK.js.gz +0 -0
  7. package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js +1 -0
  8. package/dist/dashboard/assets/CliToolsPage-DwshGtK-.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-D762MIkp.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-D762MIkp.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-D5rol2j2.js → ConsoleLogPage-KRYjHZsX.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-KRYjHZsX.js.gz +0 -0
  13. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js +1 -0
  14. package/dist/dashboard/assets/CustomizationPage-DXsxk5uM.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BEjAjD4F.js.gz +0 -0
  17. package/dist/dashboard/assets/{InjectSystemPromptPanel-B5nZvmf0.js → InjectSystemPromptPanel-d5v3K_Yc.js} +245 -2
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-d5v3K_Yc.js.gz +0 -0
  19. package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js +1 -0
  20. package/dist/dashboard/assets/LoginPage-Ccv4tjNS.js.gz +0 -0
  21. package/dist/dashboard/assets/{ModelPicker-DnQPSIA8.js → ModelPicker-C6WlpU5E.js} +1 -1
  22. package/dist/dashboard/assets/ModelPicker-C6WlpU5E.js.gz +0 -0
  23. package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js +1 -0
  24. package/dist/dashboard/assets/OverviewPage-DN9fYNWA.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js +5 -0
  26. package/dist/dashboard/assets/ProviderDetailPage-BY0ieUgz.js.gz +0 -0
  27. package/dist/dashboard/assets/ProviderIcon-jjqXJDA3.js +1 -0
  28. package/dist/dashboard/assets/ProviderIcon-jjqXJDA3.js.gz +0 -0
  29. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js +1 -0
  30. package/dist/dashboard/assets/ProvidersPage-DNsbfea2.js.gz +0 -0
  31. package/dist/dashboard/assets/{ProxyPage-5SnUSy3j.js → ProxyPage-DxjvUzSD.js} +1 -1
  32. package/dist/dashboard/assets/ProxyPage-DxjvUzSD.js.gz +0 -0
  33. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js +1 -0
  34. package/dist/dashboard/assets/QuotaPage-OaHScjpX.js.gz +0 -0
  35. package/dist/dashboard/assets/{SettingsPage-BJtRZ4qD.js → SettingsPage-BW4AKj2H.js} +1 -1
  36. package/dist/dashboard/assets/SettingsPage-BW4AKj2H.js.gz +0 -0
  37. package/dist/dashboard/assets/SetupPage-44BZrxFC.js +1 -0
  38. package/dist/dashboard/assets/SetupPage-44BZrxFC.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-BfAb5-o_.js → StudioPage-DRPavCdU.js} +4 -4
  40. package/dist/dashboard/assets/StudioPage-DRPavCdU.js.gz +0 -0
  41. package/dist/dashboard/assets/ToolIcon-CfeLA-Z_.js +1 -0
  42. package/dist/dashboard/assets/ToolIcon-CfeLA-Z_.js.gz +0 -0
  43. package/dist/dashboard/assets/{UsagePage-BNzeZ2Vk.js → UsagePage-B7M7LvMM.js} +13 -13
  44. package/dist/dashboard/assets/UsagePage-B7M7LvMM.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-KvX83mRe.js +1 -0
  46. package/dist/dashboard/assets/api-keys-KvX83mRe.js.gz +0 -0
  47. package/dist/dashboard/assets/app-DRnQLhsc.js +9 -0
  48. package/dist/dashboard/assets/app-DRnQLhsc.js.gz +0 -0
  49. package/dist/dashboard/assets/app-mweRcsKp.css +2 -0
  50. package/dist/dashboard/assets/app-mweRcsKp.css.gz +0 -0
  51. package/dist/dashboard/assets/{assert-C0K-c4eo.js → assert-CidQVoNo.js} +1 -1
  52. package/dist/dashboard/assets/assert-CidQVoNo.js.gz +0 -0
  53. package/dist/dashboard/assets/{badge-DfbhQU4k.js → badge-BaX2GTrn.js} +1 -1
  54. package/dist/dashboard/assets/badge-BaX2GTrn.js.gz +0 -0
  55. package/dist/dashboard/assets/{banner-BllT4LcF.js → banner-DFH4Bkte.js} +1 -1
  56. package/dist/dashboard/assets/banner-DFH4Bkte.js.gz +0 -0
  57. package/dist/dashboard/assets/{card-CtkX66Wd.js → card-CxDw0lK6.js} +1 -1
  58. package/dist/dashboard/assets/card-CxDw0lK6.js.gz +0 -0
  59. package/dist/dashboard/assets/circle-question-mark-Cz4un8sH.js +1 -0
  60. package/dist/dashboard/assets/circle-question-mark-Cz4un8sH.js.gz +0 -0
  61. package/dist/dashboard/assets/{cli-tools-CQXVMn4P.js → cli-tools-Bwv5-zGZ.js} +1 -1
  62. package/dist/dashboard/assets/cli-tools-Bwv5-zGZ.js.gz +0 -0
  63. package/dist/dashboard/assets/{clipboard-button-BQqBQd2n.js → clipboard-button-DfS1ntiq.js} +1 -1
  64. package/dist/dashboard/assets/clipboard-button-DfS1ntiq.js.gz +0 -0
  65. package/dist/dashboard/assets/dialog-BK-NRr5u.js +1 -0
  66. package/dist/dashboard/assets/dialog-BK-NRr5u.js.gz +0 -0
  67. package/dist/dashboard/assets/{drawer-BrTEZ8zu.js → drawer-QqPul6sC.js} +1 -1
  68. package/dist/dashboard/assets/drawer-QqPul6sC.js.gz +0 -0
  69. package/dist/dashboard/assets/entry-BMlYJVFj.js +1 -0
  70. package/dist/dashboard/assets/entry-BMlYJVFj.js.gz +0 -0
  71. package/dist/dashboard/assets/entry-D6WvojeD.js +4 -0
  72. package/dist/dashboard/assets/entry-D6WvojeD.js.gz +0 -0
  73. package/dist/dashboard/assets/entry-Dmx7gBiN.js +1 -0
  74. package/dist/dashboard/assets/entry-Dmx7gBiN.js.gz +0 -0
  75. package/dist/dashboard/assets/{formatters-BAACA727.js → formatters-u7JPB5BX.js} +1 -1
  76. package/dist/dashboard/assets/formatters-u7JPB5BX.js.gz +0 -0
  77. package/dist/dashboard/assets/house-h9AZlJuT.js +1 -0
  78. package/dist/dashboard/assets/house-h9AZlJuT.js.gz +0 -0
  79. package/dist/dashboard/assets/{index-FQlj0HUT.js → index-Ko4dLRnV.js} +2 -2
  80. package/dist/dashboard/assets/index-Ko4dLRnV.js.gz +0 -0
  81. package/dist/dashboard/assets/{inline-C4gU5iOL.js → inline-CHJQwxLa.js} +1 -1
  82. package/dist/dashboard/assets/inline-CHJQwxLa.js.gz +0 -0
  83. package/dist/dashboard/assets/{input-BHHX_Tmm.js → input-BlmlizVV.js} +1 -1
  84. package/dist/dashboard/assets/input-BlmlizVV.js.gz +0 -0
  85. package/dist/dashboard/assets/{layout-CajrYUnD.js → layout-Bz93I-yE.js} +1 -1
  86. package/dist/dashboard/assets/layout-Bz93I-yE.js.gz +0 -0
  87. package/dist/dashboard/assets/{list-row-Bx2I7XUe.js → list-row-Ml16KCfu.js} +1 -1
  88. package/dist/dashboard/assets/list-row-Ml16KCfu.js.gz +0 -0
  89. package/dist/dashboard/assets/{live-DNNqRbj2.js → live-7_QpnYGu.js} +1 -1
  90. package/dist/dashboard/assets/live-7_QpnYGu.js.gz +0 -0
  91. package/dist/dashboard/assets/{mutation-Rr0TIvcj.js → mutation-GU7XdEjz.js} +1 -1
  92. package/dist/dashboard/assets/mutation-GU7XdEjz.js.gz +0 -0
  93. package/dist/dashboard/assets/{network-C5UBACrJ.js → network-CuMDaTzT.js} +1 -1
  94. package/dist/dashboard/assets/network-CuMDaTzT.js.gz +0 -0
  95. package/dist/dashboard/assets/numbers-CoqpaDMx.js +2 -0
  96. package/dist/dashboard/assets/numbers-CoqpaDMx.js.gz +0 -0
  97. package/dist/dashboard/assets/{pagination-Xsi9Qx04.js → pagination-B5aOoweZ.js} +1 -1
  98. package/dist/dashboard/assets/pagination-B5aOoweZ.js.gz +0 -0
  99. package/dist/dashboard/assets/providers/zai.webp +0 -0
  100. package/dist/dashboard/assets/providers/zcode.webp +0 -0
  101. package/dist/dashboard/assets/{query-keys-BloKtaXJ.js → query-keys-YSoQOUh6.js} +1 -1
  102. package/dist/dashboard/assets/{query-keys-BloKtaXJ.js.gz → query-keys-YSoQOUh6.js.gz} +0 -0
  103. package/dist/dashboard/assets/quota-DJ1QlAOq.js +1 -0
  104. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  105. package/dist/dashboard/assets/{routing-in0f_IfK.js → routing-fnCsxgNO.js} +1 -1
  106. package/dist/dashboard/assets/routing-fnCsxgNO.js.gz +0 -0
  107. package/dist/dashboard/assets/{select-BLULNqTp.js → select-BHqpd-bg.js} +2 -2
  108. package/dist/dashboard/assets/select-BHqpd-bg.js.gz +0 -0
  109. package/dist/dashboard/assets/{settings-CcGczj6I.js → settings-BxslsErn.js} +1 -1
  110. package/dist/dashboard/assets/settings-BxslsErn.js.gz +0 -0
  111. package/dist/dashboard/assets/{stack-D2lIR0tg.js → stack-j7cXjgYX.js} +1 -1
  112. package/dist/dashboard/assets/stack-j7cXjgYX.js.gz +0 -0
  113. package/dist/dashboard/assets/{state-DTARjfiW.js → state-DHmEnLa2.js} +1 -1
  114. package/dist/dashboard/assets/state-DHmEnLa2.js.gz +0 -0
  115. package/dist/dashboard/assets/{switch-c0oPSnqW.js → switch-DmZcyTIC.js} +1 -1
  116. package/dist/dashboard/assets/switch-DmZcyTIC.js.gz +0 -0
  117. package/dist/dashboard/assets/{useMutation-m8WGNPrz.js → useMutation-CbCP4w1o.js} +1 -1
  118. package/dist/dashboard/assets/useMutation-CbCP4w1o.js.gz +0 -0
  119. package/dist/dashboard/index.html +2 -2
  120. package/dist/dashboard/index.html.gz +0 -0
  121. package/dist/dashboard/precompressed-assets.json +57 -57
  122. package/migrations/full-postgres/0042_add_telemetry_degenerate_stop.sql +14 -0
  123. package/migrations/lite-sqlite3/0042_add_telemetry_degenerate_stop.sql +12 -0
  124. package/package.json +1 -1
  125. package/src/boot/dependencies.ts +10 -2
  126. package/src/config/providers.ts +13 -7
  127. package/src/config/spec.ts +18 -11
  128. package/src/config/usage.ts +27 -0
  129. package/src/console/api-keys/routes.ts +19 -23
  130. package/src/console/cli-tools/contracts.ts +39 -295
  131. package/src/console/cli-tools/fs-ops.ts +29 -0
  132. package/src/console/cli-tools/injectors/claude.ts +3 -3
  133. package/src/console/cli-tools/injectors/codex.ts +6 -6
  134. package/src/console/cli-tools/injectors/driver.ts +8 -105
  135. package/src/console/cli-tools/injectors/kilo.ts +146 -21
  136. package/src/console/cli-tools/injectors/opencode.ts +28 -5
  137. package/src/console/cli-tools/routes.ts +6 -0
  138. package/src/console/cli-tools/service.ts +7 -6
  139. package/src/console/observability/contracts.ts +6 -0
  140. package/src/console/observability/store.ts +3 -0
  141. package/src/console/share/share-stats.ts +7 -9
  142. package/src/observability/event-log.ts +7 -0
  143. package/src/observability/event-query.ts +2 -0
  144. package/src/observability/telemetry-buffer.ts +7 -0
  145. package/src/persistence/schema.ts +8 -0
  146. package/src/persistence/stores/api-key-store.ts +19 -1
  147. package/src/protocol/dsml.ts +5 -76
  148. package/src/providers/adapters/compatible-adapter.ts +29 -1
  149. package/src/providers/default-registry.ts +12 -2
  150. package/src/providers/integrations/antigravity/antigravity-protocol.ts +88 -14
  151. package/src/providers/integrations/antigravity/antigravity-quota.ts +18 -6
  152. package/src/providers/integrations/antigravity/antigravity.ts +20 -11
  153. package/src/providers/integrations/cursor/cursor.ts +2 -3
  154. package/src/providers/integrations/github/github-discovery.ts +15 -2
  155. package/src/providers/integrations/github/github-oauth.ts +178 -9
  156. package/src/providers/integrations/github/github.ts +95 -14
  157. package/src/providers/integrations/kiro/kiro.ts +1 -2
  158. package/src/providers/integrations/qoder/qoder-catalog.ts +9 -3
  159. package/src/providers/integrations/qoder/qoder.ts +147 -88
  160. package/src/providers/integrations/trae/trae.ts +2 -2
  161. package/src/providers/operations/account-error-classification.ts +67 -17
  162. package/src/providers/operations/byok-wire-profile.ts +19 -15
  163. package/src/providers/provider-usage.ts +15 -49
  164. package/src/security/admission/service.ts +11 -4
  165. package/src/security/api-key-auth.ts +5 -3
  166. package/src/transport/dispatch/attempt-finalize.ts +69 -21
  167. package/src/transport/dispatch/attempt-loop.ts +22 -0
  168. package/src/transport/failure-policy.ts +15 -1
  169. package/src/transport/guards/error-lifecycle.ts +4 -0
  170. package/src/transport/request/state.ts +6 -0
  171. package/src/transport/routing/router.ts +7 -0
  172. package/src/transport/surface/reasoning-summary.ts +9 -14
  173. package/src/transport/vision-handoff.ts +83 -28
  174. package/dist/dashboard/assets/BannedPage-Cj15GsMp.js +0 -1
  175. package/dist/dashboard/assets/BannedPage-Cj15GsMp.js.gz +0 -0
  176. package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js +0 -1
  177. package/dist/dashboard/assets/ClaimGiftsButton-D83pBJ6J.js.gz +0 -0
  178. package/dist/dashboard/assets/CliToolDetailPage-DZKmVuJN.js +0 -1
  179. package/dist/dashboard/assets/CliToolDetailPage-DZKmVuJN.js.gz +0 -0
  180. package/dist/dashboard/assets/CliToolsPage-BIccZmeL.js +0 -1
  181. package/dist/dashboard/assets/CliToolsPage-BIccZmeL.js.gz +0 -0
  182. package/dist/dashboard/assets/CombosPage-DEEKkYD-.js +0 -1
  183. package/dist/dashboard/assets/CombosPage-DEEKkYD-.js.gz +0 -0
  184. package/dist/dashboard/assets/ConsoleLogPage-D5rol2j2.js.gz +0 -0
  185. package/dist/dashboard/assets/CustomizationPage-BKj_r0rp.js +0 -1
  186. package/dist/dashboard/assets/CustomizationPage-BKj_r0rp.js.gz +0 -0
  187. package/dist/dashboard/assets/InjectSystemPromptPanel-B5nZvmf0.js.gz +0 -0
  188. package/dist/dashboard/assets/LoginPage-DkXb6dx-.js +0 -1
  189. package/dist/dashboard/assets/LoginPage-DkXb6dx-.js.gz +0 -0
  190. package/dist/dashboard/assets/ModelPicker-DnQPSIA8.js.gz +0 -0
  191. package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js +0 -1
  192. package/dist/dashboard/assets/OverviewPage-Ci2wQKUz.js.gz +0 -0
  193. package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js +0 -5
  194. package/dist/dashboard/assets/ProviderDetailPage-odlgYrOg.js.gz +0 -0
  195. package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js +0 -1
  196. package/dist/dashboard/assets/ProviderIcon-EfeloBn4.js.gz +0 -0
  197. package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js +0 -1
  198. package/dist/dashboard/assets/ProvidersPage-CqFBQEB8.js.gz +0 -0
  199. package/dist/dashboard/assets/ProxyPage-5SnUSy3j.js.gz +0 -0
  200. package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js +0 -1
  201. package/dist/dashboard/assets/QuotaPage-CmnMDzhP.js.gz +0 -0
  202. package/dist/dashboard/assets/SettingsPage-BJtRZ4qD.js.gz +0 -0
  203. package/dist/dashboard/assets/SetupPage-WTa0sktC.js +0 -1
  204. package/dist/dashboard/assets/SetupPage-WTa0sktC.js.gz +0 -0
  205. package/dist/dashboard/assets/StudioPage-BfAb5-o_.js.gz +0 -0
  206. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js +0 -1
  207. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  208. package/dist/dashboard/assets/UsagePage-BNzeZ2Vk.js.gz +0 -0
  209. package/dist/dashboard/assets/api-keys-l29JROPv.js +0 -1
  210. package/dist/dashboard/assets/api-keys-l29JROPv.js.gz +0 -0
  211. package/dist/dashboard/assets/app--PJWq8_C.css +0 -2
  212. package/dist/dashboard/assets/app--PJWq8_C.css.gz +0 -0
  213. package/dist/dashboard/assets/app-Bgd3zANQ.js +0 -9
  214. package/dist/dashboard/assets/app-Bgd3zANQ.js.gz +0 -0
  215. package/dist/dashboard/assets/assert-C0K-c4eo.js.gz +0 -0
  216. package/dist/dashboard/assets/badge-DfbhQU4k.js.gz +0 -0
  217. package/dist/dashboard/assets/banner-BllT4LcF.js.gz +0 -0
  218. package/dist/dashboard/assets/card-CtkX66Wd.js.gz +0 -0
  219. package/dist/dashboard/assets/cli-tools-CQXVMn4P.js.gz +0 -0
  220. package/dist/dashboard/assets/clipboard-button-BQqBQd2n.js.gz +0 -0
  221. package/dist/dashboard/assets/dialog-BEK7KP0H.js +0 -1
  222. package/dist/dashboard/assets/dialog-BEK7KP0H.js.gz +0 -0
  223. package/dist/dashboard/assets/drawer-BrTEZ8zu.js.gz +0 -0
  224. package/dist/dashboard/assets/entry-CHWxbS56.js +0 -1
  225. package/dist/dashboard/assets/entry-CHWxbS56.js.gz +0 -0
  226. package/dist/dashboard/assets/entry-EBHJ3DrE.js +0 -1
  227. package/dist/dashboard/assets/entry-EBHJ3DrE.js.gz +0 -0
  228. package/dist/dashboard/assets/entry-LCS85hiq.js +0 -4
  229. package/dist/dashboard/assets/entry-LCS85hiq.js.gz +0 -0
  230. package/dist/dashboard/assets/formatters-BAACA727.js.gz +0 -0
  231. package/dist/dashboard/assets/github-badge-YSCE4UC2.js +0 -1
  232. package/dist/dashboard/assets/github-badge-YSCE4UC2.js.gz +0 -0
  233. package/dist/dashboard/assets/index-FQlj0HUT.js.gz +0 -0
  234. package/dist/dashboard/assets/inline-C4gU5iOL.js.gz +0 -0
  235. package/dist/dashboard/assets/input-BHHX_Tmm.js.gz +0 -0
  236. package/dist/dashboard/assets/layout-CajrYUnD.js.gz +0 -0
  237. package/dist/dashboard/assets/list-row-Bx2I7XUe.js.gz +0 -0
  238. package/dist/dashboard/assets/live-DNNqRbj2.js.gz +0 -0
  239. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  240. package/dist/dashboard/assets/network-C5UBACrJ.js.gz +0 -0
  241. package/dist/dashboard/assets/numbers-CUWAZK4J.js +0 -2
  242. package/dist/dashboard/assets/numbers-CUWAZK4J.js.gz +0 -0
  243. package/dist/dashboard/assets/pagination-Xsi9Qx04.js.gz +0 -0
  244. package/dist/dashboard/assets/providers/zai-coding-plan.svg +0 -6
  245. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  246. package/dist/dashboard/assets/quota-DvQbD_sr.js +0 -1
  247. package/dist/dashboard/assets/quota-DvQbD_sr.js.gz +0 -0
  248. package/dist/dashboard/assets/routing-in0f_IfK.js.gz +0 -0
  249. package/dist/dashboard/assets/select-BLULNqTp.js.gz +0 -0
  250. package/dist/dashboard/assets/settings-CcGczj6I.js.gz +0 -0
  251. package/dist/dashboard/assets/stack-D2lIR0tg.js.gz +0 -0
  252. package/dist/dashboard/assets/state-DTARjfiW.js.gz +0 -0
  253. package/dist/dashboard/assets/switch-c0oPSnqW.js.gz +0 -0
  254. package/dist/dashboard/assets/useMutation-m8WGNPrz.js.gz +0 -0
  255. package/src/console/cli-tools/injectors/cline.ts +0 -110
  256. package/src/console/cli-tools/injectors/copilot.ts +0 -108
  257. package/src/console/cli-tools/injectors/cowork.ts +0 -64
  258. package/src/console/cli-tools/injectors/deepseek-tui.ts +0 -85
  259. package/src/console/cli-tools/injectors/droid.ts +0 -96
  260. package/src/console/cli-tools/injectors/grok-build.ts +0 -70
  261. package/src/console/cli-tools/injectors/hermes.ts +0 -101
  262. package/src/console/cli-tools/injectors/jcode.ts +0 -115
  263. package/src/console/cli-tools/injectors/openclaw.ts +0 -161
@@ -998,8 +998,16 @@ export function buildQoderRequest(
998
998
  // When the caller sends no intent but the model needs the push to think
999
999
  // (verified live: Sonus/Cantus stream no reasoning without it), use the
1000
1000
  // roster's default effort.
1001
+ //
1002
+ // Only a reasoning-capable model carries the field. A non-reasoning model
1003
+ // (e.g. `lite`) cannot think, so an effort is meaningless there; sending it
1004
+ // would also assert a capability the model does not advertise. The upstream
1005
+ // tolerates it today, but declaring reasoning on a model that has none is a
1006
+ // latent contract violation, so the field is kept off non-reasoning models.
1001
1007
  const reasoningEffort =
1002
- reasoningEffortFromIntent(request.reasoning) ?? modelConfig.default_reasoning_effort;
1008
+ reasoning
1009
+ ? (reasoningEffortFromIntent(request.reasoning) ?? modelConfig.default_reasoning_effort)
1010
+ : undefined;
1003
1011
  const sessionId = qoderStableHash("qoder-session", auth.userId, modelId);
1004
1012
  const recordId = qoderStableRecordId(modelId, allMessages, maxTokens);
1005
1013
  return {
@@ -1073,6 +1081,17 @@ export function buildQoderRequest(
1073
1081
  const choice = qoderToolChoice(request.tool_choice);
1074
1082
  return choice === undefined ? {} : { tool_choice: choice };
1075
1083
  })(),
1084
+ // Passthrough knobs the IDE surface reads from `parameters` (worker `vyl`):
1085
+ // parallel_tool_calls, tool_choice, top_p, seed, user, response_format, …
1086
+ // Only include what the caller actually set.
1087
+ parameters: (() => {
1088
+ const out: Record<string, unknown> = {};
1089
+ const choice = qoderToolChoice(request.tool_choice);
1090
+ if (choice !== undefined) out["tool_choice"] = choice;
1091
+ const parallel = request.generation_controls.parallel_tool_calls;
1092
+ if (parallel !== undefined) out["parallel_tool_calls"] = parallel;
1093
+ return Object.keys(out).length > 0 ? out : undefined;
1094
+ })(),
1076
1095
  };
1077
1096
  }
1078
1097
 
@@ -1096,11 +1115,21 @@ export function buildQoderIdeRequest(
1096
1115
  ): Record<string, unknown> {
1097
1116
  const legacy = buildQoderRequest(modelId, request, modelConfig, auth, realm);
1098
1117
  const messages = legacy.messages as Array<Record<string, unknown>>;
1118
+ // The IDE surface (`model/v1/chat/completions`) must ship the same tool
1119
+ // declaration as the legacy builder. The unpacked desktop client (worker
1120
+ // `PFn`) injects `tools` directly when present — without it the upstream
1121
+ // sees no toolset and answers with a DSML text block (or claims no tool
1122
+ // exists) instead of a structured `tool_calls`. The remaining knobs
1123
+ // (`parallel_tool_calls`, `tool_choice`, …) ride the `parameters`
1124
+ // passthrough (worker `vyl`), matching the reference exactly.
1125
+ const tools = legacy.tools as Array<Record<string, unknown>> | undefined;
1099
1126
  return {
1100
1127
  model: modelId,
1101
1128
  messages,
1102
1129
  stream: true,
1103
1130
  stream_options: { include_usage: true },
1131
+ ...(tools !== undefined && tools.length > 0 ? { tools } : {}),
1132
+ ...(legacy.parameters === undefined ? {} : { parameters: legacy.parameters }),
1104
1133
  // The same reasoning-effort field the legacy surface uses; without it the
1105
1134
  // IDE surface streams no thinking for models that need the push (Sonus,
1106
1135
  // Cantus). Undefined when the caller asked for none and the roster has no
@@ -1197,93 +1226,90 @@ function qoderIdeEntitlementRefused(response: Response): boolean {
1197
1226
  * normal stop with reasoning only and no output text/tool calls (observed ~1
1198
1227
  * in 4 runs with Sonus). That is not a valid completion — the lab would show a
1199
1228
  * thought with "No text came back". Replay such a stream before committing to
1200
- * it. Buffering the body is required: the decision is only knowable after the
1201
- * stream ends, and the bytes are replayed to the canonical parser.
1229
+ * it. `sniffQoderStream` below inspects just enough of the body to make that
1230
+ * call without buffering the whole (possibly long) stream.
1202
1231
  */
1203
- interface QoderStreamDrain {
1204
- readonly bytes: Uint8Array;
1205
- readonly meaningful: boolean;
1206
- readonly thoughtOnly: boolean;
1207
- }
1208
1232
 
1209
- async function drainQoderResponse(
1233
+ /**
1234
+ * Reads the upstream body just far enough to decide whether it carries real
1235
+ * output, then returns a replay stream that re-emits the already-read bytes
1236
+ * followed by the rest of the original body. If the body ends before any
1237
+ * meaningful output is seen, `meaningful` is false and the caller replays the
1238
+ * whole request (empty / thought-only recovery) without ever buffering a large
1239
+ * response: the original `drainQoderResponse` read the entire stream before
1240
+ * forwarding, which made every stream appear to complete in one burst at the
1241
+ * end.
1242
+ */
1243
+ async function sniffQoderStream(
1210
1244
  response: Response,
1211
1245
  signal: AbortSignal,
1212
- ): Promise<QoderStreamDrain> {
1213
- const chunks: Uint8Array[] = [];
1214
- let total = 0;
1215
- const reader = response.body!.getReader();
1216
- try {
1217
- for (;;) {
1218
- if (signal.aborted) break;
1219
- const { done, value } = await reader.read();
1220
- if (done) break;
1221
- if (value) {
1222
- chunks.push(value);
1223
- total += value.byteLength;
1224
- }
1225
- }
1226
- } finally {
1227
- reader.releaseLock();
1228
- }
1229
- const bytes = new Uint8Array(total);
1230
- let offset = 0;
1231
- for (const chunk of chunks) {
1232
- bytes.set(chunk, offset);
1233
- offset += chunk.byteLength;
1234
- }
1235
-
1236
- let dataEvents = 0;
1237
- let meaningful = false;
1238
- let thoughtOnly = false;
1246
+ ): Promise<{ meaningful: boolean; response: Response }> {
1247
+ const body = response.body;
1248
+ if (body === null) return { meaningful: true, response };
1249
+ const reader = body.getReader();
1239
1250
  const decoder = new TextDecoder();
1240
- const text = decoder.decode(bytes);
1241
- for (const frame of text.split(/\r?\n\r?\n/)) {
1242
- if (frame.trim().length === 0) continue;
1243
- const dataLines: string[] = [];
1244
- for (const line of frame.split(/\r?\n/)) {
1245
- if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
1246
- }
1247
- const data = dataLines.join("\n").trim();
1248
- if (data.length === 0 || data === "[DONE]") continue;
1249
- dataEvents++;
1250
- for (const { data: frameData } of qoderEnvelopeToFrames(data)) {
1251
- if (frameData === "[DONE]") continue;
1252
- let json: Record<string, unknown>;
1253
- try {
1254
- json = JSON.parse(frameData) as Record<string, unknown>;
1255
- } catch {
1256
- continue;
1257
- }
1258
- const choices = json["choices"];
1259
- if (!Array.isArray(choices)) continue;
1260
- for (const choice of choices as Array<Record<string, unknown>>) {
1261
- const delta = choice["delta"] as Record<string, unknown> | undefined;
1262
- if (delta === undefined) continue;
1263
- if (typeof delta["content"] === "string" && (delta["content"] as string).length > 0) {
1264
- meaningful = true;
1265
- return { bytes, meaningful, thoughtOnly };
1266
- }
1267
- if (Array.isArray(delta["tool_calls"]) && (delta["tool_calls"] as unknown[]).length > 0) {
1251
+ const pending: Uint8Array[] = [];
1252
+ let inspected = "";
1253
+ let meaningful = false;
1254
+ for (let i = 0; i < 32 && !meaningful; i += 1) {
1255
+ const { done, value } = await reader.read();
1256
+ if (signal.aborted) break;
1257
+ if (done) break;
1258
+ pending.push(value);
1259
+ inspected += decoder.decode(value, { stream: true });
1260
+ // Peek at completed SSE frames only: a content delta or a tool-call
1261
+ // fragment means real output is coming. Reasoning-only streams are NOT
1262
+ // meaningful (they are exactly what empty-stream recovery replays), and a
1263
+ // finish reason alone is not output either.
1264
+ if (/data:/.test(inspected)) {
1265
+ for (const frame of inspected.split(/\r?\n\r?\n/)) {
1266
+ if (!frame.includes("data:")) continue;
1267
+ if (frame.includes('"content":"') || frame.includes('"tool_calls"')) {
1268
1268
  meaningful = true;
1269
- return { bytes, meaningful, thoughtOnly };
1270
- }
1271
- if (
1272
- typeof delta["reasoning_content"] === "string" &&
1273
- (delta["reasoning_content"] as string).length > 0
1274
- ) {
1275
- thoughtOnly = true;
1269
+ break;
1276
1270
  }
1277
1271
  }
1278
1272
  }
1273
+ if (inspected.length > 65536) {
1274
+ meaningful = true;
1275
+ break;
1276
+ }
1279
1277
  }
1280
- return { bytes, meaningful, thoughtOnly };
1278
+ // Replay: previously-read bytes first, then continue the original body.
1279
+ let offset = 0;
1280
+ const replay = new ReadableStream<Uint8Array>({
1281
+ start(controller) {
1282
+ for (; offset < pending.length; offset += 1) controller.enqueue(pending[offset]);
1283
+ },
1284
+ async pull(controller) {
1285
+ if (offset < pending.length) {
1286
+ controller.enqueue(pending[offset]);
1287
+ offset += 1;
1288
+ return;
1289
+ }
1290
+ const { done, value } = await reader.read();
1291
+ if (signal.aborted) {
1292
+ controller.close();
1293
+ return;
1294
+ }
1295
+ if (done) {
1296
+ controller.close();
1297
+ return;
1298
+ }
1299
+ controller.enqueue(value);
1300
+ },
1301
+ cancel(reason) {
1302
+ return reader.cancel(reason);
1303
+ },
1304
+ });
1305
+ return { meaningful, response: new Response(replay, response) };
1281
1306
  }
1282
1307
 
1283
1308
  /**
1284
1309
  * Runs the IDE request up to `1 + maxReplays` times, returning the first
1285
1310
  * response whose stream carried real output (text or tool calls). A 200 with
1286
- * no output (empty or thought-only) is replayed rather than surfaced.
1311
+ * no output (empty or thought-only) is replayed rather than surfaced. The
1312
+ * stream is forwarded progressively (not buffered to completion).
1287
1313
  */
1288
1314
  async function fetchQoderIdeWithEmptyRecovery(
1289
1315
  requestOnce: () => Promise<Response>,
@@ -1294,12 +1320,9 @@ async function fetchQoderIdeWithEmptyRecovery(
1294
1320
  for (let attempt = 0; attempt <= maxReplays; attempt += 1) {
1295
1321
  const res = await requestOnce();
1296
1322
  if (!res.ok || !res.body) return res;
1297
- const drain = await drainQoderResponse(res, signal);
1298
- last = new Response(drain.bytes as BodyInit, {
1299
- status: 200,
1300
- headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
1301
- });
1302
- if (drain.meaningful) return last;
1323
+ const sniff = await sniffQoderStream(res, signal);
1324
+ last = sniff.response;
1325
+ if (sniff.meaningful) return last;
1303
1326
  }
1304
1327
  return last!;
1305
1328
  }
@@ -1336,6 +1359,9 @@ export async function* qoderBodyToCanonicalEvents(
1336
1359
  // (recovered) or ordinary prose (released).
1337
1360
  let leakBuf = "";
1338
1361
  let leakFrames = 0;
1362
+ // Tool-call argument fragments, keyed by call id, reconciled at end of
1363
+ // stream (see the tool_calls handling in the loop).
1364
+ const toolCallBuffer = new Map<string, { index: number; name: string | undefined; arguments: string }>();
1339
1365
 
1340
1366
  for await (const sse of decodeQoderSseEvents(body, signal)) {
1341
1367
  const sseData = sse.data;
@@ -1492,7 +1518,14 @@ export async function* qoderBodyToCanonicalEvents(
1492
1518
  for (const tc of toolCalls) {
1493
1519
  const fn = tc["function"] as Record<string, unknown> | undefined;
1494
1520
  const index = typeof tc["index"] === "number" ? (tc["index"] as number) : undefined;
1495
- const declaredId = typeof tc["id"] === "string" ? (tc["id"] as string) : undefined;
1521
+ // `id` is only meaningful when non-empty: some Qoder slots echo
1522
+ // `id: ""` on continuation chunks, and treating that as a fresh
1523
+ // call id would orphan every argument fragment (the real call
1524
+ // keeps its first-seen id, the fragments pile up under "").
1525
+ const declaredId =
1526
+ typeof tc["id"] === "string" && (tc["id"] as string).length > 0
1527
+ ? (tc["id"] as string)
1528
+ : undefined;
1496
1529
  const fragName = typeof fn?.["name"] === "string" ? (fn["name"] as string) : undefined;
1497
1530
  // OpenAI continuation chunks re-identify their call by wire
1498
1531
  // `index` alone — the `id` appears only once, in the first chunk.
@@ -1520,14 +1553,27 @@ export async function* qoderBodyToCanonicalEvents(
1520
1553
  } else {
1521
1554
  callId = `call_${String(index ?? 0)}`;
1522
1555
  }
1523
- yield {
1524
- type: "tool_call_delta",
1525
- sequence_number: seq++,
1526
- call_id: callId,
1527
- ...(index !== undefined ? { index } : {}),
1528
- ...(fragName === undefined ? {} : { name: fragName }),
1529
- arguments_delta: (fn?.["arguments"] as string) ?? "",
1530
- } as CanonicalEvent;
1556
+ // Buffer tool-call argument fragments per call id until the end of
1557
+ // stream. Upstream streaming fragments occasionally drop or mangle
1558
+ // a byte mid-arguments; forwarding each fragment as an
1559
+ // `arguments_delta` would hand the client a malformed JSON blob to
1560
+ // stitch. Holding and reconciling here yields one complete,
1561
+ // parseable arguments string per call — flush happens at end of
1562
+ // stream (or when a terminal frame arrives).
1563
+ const existing = toolCallBuffer.get(callId);
1564
+ if (existing) {
1565
+ existing.index = index ?? existing.index;
1566
+ if (fragName !== undefined) existing.name = fragName;
1567
+ if (typeof fn?.["arguments"] === "string") {
1568
+ existing.arguments += (fn["arguments"] as string);
1569
+ }
1570
+ } else {
1571
+ toolCallBuffer.set(callId, {
1572
+ index: index ?? 0,
1573
+ name: fragName,
1574
+ arguments: typeof fn?.["arguments"] === "string" ? (fn["arguments"] as string) : "",
1575
+ });
1576
+ }
1531
1577
  }
1532
1578
  }
1533
1579
  }
@@ -1588,6 +1634,19 @@ export async function* qoderBodyToCanonicalEvents(
1588
1634
  const stopReason = mapQoderFinishReason(finishReason);
1589
1635
  const _providerStop = typeof finishReason === "string" ? finishReason : undefined;
1590
1636
  void hasTerminal;
1637
+ // Flush buffered tool-call fragments as complete, stitched calls — one
1638
+ // `arguments_delta` per call with the full JSON string, so the client never
1639
+ // has to repair upstream frame-loss.
1640
+ for (const [callId, buffered] of toolCallBuffer) {
1641
+ yield {
1642
+ type: "tool_call_delta",
1643
+ sequence_number: seq++,
1644
+ call_id: callId,
1645
+ index: buffered.index,
1646
+ name: buffered.name,
1647
+ arguments_delta: buffered.arguments,
1648
+ } as CanonicalEvent;
1649
+ }
1591
1650
  yield {
1592
1651
  type: "terminal",
1593
1652
  sequence_number: seq++,
@@ -7,7 +7,7 @@
7
7
  import { GatewayError } from "../../../protocol/gateway-error";
8
8
  import type { CanonicalEvent, CanonicalMessage, CanonicalRequest, CanonicalStopReason } from "../../../protocol/canonical-model";
9
9
  import type { UsageRecord } from "../../../protocol/canonical-model";
10
- import { applyCacheMode, usageFromProvider } from "../../provider-usage";
10
+ import { usageFromProvider } from "../../provider-usage";
11
11
  import {
12
12
  readCredentialSecret,
13
13
  assertWireFamily,
@@ -482,7 +482,7 @@ class TraeAdapter implements ProviderAdapter {
482
482
  // prefixes we estimate them locally (see trae-cache.ts). Then the
483
483
  // operator's cache mode decides whether a hit makes the whole input count
484
484
  // as cached.
485
- return applyCacheMode(estimateTraeSessionCache(record, sessionId, messages));
485
+ return estimateTraeSessionCache(record, sessionId, messages);
486
486
  }
487
487
 
488
488
  }
@@ -458,6 +458,26 @@ function banWording(lower: string): boolean {
458
458
  );
459
459
  }
460
460
 
461
+ /**
462
+ * Whether GitHub Copilot stated the account lacks a license — a ban-shaped
463
+ * verdict on the account, not a refreshable auth rejection.
464
+ *
465
+ * Observed live (2026-11-03): the health log showed
466
+ * `unauthorized: not licensed to use Copilot` and `forbidden` on
467
+ * `gpt-6-luna`. Copilot answers 401 for a bad credential and 403 for a
468
+ * license/entitlement refusal, so `forbidden`/`unauthorized` on this surface
469
+ * is the same verdict. No amount of waiting or refreshing changes it — the
470
+ * subscription cannot serve this credential.
471
+ */
472
+ function copilotNotLicensed(lower: string): boolean {
473
+ return (
474
+ lower.includes("not licensed") ||
475
+ lower.includes("forbidden") ||
476
+ lower.includes("unauthorized") ||
477
+ lower.includes("license")
478
+ );
479
+ }
480
+
461
481
  /**
462
482
  * Bounded to a day, matching `MAX_COOLDOWN_MS` in the failure policy: a stated
463
483
  * window longer than that is a monthly or plan-level limit, and parking an
@@ -1263,6 +1283,33 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1263
1283
  true,
1264
1284
  ),
1265
1285
  },
1286
+ {
1287
+ // GitHub Copilot answers a license/entitlement refusal with 403 and
1288
+ // "not licensed to use Copilot" / "forbidden" / "unauthorized". That is a
1289
+ // verdict on the account: the subscription cannot serve this credential,
1290
+ // and no amount of waiting or refreshing changes it. Cooldown cycling
1291
+ // (auth_invalidated + 5m backoff) kept a dead account in rotation where a
1292
+ // client's retry budget burned sibling accounts — the same failure the
1293
+ // buddy ban exists to prevent. Disable immediately. Must run before
1294
+ // `credential-rejected`, which would claim the bare 403 as a refreshable
1295
+ // auth rejection.
1296
+ name: "github-copilot-not-licensed",
1297
+ match: (s) =>
1298
+ s.origin === "upstream" &&
1299
+ s.providerId === "github" &&
1300
+ copilotNotLicensed(s.lower),
1301
+ decide: (s) =>
1302
+ s.result(
1303
+ "policy_blocked_banned",
1304
+ "disabled",
1305
+ 0,
1306
+ null,
1307
+ s.reason("GitHub Copilot account not licensed"),
1308
+ // A license refusal is evidence about the account even at provider
1309
+ // scope, for the same reason a named revocation or a buddy ban is.
1310
+ true,
1311
+ ),
1312
+ },
1266
1313
  {
1267
1314
  name: "credential-rejected",
1268
1315
  match: (s) =>
@@ -1471,7 +1518,7 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1471
1518
  },
1472
1519
  {
1473
1520
  name: "quota-exhausted",
1474
- match: (s) => s.quotaSignal,
1521
+ match: (s) => s.origin === "upstream" && s.quotaSignal,
1475
1522
  decide: (s) => {
1476
1523
  // xAI Grok Build's free tier resets on a rolling window. Use a duration
1477
1524
  // stated by the provider when present; otherwise park for the conservative
@@ -1525,6 +1572,7 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1525
1572
  // moves to the next account and the flagged one returns almost at once.
1526
1573
  name: "inception-per-minute-limit",
1527
1574
  match: (s) =>
1575
+ s.origin === "upstream" &&
1528
1576
  s.providerId === "inception" &&
1529
1577
  (s.statusCode === 429 ||
1530
1578
  s.providerCode === "rate_limit_reached" ||
@@ -1549,15 +1597,16 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1549
1597
  {
1550
1598
  name: "rate-limit",
1551
1599
  match: (s) =>
1552
- s.statusCode === 429 ||
1553
- s.providerCode === "rate_limit_exceeded" ||
1554
- s.lower.includes("rate_limit") ||
1555
- s.lower.includes("rate limit") ||
1556
- s.lower.includes("too many requests") ||
1557
- s.lower.includes("requests per minute") ||
1558
- s.lower.includes("tokens per minute") ||
1559
- s.lower.includes("tpm limit") ||
1560
- s.lower.includes("rpm limit"),
1600
+ s.origin === "upstream" &&
1601
+ (s.statusCode === 429 ||
1602
+ s.providerCode === "rate_limit_exceeded" ||
1603
+ s.lower.includes("rate_limit") ||
1604
+ s.lower.includes("rate limit") ||
1605
+ s.lower.includes("too many requests") ||
1606
+ s.lower.includes("requests per minute") ||
1607
+ s.lower.includes("tokens per minute") ||
1608
+ s.lower.includes("tpm limit") ||
1609
+ s.lower.includes("rpm limit")),
1561
1610
  decide: (s) => {
1562
1611
  const cooldownMs = s.statedCooldownMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
1563
1612
  // Same per-account reasoning as quota above: the failing account (or
@@ -1576,13 +1625,14 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
1576
1625
  {
1577
1626
  name: "model-capacity",
1578
1627
  match: (s) =>
1579
- s.statusCode === 503 ||
1580
- s.statusCode === 529 ||
1581
- s.providerCode === "model_capacity" ||
1582
- s.queuedUpstream ||
1583
- s.lower.includes("model overloaded") ||
1584
- s.lower.includes("capacity exceeded") ||
1585
- s.lower.includes("temporarily unavailable"),
1628
+ s.origin === "upstream" &&
1629
+ (s.statusCode === 503 ||
1630
+ s.statusCode === 529 ||
1631
+ s.providerCode === "model_capacity" ||
1632
+ s.queuedUpstream ||
1633
+ s.lower.includes("model overloaded") ||
1634
+ s.lower.includes("capacity exceeded") ||
1635
+ s.lower.includes("temporarily unavailable")),
1586
1636
  decide: (s) => {
1587
1637
  const cooldownMs = s.statedCooldownMs ?? MODEL_CAPACITY_COOLDOWN_MS();
1588
1638
  const modelScopedMutation = s.origin === "upstream" && s.modelId !== undefined;
@@ -92,12 +92,11 @@ const ENDPOINT_TAIL = /\/(?:v\d+\/)?(?:messages|chat\/completions|responses|comp
92
92
  * The root an operator's pasted URL really meant, plus the endpoint tail that
93
93
  * was removed.
94
94
  *
95
- * The suggestion is the bare **origin**, not the path left behind. For
96
- * `https://api.deepseek.com/anthropic/v1/messages` the tail `/v1/messages` is
97
- * only the last segment; `/anthropic` is part of that same endpoint, and
98
- * suggesting `https://api.deepseek.com/anthropic` would still derive the model
99
- * list at `…/anthropic/models` and 404 — the very failure this corrects. A URL
100
- * that is already a root comes back with an empty `stripped`.
95
+ * Only the **tail** is stripped. Any path before it belongs to the gateway
96
+ * root and survives, so `https://api.longcat.ai/anthropic/v1/messages`
97
+ * resolves to `https://api.longcat.ai/anthropic` (not the bare origin) — the
98
+ * `/anthropic` prefix is where LongCat actually serves the API. A URL that is
99
+ * already a root comes back with an empty `stripped`.
101
100
  */
102
101
  export function splitEndpointBaseUrl(baseUrl: string): { readonly baseUrl: string; readonly stripped: string } {
103
102
  const trimmed = baseUrl.replace(/\/+$/, "");
@@ -110,20 +109,22 @@ export function splitEndpointBaseUrl(baseUrl: string): { readonly baseUrl: strin
110
109
  const basePath = parsed.pathname.replace(/\/+$/, "");
111
110
  const match = ENDPOINT_TAIL.exec(basePath);
112
111
  if (match === null) return { baseUrl: trimmed, stripped: "" };
113
- return { baseUrl: parsed.origin, stripped: basePath };
112
+ const keptPath = basePath.slice(0, match.index);
113
+ return { baseUrl: `${parsed.origin}${keptPath}`, stripped: basePath };
114
114
  }
115
115
 
116
116
  /**
117
117
  * Base URL an operator's model list lives under (`<base>/models`). A bare host
118
118
  * has no version segment, so it gets the `/v1` prefix; a base that already
119
119
  * carries a path (conventionally `/v1`) is used as-is. A pasted endpoint path
120
- * is stripped first, so `…/anthropic/v1/messages` discovers at `<host>/v1/models`
121
- * rather than one level below a per-request path.
120
+ * is stripped first, so `…/anthropic/v1/messages` discovers at
121
+ * `<origin>/anthropic/v1/models` — the gateway prefix before the endpoint tail
122
+ * belongs to the root and is preserved.
122
123
  */
123
124
  export function modelDiscoveryBaseUrl(baseUrl: string): string {
124
- // A pasted endpoint is not a base at all, so its whole path is discarded and
125
- // the list is looked for at `<origin>/v1`. A base that already IS a root —
126
- // bare host, or a version/gateway prefix — keeps that path, because
125
+ // A pasted endpoint has its tail removed; whatever path came before it is a
126
+ // real gateway prefix and stays. A base that already IS a root — bare host,
127
+ // or a version/gateway prefix — keeps that path, because
127
128
  // `https://gateway.example/api/v1` legitimately serves its list there.
128
129
  const trimmed = baseUrl.replace(/\/+$/, "");
129
130
  let parsed: URL;
@@ -133,9 +134,12 @@ export function modelDiscoveryBaseUrl(baseUrl: string): string {
133
134
  return `${trimmed}/v1`;
134
135
  }
135
136
  const basePath = parsed.pathname.replace(/\/+$/, "");
136
- const isPastedEndpoint = ENDPOINT_TAIL.test(basePath);
137
- const listBase = isPastedEndpoint ? parsed.origin : `${parsed.origin}${basePath}`;
138
- if (isPastedEndpoint || basePath === "" || basePath === "/") return `${listBase}/v1`;
137
+ const match = ENDPOINT_TAIL.exec(basePath);
138
+ const listBase =
139
+ match === null
140
+ ? `${parsed.origin}${basePath}`
141
+ : `${parsed.origin}${basePath.slice(0, match.index)}`;
142
+ if (match !== null || basePath === "" || basePath === "/") return `${listBase}/v1`;
139
143
  return listBase;
140
144
  }
141
145
 
@@ -1,4 +1,5 @@
1
1
  import type { UsageRecord } from "../protocol/canonical-model";
2
+ import { resolveUsageInputMode } from "../config/usage";
2
3
  export interface OpenAiUsage {
3
4
  prompt_tokens: number;
4
5
  completion_tokens: number;
@@ -11,7 +12,6 @@ export interface OpenAiUsage {
11
12
  };
12
13
  }
13
14
  import { modelsDevCatalog } from "./discovery/models-dev-catalog";
14
- import { resolveCacheMode } from "../config/providers";
15
15
 
16
16
  /**
17
17
  * Extract cost multipliers from catalog model cost JSONB.
@@ -126,36 +126,6 @@ export function repriceUsage(
126
126
  : { ...usage, estimated_cost: estimatedCost };
127
127
  }
128
128
 
129
- /**
130
- * Simplified cache accounting: when the provider reported a cache hit, the
131
- * whole prompt counts as cached — request input ber-cache = cache-eligible →
132
- * input = cache. The cache-write volume is already inside the all-in input
133
- * total on cache-aware shapes, so it stays separate and is not double-counted
134
- * as a hit. A reported `0` or an absent report stay a cache miss. In `real`
135
- * mode the provider's reported split is kept as-is.
136
- *
137
- * Exported so the hand-built record producers (Cursor, Kiro) apply the same
138
- * rule as `normalizeUsage`.
139
- */
140
- export function applyCacheMode(usage: UsageRecord): UsageRecord {
141
- if (resolveCacheMode() !== "simplified") return usage;
142
- if (
143
- typeof usage.cached_input_tokens === "number" &&
144
- usage.cached_input_tokens > 0 &&
145
- usage.input_tokens > 0
146
- ) {
147
- const writes =
148
- typeof usage.cache_write_tokens === "number" ? Math.max(0, usage.cache_write_tokens) : 0;
149
- const cached = Math.min(usage.input_tokens, Math.max(0, usage.input_tokens - writes));
150
- return {
151
- ...usage,
152
- cached_input_tokens: cached,
153
- uncached_input_tokens: 0,
154
- };
155
- }
156
- return usage;
157
- }
158
-
159
129
  /**
160
130
  * Normalize provider-specific usage data into canonical UsageRecord.
161
131
  * Handles Anthropic (cache_read_input_tokens, cache_creation_input_tokens, output_tokens_details.thinking_tokens)
@@ -266,10 +236,21 @@ export function normalizeUsage(input: Record<string, unknown>): UsageRecord {
266
236
  const reportedTotal = typeof input.total_tokens === "number" ? input.total_tokens : null;
267
237
  const inputAlreadyIncludesCache =
268
238
  reportedTotal !== null && reportedTotal === input_tokens + output_tokens;
269
- const hasAnthropicCacheShape = anthropic_cache_read !== null && !inputAlreadyIncludesCache;
239
+ // `inclusive` (default) folds cache into input so every provider reads alike:
240
+ // IN is always the whole prompt and CACHED its subset. Anthropic's raw shape
241
+ // is fresh-only with cache separate — and its `total_tokens` equals
242
+ // input+output by contract, so the `inputAlreadyIncludesCache` heuristic
243
+ // would otherwise mistake it for already-inclusive and never add the cache,
244
+ // under-reporting prompt by the entire cached span. `as-is` keeps the raw
245
+ // upstream semantics (and the legacy heuristic for already-inclusive bridges).
246
+ const accountInputInclusive = resolveUsageInputMode() === "inclusive";
247
+ const cacheIsSeparate = anthropic_cache_read !== null;
248
+ const shouldFoldCache = accountInputInclusive
249
+ ? cacheIsSeparate
250
+ : cacheIsSeparate && !inputAlreadyIncludesCache;
270
251
  let uncached_input_tokens: number | "unavailable" = "unavailable";
271
252
  if (typeof cached_input_tokens === "number") {
272
- if (hasAnthropicCacheShape) {
253
+ if (shouldFoldCache) {
273
254
  uncached_input_tokens = input_tokens;
274
255
  input_tokens +=
275
256
  cached_input_tokens + (typeof cache_write_tokens === "number" ? cache_write_tokens : 0);
@@ -277,25 +258,10 @@ export function normalizeUsage(input: Record<string, unknown>): UsageRecord {
277
258
  uncached_input_tokens = Math.max(0, input_tokens - Math.min(input_tokens, cached_input_tokens));
278
259
  cached_input_tokens = Math.min(input_tokens, cached_input_tokens);
279
260
  }
280
- } else if (hasAnthropicCacheShape && typeof cache_write_tokens === "number") {
261
+ } else if (shouldFoldCache && typeof cache_write_tokens === "number") {
281
262
  input_tokens += cache_write_tokens;
282
263
  }
283
264
 
284
- // Simplified cache accounting: a reported hit makes the whole prompt count as
285
- // cached — request input ber-cache = cache-eligible → input = cache. The
286
- // cache-write volume is already inside the all-in input total on cache-aware
287
- // shapes, so it stays separate and is not double-counted as a hit. Applied
288
- // before pricing so the estimated cost reads the same simplified split as the
289
- // record.
290
- if (typeof cached_input_tokens === "number" && cached_input_tokens > 0 && input_tokens > 0) {
291
- if (resolveCacheMode() === "simplified") {
292
- const writes =
293
- typeof cache_write_tokens === "number" ? Math.max(0, cache_write_tokens) : 0;
294
- cached_input_tokens = Math.min(input_tokens, Math.max(0, input_tokens - writes));
295
- uncached_input_tokens = 0;
296
- }
297
- }
298
-
299
265
  // Handle reasoning tokens: Anthropic and OpenAI both use output_tokens_details
300
266
  let reasoning_tokens: number | "unavailable" = "unavailable";
301
267
  const details: NonNullable<UsageRecord["details"]> = {};
@@ -164,13 +164,20 @@ export class ApiKeyAdmissionService {
164
164
  const actual = usageTokens(usage);
165
165
  await this.store.reconcile(apiKeyId, estimatedTokens, actual, reservationId);
166
166
  finalized = true;
167
- // Persist against the authenticating key (`snapshot.api_key_id`), not
168
- // the admission identity. Share children share the parent's counter
169
- // namespace for enforcement, but attribution must still land on the
170
- // child row so recipient usage and sumChildrenConsumed stay correct.
167
+ // Persist the spend against BOTH the authenticating key and the family
168
+ // owner. The authenticating key (`snapshot.api_key_id`, the child for
169
+ // a share recipient) carries attribution so `sumChildrenConsumed` and
170
+ // recipient rows stay correct. The family owner (`admission_identity`,
171
+ // the template itself for a share child) is the live accumulator the
172
+ // console reads for total usage — without it the template column never
173
+ // moved and every figure depended on telemetry rows alone.
171
174
  if (this.persistLifetimeUsage) {
175
+ const ownerId = snapshot.admission_identity ?? snapshot.api_key_id;
172
176
  try {
173
177
  await this.persistLifetimeUsage({ apiKeyId: snapshot.api_key_id, delta: actual });
178
+ if (ownerId !== snapshot.api_key_id) {
179
+ await this.persistLifetimeUsage({ apiKeyId: ownerId, delta: actual });
180
+ }
174
181
  } catch {
175
182
  // Non-fatal: the transient counter already reflects reality; a
176
183
  // subsequent request will overwrite the row with the fresh count.