cartethyia 2.4.5 → 2.5.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 (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,499 +1,532 @@
1
- import { completeRequiredSchema } from "../../../protocol/codecs";
2
- import type { ModelDefinition } from "../../provider-model-definition";
3
- import type { CanonicalRequest, ToolDefinition } from "../../../protocol/canonical-model";
4
- import { isRecord } from "../../../protocol/codecs";
5
- import type { ProviderDispatchContext } from "../../provider-contracts";
6
- import { PROVIDER_COMPATIBILITY_PROFILES } from "../../provider-metadata";
7
- import { defineModel } from "../../provider-model-definition";
8
- import type { ApiKeyProviderSpec } from "../../configured-provider";
9
- import { buildOpenCodeHeaders } from "./fingerprint";
10
- import { OPENCODE_TOOL_DEFINITIONS } from "./tool-definitions";
11
- /**
12
- * OpenCode's three API-key tiers, all served from `opencode.ai`:
13
- *
14
- * - `opencode` (OpenCode Free): unauthenticated public route — no
15
- * `Authorization` header is ever sent, even if a credential were somehow
16
- * configured. Serves the free-tier model set.
17
- * - `opencode-zen` (OpenCode Zen): billed API-key route.
18
- * - `opencode-go` (OpenCode Go): billed API-key route on the separate
19
- * `/zen/go/v1` base — a bundled, higher-tier model set.
20
- *
21
- * Free and Zen dispatch on `/inference/v1`, NOT `/zen/v1`. The catalog is the
22
- * other way round: `/zen/v1/models` publishes the listing while
23
- * `/inference/v1/models` answers 404. A replay that hit `/inference/v1` directly
24
- * returned 200 while the gateway kept failing, because every model row carried
25
- * an explicit `/zen/v1` endpoint — which outranks the per-provider wire-family
26
- * table at dispatch.
27
- *
28
- * Free and Zen use the OpenCode desktop-client header fingerprint
29
- * (`x-opencode-*` correlation IDs, `user-agent: opencode`). Go is
30
- * intentionally API-key-only.
31
- */
32
- /** Dispatch base for the Free and Zen tiers. */
33
- const ZEN_PATH_PREFIX = "/inference/v1";
34
- const GO_PATH_PREFIX = "/zen/go/v1";
35
-
36
- /**
37
- * Free-tier ids that do not carry the `-free` suffix the rest of the tier uses.
38
- *
39
- * `/zen/v1/models` publishes the whole catalog — 81 ids, of which the billed
40
- * ones (`claude-opus-5`, `gpt-6-astra`, …) are not routable for this provider.
41
- * The tier marker is normally the suffix, but this one id has none, so a
42
- * suffix-only rule would drop a served model. Kept as an explicit list rather
43
- * than a guess from the listing, which states no tier field at all.
44
- *
45
- * One listing id is deliberately not served even though it carries the suffix:
46
- * `deepseek-v4-flash-free` is still advertised but answers 400 "Model is
47
- * unavailable" to a real free-tier dispatch (verified with this adapter's own
48
- * fingerprint headers, stream + agent tools), so it is not a free model this
49
- * provider can route to and is excluded by `isFreeTierZenModel`.
50
- */
51
- const KNOWN_FREE_ZEN_IDS: readonly string[] = ["big-pickle"];
52
-
53
- /** Free-tier ids that the listing still advertises but upstream refuses. */
54
- const UNAVAILABLE_FREE_ZEN_IDS: readonly string[] = [
55
- "deepseek-v4-flash-free",
56
- // Advertised by `/zen/v1/models` and carrying the free-tier suffix, but a
57
- // real free-tier dispatch (this adapter's own fingerprint headers, agent
58
- // tools) answers 400 "Endpoint is unavailable". Keeping it would re-add a row
59
- // that can only fail a probe.
60
- "ling-3.1-flash-free",
61
- // Advertised by `/zen/v1/models` and carrying the free-tier suffix, but
62
- // upstream refuses it for this adapter's free-tier dispatch, so discovery
63
- // must not re-add the row.
64
- "mimo-v2.5-free",
65
- "space-bunny-free",
66
- ];
67
-
68
- /**
69
- * Zen ids served by the native System One decision endpoint, not the chat wire.
70
- *
71
- * These are the `kind: "systemone"` models of the Zen catalog. They carry the
72
- * `-free` suffix convention (or the bare `jev-1.13`), so the free-tier filter
73
- * would otherwise admit them as chat models — and a decision body sent to
74
- * `/zen/v1/chat/completions` answers `500 Internal server error`, while a chat
75
- * body sent to `/zen/v1/systemone` is meaningless. Classifying them here keeps
76
- * them out of the chat surface and onto the native route.
77
- */
78
- const SYSTEMONE_ZEN_IDS: readonly string[] = ["jev-1.13", "jev-1.13-free"];
79
-
80
- /** Whether a Zen id is served by the System One decision endpoint. */
81
- export function isSystemoneZenModel(modelId: string): boolean {
82
- return SYSTEMONE_ZEN_IDS.includes(modelId);
83
- }
84
-
85
- /**
86
- * Whether a `/zen/v1/models` id belongs to the free tier this provider serves.
87
- *
88
- * The listing states no tier field, so the tier is read from the id convention
89
- * the provider itself uses. Two failure directions are handled explicitly: an
90
- * id outside the convention that is still free (`KNOWN_FREE_ZEN_IDS`), and an
91
- * id inside it that upstream no longer serves (`UNAVAILABLE_FREE_ZEN_IDS`) —
92
- * without the latter, "Fetch models" would keep re-adding a row that can only
93
- * fail a probe.
94
- */
95
- export function isFreeTierZenModel(modelId: string): boolean {
96
- if (UNAVAILABLE_FREE_ZEN_IDS.includes(modelId)) return false;
97
- return modelId.endsWith("-free") || KNOWN_FREE_ZEN_IDS.includes(modelId);
98
- }
99
-
100
- /**
101
- * The anonymous credential the free tier accepts.
102
- *
103
- * Captured from a real client dispatch and confirmed live 2026-10-14: without
104
- * it a free-model request is refused with 403 `FreeTierError` even when every
105
- * other field is byte-exact, and with it the same request returns 200. It is a
106
- * sentinel the upstream recognizes, not a secret — but it is a credential
107
- * value, so it lives here rather than in the shared fingerprint.
108
- */
109
- const ANONYMOUS_ZEN_BEARER = "public";
110
-
111
- function opencodeDesktopHeaders(
112
- context: ProviderDispatchContext,
113
- request?: CanonicalRequest,
114
- ): Record<string, string> {
115
- const freeModel = request !== undefined && isFreeTierZenModel(request.model);
116
- return {
117
- ...buildOpenCodeHeaders(
118
- undefined,
119
- context.conversation_affinity,
120
- context.request_headers?.["x-opencode-project"],
121
- ),
122
- // The gate reads the anonymous sentinel: a free dispatch with no
123
- // Authorization is refused regardless of headers, tools, or session shape.
124
- ...(freeModel ? { authorization: `Bearer ${ANONYMOUS_ZEN_BEARER}` } : {}),
125
- // The free-tier gate refuses non-streaming dispatches with 403
126
- // FreeTierError, so free models always advertise SSE regardless of the
127
- // client's own Accept; billed models keep the JSON default.
128
- ...(freeModel ? { accept: "text/event-stream" } : {}),
129
- };
130
- }
131
- /**
132
- * Canonical agent-tool fingerprint the OpenCode free tier requires on every
133
- * dispatch, captured verbatim from a real 2.0.26 client dispatch.
134
- *
135
- * Upstream rejects tool-less or *partially* agent-shaped requests with
136
- * `FreeTierError` (upstream 403). Verified live 2026-10-14: all eleven here
137
- * return 200, and so do ten (`execute` dropped), while a five-tool subset
138
- * (`bash, edit, glob, grep, read`) — and any smaller set — still returns 403.
139
- * The gate reads the tool set, not any single name, so backfilling "the core
140
- * tools" is not enough: backfill the whole set. They are plain
141
- * `ToolDefinition`s — every wire codec serializes them, no per-wire patching.
142
- */
143
- /**
144
- * Every tool the free-tier gate recognises, in the order the client declares
145
- * them, with the client's own description and schema.
146
- *
147
- * The gate compares a recognised tool's definition, not just its name: a stub
148
- * carrying the right name is refused. These are therefore verbatim captures —
149
- * see `./tool-definitions` for provenance.
150
- */
151
- const FREE_AGENT_TOOL_NAMES_ORDERED: readonly string[] = [
152
- "shell",
153
- "glob",
154
- "read",
155
- "grep",
156
- "webfetch",
157
- "websearch",
158
- "write",
159
- "edit",
160
- "subagent",
161
- "execute",
162
- "patch",
163
- "question",
164
- "skill",
165
- ];
166
-
167
- /**
168
- * The agent-tool names the free tier expects, exported so tests assert against
169
- * the same list the adapter sends instead of a copy that can drift.
170
- */
171
- export const FREE_AGENT_TOOL_NAMES: readonly string[] = FREE_AGENT_TOOL_NAMES_ORDERED;
172
- /**
173
- * Fallback schema for a tool name the capture does not carry.
174
- *
175
- * An empty `properties` object is refused: the gate reads the schema, and a
176
- * tool with no parameters at all is not agent-shaped — an otherwise-correct
177
- * dispatch carrying eleven such tools returned 403 `FreeTierError`, while the
178
- * same dispatch with one declared property returned 200 (verified live
179
- * 2026-10-15). Every name in `FREE_AGENT_TOOL_NAMES_ORDERED` has a captured
180
- * definition, so this is a safety net rather than the normal path.
181
- */
182
- const FREE_AGENT_TOOL_SCHEMA = {
183
- type: "object",
184
- properties: {
185
- input: { type: "string", description: "Input for the tool." },
186
- },
187
- required: [] as string[],
188
- } as const;
189
-
190
-
191
- function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
192
- const present: Record<string, true> = {};
193
- for (const tool of request.tools ?? []) present[tool.name] = true;
194
- const missing: ToolDefinition[] = FREE_AGENT_TOOL_NAMES_ORDERED.filter(
195
- (name) => !present[name],
196
- ).map((name) => {
197
- const definition = OPENCODE_TOOL_DEFINITIONS[name];
198
- return {
199
- name,
200
- description: definition?.description ?? name,
201
- jsonSchema: (definition?.parameters ?? FREE_AGENT_TOOL_SCHEMA) as ToolDefinition["jsonSchema"],
202
- };
203
- });
204
- if (missing.length === 0) return request;
205
- return {
206
- ...request,
207
- tools: [...(request.tools ?? []), ...missing],
208
- };
209
- }
210
-
211
-
212
-
213
- function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
214
- if (!Array.isArray(payload.tools)) return;
215
- payload.tools = payload.tools.map((tool) => {
216
- if (!isRecord(tool)) return tool;
217
- const functionValue = tool.function;
218
- if (isRecord(functionValue) && "parameters" in functionValue) {
219
- return {
220
- ...tool,
221
- function: { ...functionValue, parameters: completeRequiredSchema(functionValue.parameters) },
222
- };
223
- }
224
- if ("parameters" in tool) return { ...tool, parameters: completeRequiredSchema(tool.parameters) };
225
- return tool;
226
- });
227
- }
228
-
229
- /**
230
- * The most tool definitions one OpenCode dispatch accepts.
231
- *
232
- * A live dispatch carrying a whole agent catalog was refused with
233
- * `400 invalid_request_error`: `Invalid value for 'tools': maximum number of
234
- * items is 128`. The refusal names the array length, so the clamp counts
235
- * entries, not names.
236
- */
237
- const OPENCODE_MAX_TOOLS = 128;
238
-
239
- /** The function name a serialized tool declares, on either wire shape. */
240
- function wireToolName(tool: unknown): string | undefined {
241
- if (!isRecord(tool)) return undefined;
242
- // Responses puts the name on the tool itself; chat nests it under function.
243
- if (typeof tool["name"] === "string") return tool["name"];
244
- const fn = tool["function"];
245
- if (isRecord(fn) && typeof fn["name"] === "string") return fn["name"];
246
- const custom = tool["custom"];
247
- if (isRecord(custom) && typeof custom["name"] === "string") return custom["name"];
248
- return undefined;
249
- }
250
-
251
- /**
252
- * Drops tool definitions past the upstream's limit.
253
- *
254
- * A definition the conversation already called is kept first: dropping it
255
- * leaves a tool result in the history with no matching definition, which the
256
- * upstream rejects on its own. Everything else keeps its original order and
257
- * fills whatever slots remain. A conversation that already called more than
258
- * the limit cannot be saved by dropping anything, so it is left untouched and
259
- * fails upstream with the real reason instead of a silently broken history.
260
- */
261
- function clampOpenCodeTools(payload: Record<string, unknown>, request: CanonicalRequest): void {
262
- const tools = payload["tools"];
263
- if (!Array.isArray(tools) || tools.length <= OPENCODE_MAX_TOOLS) return;
264
- // Two sets must never be trimmed away, for different reasons:
265
- // - a tool the conversation already called, or its result stays in the
266
- // history with no definition;
267
- // - the free-tier fingerprint tool set, which the gate reads on its own. A
268
- // conversation that arrives carrying its own (larger) agent catalog fills
269
- // the wire first, so a clamp that keeps only called tools drops the
270
- // fingerprint tools and the dispatch is refused with 403 FreeTierError —
271
- // reproduced live on 2026-10-16 from a 128-tool Claude Code session routed
272
- // to a free model.
273
- const protectedNames = new Set<string>(FREE_AGENT_TOOL_NAMES);
274
- const called = new Set<string>();
275
- for (const message of request.messages) {
276
- for (const part of message.content) {
277
- if (part.kind === "toolCall") called.add(part.name);
278
- }
279
- }
280
- const kept: unknown[] = [];
281
- const rest: unknown[] = [];
282
- for (const tool of tools) {
283
- const name = wireToolName(tool);
284
- if (name !== undefined && (called.has(name) || protectedNames.has(name))) kept.push(tool);
285
- else rest.push(tool);
286
- }
287
- if (kept.length > OPENCODE_MAX_TOOLS) return;
288
- payload["tools"] = [...kept, ...rest.slice(0, OPENCODE_MAX_TOOLS - kept.length)];
289
- }
290
- function opencodeSpec(providerId: "opencode" | "opencode-zen" | "opencode-go"): ApiKeyProviderSpec {
291
- // Free-tier gate facts (verified live 2026-10-14 against opencode.ai/zen/v1):
292
- // a request for a free model with stream:false is ALWAYS refused with 403
293
- // FreeTierError — regardless of headers, tools, or session shape — while
294
- // the same body with stream:true passes. The gate keys on the model's free
295
- // status, not the provider row: every tier (free, Zen, Go) carries free
296
- // models, so the workarounds below apply per MODEL via isFreeTierZenModel,
297
- // never per provider id. The response layer aggregates the forced SSE back
298
- // to JSON for non-stream clients.
299
- return {
300
- provider_id: providerId,
301
- endpoint_paths_by_wire_family: {
302
- ...PROVIDER_COMPATIBILITY_PROFILES[providerId]?.endpoint_paths_by_wire_family,
303
- },
304
- chatTokenLimitField: "max_completion_tokens",
305
- // The OpenCode client identity fingerprint (x-opencode-* correlation ids,
306
- // user-agent, request id) is the provider's own request identity: it goes
307
- // on every dispatch of every tier, gated or not — billed traffic included.
308
- buildExtraHeaders: opencodeDesktopHeaders,
309
- prePayload: (payload, request, candidate) => {
310
- completeOpenCodeToolSchemas(payload);
311
- clampOpenCodeTools(payload, request);
312
- if (candidate.wire_family === "responses") {
313
- payload.store = false;
314
- }
315
- if (candidate.wire_family === "chat" && request.stream) {
316
- payload.stream_options = {
317
- ...(typeof payload.stream_options === "object" && payload.stream_options !== null
318
- ? (payload.stream_options as Record<string, unknown>)
319
- : {}),
320
- include_usage: true,
321
- };
322
- }
323
- // Free model: force the upstream stream flag regardless of what the
324
- // caller asked for. The gate refuses stream:false on free models with
325
- // 403 FreeTierError; here it only patches the wire body, never the
326
- // client-visible stream contract.
327
- if (isFreeTierZenModel(candidate.model_id)) payload.stream = true;
328
- },
329
- // OpenCode Free is a genuinely public, unauthenticated endpoint: no
330
- // credential is ever forwarded, whatever the account configuration says.
331
- ...(providerId === "opencode" ? { credential_forwarding: "never" as const } : {}),
332
- // Free models on any tier need the agent-tool fingerprint and the forced
333
- // upstream stream (the gate refuses tool-less or stream:false free
334
- // dispatches with 403 FreeTierError). Billed models dispatch untouched.
335
- prepareRequest: (request) =>
336
- isFreeTierZenModel(request.model) ? ensureFreeAgentRequest(request) : request,
337
- };
338
- }
339
- /** OpenCode Free — unauthenticated public route. No credential is ever forwarded. */
340
- export const OPENCODE_FREE_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode");
341
- /** OpenCode Zen — billed API-key route on `/zen/v1`. */
342
- export const OPENCODE_ZEN_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-zen");
343
- /** OpenCode Go — billed API-key route on the separate `/zen/go/v1` base. */
344
- export const OPENCODE_GO_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-go");
345
-
346
- /**
347
- * The free-tier subset of `/zen/v1/models`, as this provider's own discovery.
348
- *
349
- * The listing is the shared Zen catalog and states no tier: it returns all 81
350
- * ids, most of which are billed and not routable without a credential. This
351
- * provider serves the free tier only, so the listing is filtered to that tier
352
- * and every surviving row is marked `freeTier`, which is what the model list
353
- * groups as "Free models (auto)".
354
- *
355
- * The rows are the fetcher's own definitions, spread rather than rebuilt: it has
356
- * already resolved limits, pricing, and the wire family from the id (a
357
- * Responses-native id lands on `responses`, not pinned to chat), and
358
- * re-deriving that here would discard the resolution and let the two drift.
359
- */
360
- export async function discoverOpenCodeFreeModels(options: {
361
- readonly baseUrl: string;
362
- readonly fetcher?: typeof fetch;
363
- }): Promise<readonly ModelDefinition[] | null> {
364
- const { fetchOpenAICompatibleModels } = await import("../../discovery/openai-model-discovery");
365
- const listed = await fetchOpenAICompatibleModels({
366
- baseUrl: options.baseUrl,
367
- providerId: "opencode",
368
- ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
369
- });
370
- if (listed === null) return null;
371
- return listed
372
- .filter((model) => isFreeTierZenModel(model.modelId))
373
- .map((model) => {
374
- if (!isSystemoneZenModel(model.modelId)) return { ...model, freeTier: true } as const;
375
- // A System One id survives the free-tier filter (it carries the `-free`
376
- // suffix) but is not a chat model: reclassify it onto the native decision
377
- // endpoint so the chat surface never sees it, and carry the endpoint's
378
- // own limits — the Zen listing publishes none, so this row otherwise
379
- // advertised the invented chat window for a decision endpoint.
380
- return {
381
- ...model,
382
- ...systemoneLimits(),
383
- freeTier: true,
384
- serviceKind: "systemone" as const,
385
- endpointPath: `${ZEN_PATH_PREFIX}/systemone`,
386
- };
387
- });
388
- }
389
-
390
- // Limits and pricing resolve from the committed packed catalog
391
- // (`discovery/catalog/`) via `providerId`; the catalog is keyed per provider,
392
- // so passing it is what makes the lookup answer with OpenCode's own entry
393
- // instead of a same-named model from another reseller. The catalog's shared
394
- // OpenCode row understates this model's 1M context limit, so keep the corrected
395
- // limit explicit for both tiers that serve it.
396
- const sharedZenChat: readonly ModelDefinition[] = [
397
- defineModel({ id: "big-pickle", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
398
- defineModel({
399
- id: "mimo-v2.6-flash-free",
400
- providerId: "opencode",
401
- wireFamily: "chat",
402
- endpoint: `${ZEN_PATH_PREFIX}/chat/completions`,
403
- ctx: 1_000_000,
404
- out: 32_000,
405
- vision: true,
406
- document: true,
407
- audio: true,
408
- reasoning: true,
409
- free: true,
410
- }),
411
- ];
412
-
413
- const MUSE_SPARK_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"] as const;
414
-
415
- /**
416
- * Limits for a System One decision model, as a `(context, output)` pair.
417
- *
418
- * Not a chat window: the native route posts the caller's `{state, questions}`
419
- * body untouched. Kept in one place because the discovery path reclassifies a
420
- * fetched System One id onto this endpoint, and the Zen dispatch listing
421
- * publishes no limits at all — without this it inherited the invented chat
422
- * default instead of the row's own values.
423
- */
424
- const SYSTEMONE_LIMITS = { context: 200_000, output: 8_192 } as const;
425
-
426
- /**
427
- * System One decision models served from the Zen `/systemone` endpoint.
428
- *
429
- * Not chat models: a `serviceKind: "systemone"` row is dispatched by the native
430
- * System One route. Both the free and billed Zen tiers front the same endpoint;
431
- * `jev-1.13-free` is the free-tier spelling.
432
- */
433
- const systemoneZen = (id: string, free: boolean): ModelDefinition =>
434
- defineModel({
435
- id,
436
- providerId: "opencode",
437
- serviceKind: "systemone",
438
- wireFamily: "chat",
439
- endpoint: `${ZEN_PATH_PREFIX}/systemone`,
440
- ctx: SYSTEMONE_LIMITS.context,
441
- out: SYSTEMONE_LIMITS.output,
442
- toolCall: false,
443
- ...(free ? { free: true } : {}),
444
- });
445
-
446
- /**
447
- * The System One limits in `ModelDefinition`'s field names, so the discovery
448
- * path can reclassify a fetched id with this row's own values instead of the
449
- * chat default. `defineModel` renames them; a `ModelDefinition` does not.
450
- */
451
- export function systemoneLimits(): Pick<ModelDefinition, "contextLimit" | "outputLimit"> {
452
- return { contextLimit: SYSTEMONE_LIMITS.context, outputLimit: SYSTEMONE_LIMITS.output };
453
- }
454
-
455
- export const OPENCODE_FREE_MODELS: readonly ModelDefinition[] = [
456
- ...sharedZenChat,
457
- // Free-tier chat models the shared Zen listing advertises with the `-free`
458
- // convention. Each was dispatched through this adapter (its own fingerprint
459
- // headers, agent tools) and answered, so the row is routable, not merely
460
- // listed. Limits come from the models.dev snapshot under `opencode`; a row
461
- // the snapshot does not file keeps an explicit limit rather than the generic
462
- // default.
463
- defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
464
- defineModel({ id: "nemotron-3.5-lightning-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
465
- // Absent from the snapshot: keep declared limits rather than the generic default.
466
- defineModel({ id: "longcat-2.5-preview-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, ctx: 1_000_000, out: 131_072, reasoning: true, free: true }),
467
- // Restored 2026-10-17: this id was dropped as deprecated, but it answers
468
- // `200` on the Responses wire with the agent tool set. The 403 it gives
469
- // without tools is the free-tier gate refusing the request *shape*, not the
470
- // model — `muse-spark-1.3-contributor-free` is the one that is genuinely gone
471
- // (`410 ModelDeprecated`), and it stays out.
472
- defineModel({ id: "muse-spark-1.2-contributor-free", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS, free: true }),
473
- systemoneZen("jev-1.13-free", true),
474
- ];
475
-
476
- export const OPENCODE_ZEN_MODELS: readonly ModelDefinition[] = [
477
- ...sharedZenChat,
478
- defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true }),
479
- defineModel({ id: "muse-spark-1.2", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
480
- defineModel({ id: "muse-spark-1.3", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
481
- systemoneZen("jev-1.13", false),
482
- ];
483
-
484
- export const OPENCODE_GO_MODELS: readonly ModelDefinition[] = [
485
- // `muse-spark-1.2` is retired upstream; the catalog files
486
- // `muse-spark-1.2-contributor` at 1048576/131072, which is what this row
487
- // serves.
488
- defineModel({ id: "muse-spark-1.2-contributor", providerId: "opencode-go", wireFamily: "responses", endpoint: `${GO_PATH_PREFIX}/responses`, ctx: 1048576, out: 131072, vision: true, reasoning: true }),
489
- defineModel({ id: "grok-4.7", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 500000, out: 500000, reasoning: true }),
490
- defineModel({ id: "glm-5.2", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
491
- defineModel({ id: "kimi-k3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
492
- defineModel({ id: "kimi-k2.7-code", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
493
- defineModel({ id: "mimo-v2.5-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
494
- defineModel({ id: "qwen3.8-max", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 131072, reasoning: true }),
495
- defineModel({ id: "minimax-m3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
496
- defineModel({ id: "deepseek-v4-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
497
- defineModel({ id: "deepseek-v4.1-flash", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 384000, reasoning: true }),
498
- defineModel({ id: "hy3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
499
- ];
1
+ import { completeRequiredSchema } from "../../../protocol/codecs";
2
+ import type { ModelDefinition } from "../../provider-model-definition";
3
+ import type { CanonicalRequest, ToolDefinition } from "../../../protocol/canonical-model";
4
+ import { isRecord } from "../../../protocol/codecs";
5
+ import type { ProviderDispatchContext } from "../../provider-contracts";
6
+ import { PROVIDER_COMPATIBILITY_PROFILES } from "../../provider-metadata";
7
+ import { defineModel } from "../../provider-model-definition";
8
+ import type { ApiKeyProviderSpec } from "../../configured-provider";
9
+ import { resolveOpenCodeUnavailableFree } from "../../../config/providers";
10
+ import { buildOpenCodeHeaders } from "./fingerprint";
11
+ import { OPENCODE_TOOL_DEFINITIONS } from "./tool-definitions";
12
+ /**
13
+ * OpenCode's three API-key tiers, all served from `opencode.ai`:
14
+ *
15
+ * - `opencode` (OpenCode Free): unauthenticated public route — no
16
+ * `Authorization` header is ever sent, even if a credential were somehow
17
+ * configured. Serves the free-tier model set.
18
+ * - `opencode-zen` (OpenCode Zen): billed API-key route.
19
+ * - `opencode-go` (OpenCode Go): billed API-key route on the separate
20
+ * `/zen/go/v1` base — a bundled, higher-tier model set.
21
+ *
22
+ * Free and Zen dispatch on `/inference/v1`, NOT `/zen/v1`. The catalog is the
23
+ * other way round: `/zen/v1/models` publishes the listing while
24
+ * `/inference/v1/models` answers 404. A replay that hit `/inference/v1` directly
25
+ * returned 200 while the gateway kept failing, because every model row carried
26
+ * an explicit `/zen/v1` endpoint — which outranks the per-provider wire-family
27
+ * table at dispatch.
28
+ *
29
+ * Free and Zen use the OpenCode desktop-client header fingerprint
30
+ * (`x-opencode-*` correlation IDs, `user-agent: opencode`). Go is
31
+ * intentionally API-key-only.
32
+ */
33
+ /** Dispatch base for the Free and Zen tiers. */
34
+ const ZEN_PATH_PREFIX = "/inference/v1";
35
+ const GO_PATH_PREFIX = "/zen/go/v1";
36
+
37
+ /**
38
+ * Free-tier ids that do not carry the `-free` suffix the rest of the tier uses.
39
+ *
40
+ * `/zen/v1/models` publishes the whole catalog — 81 ids, of which the billed
41
+ * ones (`claude-opus-5`, `gpt-6-astra`, …) are not routable for this provider.
42
+ * The tier marker is normally the suffix, but this one id has none, so a
43
+ * suffix-only rule would drop a served model. Kept as an explicit list rather
44
+ * than a guess from the listing, which states no tier field at all.
45
+ *
46
+ * One listing id is deliberately not served even though it carries the suffix:
47
+ * `deepseek-v4-flash-free` is still advertised but answers 400 "Model is
48
+ * unavailable" to a real free-tier dispatch (verified with this adapter's own
49
+ * fingerprint headers, stream + agent tools), so it is not a free model this
50
+ * provider can route to and is excluded by `isFreeTierZenModel`.
51
+ */
52
+ const KNOWN_FREE_ZEN_IDS: readonly string[] = ["big-pickle"];
53
+
54
+ /** Free-tier ids that the listing still advertises but upstream refuses. */
55
+ const UNAVAILABLE_FREE_ZEN_IDS: readonly string[] = [
56
+ "deepseek-v4-flash-free",
57
+ // Advertised by `/zen/v1/models` and carrying the free-tier suffix, but a
58
+ // real free-tier dispatch (this adapter's own fingerprint headers, agent
59
+ // tools) answers 400 "Endpoint is unavailable". Keeping it would re-add a row
60
+ // that can only fail a probe.
61
+ "ling-3.1-flash-free",
62
+ // Advertised by `/zen/v1/models` and carrying the free-tier suffix, but
63
+ // upstream refuses it for this adapter's free-tier dispatch, so discovery
64
+ // must not re-add the row.
65
+ "mimo-v2.5-free",
66
+ "space-bunny-free",
67
+ ];
68
+
69
+ /**
70
+ * The unavailable list, overridable without a code change.
71
+ *
72
+ * These ids are what upstream *currently* refuses, measured 2026-10-14 — and
73
+ * the reference gateway takes the opposite approach entirely: it trusts
74
+ * metadata (`pricing.go` marks a model free when its cost is 0 and it is not
75
+ * deprecated) and hardcodes no ids at all. Ours is more precise while the
76
+ * measurements hold, and worse the moment upstream changes, because a new
77
+ * refusal needs a deploy.
78
+ *
79
+ * `CARTETHYIA_OPENCODE_UNAVAILABLE_FREE` lets an operator correct that
80
+ * immediately: a comma-separated list that *replaces* the bundled one, or the
81
+ * single token `none` to trust the listing completely. The bundled ids remain
82
+ * the default so an unconfigured install behaves exactly as it does today.
83
+ */
84
+ function unavailableFreeZenIds(): readonly string[] {
85
+ const override = resolveOpenCodeUnavailableFree();
86
+ if (override === undefined) return UNAVAILABLE_FREE_ZEN_IDS;
87
+ if (override === "none") return [];
88
+ return override;
89
+ }
90
+
91
+ /**
92
+ * Zen ids served by the native System One decision endpoint, not the chat wire.
93
+ *
94
+ * These are the `kind: "systemone"` models of the Zen catalog. They carry the
95
+ * `-free` suffix convention (or the bare `jev-1.13`), so the free-tier filter
96
+ * would otherwise admit them as chat models — and a decision body sent to
97
+ * `/zen/v1/chat/completions` answers `500 Internal server error`, while a chat
98
+ * body sent to `/zen/v1/systemone` is meaningless. Classifying them here keeps
99
+ * them out of the chat surface and onto the native route.
100
+ */
101
+ const SYSTEMONE_ZEN_IDS: readonly string[] = ["jev-1.13", "jev-1.13-free"];
102
+
103
+ /** Whether a Zen id is served by the System One decision endpoint. */
104
+ export function isSystemoneZenModel(modelId: string): boolean {
105
+ return SYSTEMONE_ZEN_IDS.includes(modelId);
106
+ }
107
+
108
+ /**
109
+ * Whether a `/zen/v1/models` id belongs to the free tier this provider serves.
110
+ *
111
+ * The listing states no tier field, so the tier is read from the id convention
112
+ * the provider itself uses. Two failure directions are handled explicitly: an
113
+ * id outside the convention that is still free (`KNOWN_FREE_ZEN_IDS`), and an
114
+ * id inside it that upstream no longer serves (`UNAVAILABLE_FREE_ZEN_IDS`) —
115
+ * without the latter, "Fetch models" would keep re-adding a row that can only
116
+ * fail a probe.
117
+ */
118
+ export function isFreeTierZenModel(modelId: string): boolean {
119
+ if (unavailableFreeZenIds().includes(modelId)) return false;
120
+ return modelId.endsWith("-free") || KNOWN_FREE_ZEN_IDS.includes(modelId);
121
+ }
122
+
123
+ /**
124
+ * The anonymous credential the free tier accepts.
125
+ *
126
+ * Captured from a real client dispatch and confirmed live 2026-10-14: without
127
+ * it a free-model request is refused with 403 `FreeTierError` even when every
128
+ * other field is byte-exact, and with it the same request returns 200. It is a
129
+ * sentinel the upstream recognizes, not a secret — but it is a credential
130
+ * value, so it lives here rather than in the shared fingerprint.
131
+ */
132
+ const ANONYMOUS_ZEN_BEARER = "public";
133
+
134
+ /**
135
+ * The `Accept` a free-tier dispatch advertises.
136
+ *
137
+ * What OpenCode 2.x's AI SDK puts on the wire, and what the reference gateway
138
+ * sends (`upstream.go:1293`). The gate has only ever been seen accepting the
139
+ * narrower `text/event-stream`, so nothing here fixes a live failure — this is
140
+ * the value a real client produces, which is the only defensible choice when
141
+ * the gate tightens.
142
+ */
143
+ const FREE_TIER_ACCEPT = "application/json, text/event-stream";
144
+
145
+ function opencodeDesktopHeaders(
146
+ context: ProviderDispatchContext,
147
+ request?: CanonicalRequest,
148
+ ): Record<string, string> {
149
+ const freeModel = request !== undefined && isFreeTierZenModel(request.model);
150
+ return {
151
+ ...buildOpenCodeHeaders(
152
+ undefined,
153
+ context.conversation_affinity,
154
+ context.request_headers?.["x-opencode-project"],
155
+ ),
156
+ // The gate reads the anonymous sentinel: a free dispatch with no
157
+ // Authorization is refused regardless of headers, tools, or session shape.
158
+ ...(freeModel ? { authorization: `Bearer ${ANONYMOUS_ZEN_BEARER}` } : {}),
159
+ // The free-tier gate refuses non-streaming dispatches with 403
160
+ // FreeTierError, so free models always advertise SSE regardless of the
161
+ // client's own Accept; billed models keep the JSON default. The value is
162
+ // what OpenCode 2.x's AI SDK sends, not the narrower
163
+ // `text/event-stream` we used to claim — the gate always accepted the
164
+ // narrow one, so this is alignment with the real client rather than a
165
+ // fix for a live failure. It costs nothing: `stream: true` is forced on
166
+ // the body either way, so the upstream answers SSE regardless.
167
+ ...(freeModel ? { accept: FREE_TIER_ACCEPT } : {}),
168
+ };
169
+ }
170
+ /**
171
+ * Canonical agent-tool fingerprint the OpenCode free tier requires on every
172
+ * dispatch, captured verbatim from a real 2.0.26 client dispatch.
173
+ *
174
+ * Upstream rejects tool-less or *partially* agent-shaped requests with
175
+ * `FreeTierError` (upstream 403). Verified live 2026-10-14: the full set here
176
+ * returns 200, and so does one with `execute` dropped, while a five-tool subset
177
+ * (`bash, edit, glob, grep, read`) — and any smaller set — still returns 403.
178
+ * The gate reads the tool set, not any single name, so backfilling "the core
179
+ * tools" is not enough: backfill the whole set. They are plain
180
+ * `ToolDefinition`s — every wire codec serializes them, no per-wire patching.
181
+ */
182
+ /**
183
+ * Every tool the free-tier gate recognises, in the order the client declares
184
+ * them, with the client's own description and schema.
185
+ *
186
+ * The gate compares a recognised tool's definition, not just its name: a stub
187
+ * carrying the right name is refused. These are therefore verbatim captures —
188
+ * see `./tool-definitions` for provenance.
189
+ */
190
+ const FREE_AGENT_TOOL_NAMES_ORDERED: readonly string[] = [
191
+ "shell",
192
+ "glob",
193
+ "read",
194
+ "grep",
195
+ "webfetch",
196
+ "websearch",
197
+ "write",
198
+ "edit",
199
+ "subagent",
200
+ "execute",
201
+ "patch",
202
+ "question",
203
+ "skill",
204
+ ];
205
+
206
+ /**
207
+ * The agent-tool names the free tier expects, exported so tests assert against
208
+ * the same list the adapter sends instead of a copy that can drift.
209
+ */
210
+ export const FREE_AGENT_TOOL_NAMES: readonly string[] = FREE_AGENT_TOOL_NAMES_ORDERED;
211
+ /**
212
+ * Fallback schema for a tool name the capture does not carry.
213
+ *
214
+ * An empty `properties` object is refused: the gate reads the schema, and a
215
+ * tool with no parameters at all is not agent-shaped — an otherwise-correct
216
+ * dispatch carrying eleven such tools returned 403 `FreeTierError`, while the
217
+ * same dispatch with one declared property returned 200 (verified live
218
+ * 2026-10-15). Every name in `FREE_AGENT_TOOL_NAMES_ORDERED` has a captured
219
+ * definition, so this is a safety net rather than the normal path.
220
+ */
221
+ const FREE_AGENT_TOOL_SCHEMA = {
222
+ type: "object",
223
+ properties: {
224
+ input: { type: "string", description: "Input for the tool." },
225
+ },
226
+ required: [] as string[],
227
+ } as const;
228
+
229
+
230
+ function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
231
+ const present: Record<string, true> = {};
232
+ for (const tool of request.tools ?? []) present[tool.name] = true;
233
+ const missing: ToolDefinition[] = FREE_AGENT_TOOL_NAMES_ORDERED.filter(
234
+ (name) => !present[name],
235
+ ).map((name) => {
236
+ const definition = OPENCODE_TOOL_DEFINITIONS[name];
237
+ return {
238
+ name,
239
+ description: definition?.description ?? name,
240
+ jsonSchema: (definition?.parameters ?? FREE_AGENT_TOOL_SCHEMA) as ToolDefinition["jsonSchema"],
241
+ };
242
+ });
243
+ if (missing.length === 0) return request;
244
+ return {
245
+ ...request,
246
+ tools: [...(request.tools ?? []), ...missing],
247
+ };
248
+ }
249
+
250
+
251
+
252
+ function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
253
+ if (!Array.isArray(payload.tools)) return;
254
+ payload.tools = payload.tools.map((tool) => {
255
+ if (!isRecord(tool)) return tool;
256
+ const functionValue = tool.function;
257
+ if (isRecord(functionValue) && "parameters" in functionValue) {
258
+ return {
259
+ ...tool,
260
+ function: { ...functionValue, parameters: completeRequiredSchema(functionValue.parameters) },
261
+ };
262
+ }
263
+ if ("parameters" in tool) return { ...tool, parameters: completeRequiredSchema(tool.parameters) };
264
+ return tool;
265
+ });
266
+ }
267
+
268
+ /**
269
+ * The most tool definitions one OpenCode dispatch accepts.
270
+ *
271
+ * A live dispatch carrying a whole agent catalog was refused with
272
+ * `400 invalid_request_error`: `Invalid value for 'tools': maximum number of
273
+ * items is 128`. The refusal names the array length, so the clamp counts
274
+ * entries, not names.
275
+ */
276
+ const OPENCODE_MAX_TOOLS = 128;
277
+
278
+ /** The function name a serialized tool declares, on either wire shape. */
279
+ function wireToolName(tool: unknown): string | undefined {
280
+ if (!isRecord(tool)) return undefined;
281
+ // Responses puts the name on the tool itself; chat nests it under function.
282
+ if (typeof tool["name"] === "string") return tool["name"];
283
+ const fn = tool["function"];
284
+ if (isRecord(fn) && typeof fn["name"] === "string") return fn["name"];
285
+ const custom = tool["custom"];
286
+ if (isRecord(custom) && typeof custom["name"] === "string") return custom["name"];
287
+ return undefined;
288
+ }
289
+
290
+ /**
291
+ * Drops tool definitions past the upstream's limit.
292
+ *
293
+ * A definition the conversation already called is kept first: dropping it
294
+ * leaves a tool result in the history with no matching definition, which the
295
+ * upstream rejects on its own. Everything else keeps its original order and
296
+ * fills whatever slots remain. A conversation that already called more than
297
+ * the limit cannot be saved by dropping anything, so it is left untouched and
298
+ * fails upstream with the real reason instead of a silently broken history.
299
+ */
300
+ function clampOpenCodeTools(payload: Record<string, unknown>, request: CanonicalRequest): void {
301
+ const tools = payload["tools"];
302
+ if (!Array.isArray(tools) || tools.length <= OPENCODE_MAX_TOOLS) return;
303
+ // Two sets must never be trimmed away, for different reasons:
304
+ // - a tool the conversation already called, or its result stays in the
305
+ // history with no definition;
306
+ // - the free-tier fingerprint tool set, which the gate reads on its own. A
307
+ // conversation that arrives carrying its own (larger) agent catalog fills
308
+ // the wire first, so a clamp that keeps only called tools drops the
309
+ // fingerprint tools and the dispatch is refused with 403 FreeTierError —
310
+ // reproduced live on 2026-10-16 from a 128-tool Claude Code session routed
311
+ // to a free model.
312
+ const protectedNames = new Set<string>(FREE_AGENT_TOOL_NAMES);
313
+ const called = new Set<string>();
314
+ for (const message of request.messages) {
315
+ for (const part of message.content) {
316
+ if (part.kind === "toolCall") called.add(part.name);
317
+ }
318
+ }
319
+ const kept: unknown[] = [];
320
+ const rest: unknown[] = [];
321
+ for (const tool of tools) {
322
+ const name = wireToolName(tool);
323
+ if (name !== undefined && (called.has(name) || protectedNames.has(name))) kept.push(tool);
324
+ else rest.push(tool);
325
+ }
326
+ if (kept.length > OPENCODE_MAX_TOOLS) return;
327
+ payload["tools"] = [...kept, ...rest.slice(0, OPENCODE_MAX_TOOLS - kept.length)];
328
+ }
329
+ function opencodeSpec(providerId: "opencode" | "opencode-zen" | "opencode-go"): ApiKeyProviderSpec {
330
+ // Free-tier gate facts (verified live 2026-10-14 against opencode.ai/zen/v1):
331
+ // a request for a free model with stream:false is ALWAYS refused with 403
332
+ // FreeTierError — regardless of headers, tools, or session shape — while
333
+ // the same body with stream:true passes. The gate keys on the model's free
334
+ // status, not the provider row: every tier (free, Zen, Go) carries free
335
+ // models, so the workarounds below apply per MODEL via isFreeTierZenModel,
336
+ // never per provider id. The response layer aggregates the forced SSE back
337
+ // to JSON for non-stream clients.
338
+ return {
339
+ provider_id: providerId,
340
+ endpoint_paths_by_wire_family: {
341
+ ...PROVIDER_COMPATIBILITY_PROFILES[providerId]?.endpoint_paths_by_wire_family,
342
+ },
343
+ chatTokenLimitField: "max_completion_tokens",
344
+ // The OpenCode client identity fingerprint (x-opencode-* correlation ids,
345
+ // user-agent, request id) is the provider's own request identity: it goes
346
+ // on every dispatch of every tier, gated or not — billed traffic included.
347
+ buildExtraHeaders: opencodeDesktopHeaders,
348
+ prePayload: (payload, request, candidate) => {
349
+ completeOpenCodeToolSchemas(payload);
350
+ clampOpenCodeTools(payload, request);
351
+ if (candidate.wire_family === "responses") {
352
+ payload.store = false;
353
+ }
354
+ if (candidate.wire_family === "chat" && request.stream) {
355
+ payload.stream_options = {
356
+ ...(typeof payload.stream_options === "object" && payload.stream_options !== null
357
+ ? (payload.stream_options as Record<string, unknown>)
358
+ : {}),
359
+ include_usage: true,
360
+ };
361
+ }
362
+ // Free model: force the upstream stream flag regardless of what the
363
+ // caller asked for. The gate refuses stream:false on free models with
364
+ // 403 FreeTierError; here it only patches the wire body, never the
365
+ // client-visible stream contract.
366
+ if (isFreeTierZenModel(candidate.model_id)) payload.stream = true;
367
+ },
368
+ // OpenCode Free is a genuinely public, unauthenticated endpoint: no
369
+ // credential is ever forwarded, whatever the account configuration says.
370
+ ...(providerId === "opencode" ? { credential_forwarding: "never" as const } : {}),
371
+ // Free models on any tier need the agent-tool fingerprint and the forced
372
+ // upstream stream (the gate refuses tool-less or stream:false free
373
+ // dispatches with 403 FreeTierError). Billed models dispatch untouched.
374
+ prepareRequest: (request) =>
375
+ isFreeTierZenModel(request.model) ? ensureFreeAgentRequest(request) : request,
376
+ };
377
+ }
378
+ /** OpenCode Free — unauthenticated public route. No credential is ever forwarded. */
379
+ export const OPENCODE_FREE_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode");
380
+ /** OpenCode Zen — billed API-key route on `/zen/v1`. */
381
+ export const OPENCODE_ZEN_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-zen");
382
+ /** OpenCode Go — billed API-key route on the separate `/zen/go/v1` base. */
383
+ export const OPENCODE_GO_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-go");
384
+
385
+ /**
386
+ * The free-tier subset of `/zen/v1/models`, as this provider's own discovery.
387
+ *
388
+ * The listing is the shared Zen catalog and states no tier: it returns all 81
389
+ * ids, most of which are billed and not routable without a credential. This
390
+ * provider serves the free tier only, so the listing is filtered to that tier
391
+ * and every surviving row is marked `freeTier`, which is what the model list
392
+ * groups as "Free models (auto)".
393
+ *
394
+ * The rows are the fetcher's own definitions, spread rather than rebuilt: it has
395
+ * already resolved limits, pricing, and the wire family from the id (a
396
+ * Responses-native id lands on `responses`, not pinned to chat), and
397
+ * re-deriving that here would discard the resolution and let the two drift.
398
+ */
399
+ export async function discoverOpenCodeFreeModels(options: {
400
+ readonly baseUrl: string;
401
+ readonly fetcher?: typeof fetch;
402
+ }): Promise<readonly ModelDefinition[] | null> {
403
+ const { fetchOpenAICompatibleModels } = await import("../../discovery/openai-model-discovery");
404
+ const listed = await fetchOpenAICompatibleModels({
405
+ baseUrl: options.baseUrl,
406
+ providerId: "opencode",
407
+ ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
408
+ });
409
+ if (listed === null) return null;
410
+ return listed
411
+ .filter((model) => isFreeTierZenModel(model.modelId))
412
+ .map((model) => {
413
+ if (!isSystemoneZenModel(model.modelId)) return { ...model, freeTier: true } as const;
414
+ // A System One id survives the free-tier filter (it carries the `-free`
415
+ // suffix) but is not a chat model: reclassify it onto the native decision
416
+ // endpoint so the chat surface never sees it, and carry the endpoint's
417
+ // own limits — the Zen listing publishes none, so this row otherwise
418
+ // advertised the invented chat window for a decision endpoint.
419
+ return {
420
+ ...model,
421
+ ...systemoneLimits(),
422
+ freeTier: true,
423
+ serviceKind: "systemone" as const,
424
+ endpointPath: `${ZEN_PATH_PREFIX}/systemone`,
425
+ };
426
+ });
427
+ }
428
+
429
+ // Limits and pricing resolve from the committed packed catalog
430
+ // (`discovery/catalog/`) via `providerId`; the catalog is keyed per provider,
431
+ // so passing it is what makes the lookup answer with OpenCode's own entry
432
+ // instead of a same-named model from another reseller. The catalog's shared
433
+ // OpenCode row understates this model's 1M context limit, so keep the corrected
434
+ // limit explicit for both tiers that serve it.
435
+ const sharedZenChat: readonly ModelDefinition[] = [
436
+ defineModel({ id: "big-pickle", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
437
+ defineModel({
438
+ id: "mimo-v2.6-flash-free",
439
+ providerId: "opencode",
440
+ wireFamily: "chat",
441
+ endpoint: `${ZEN_PATH_PREFIX}/chat/completions`,
442
+ ctx: 1_000_000,
443
+ out: 32_000,
444
+ vision: true,
445
+ document: true,
446
+ audio: true,
447
+ reasoning: true,
448
+ free: true,
449
+ }),
450
+ ];
451
+
452
+ const MUSE_SPARK_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"] as const;
453
+
454
+ /**
455
+ * Limits for a System One decision model, as a `(context, output)` pair.
456
+ *
457
+ * Not a chat window: the native route posts the caller's `{state, questions}`
458
+ * body untouched. Kept in one place because the discovery path reclassifies a
459
+ * fetched System One id onto this endpoint, and the Zen dispatch listing
460
+ * publishes no limits at all — without this it inherited the invented chat
461
+ * default instead of the row's own values.
462
+ */
463
+ const SYSTEMONE_LIMITS = { context: 200_000, output: 8_192 } as const;
464
+
465
+ /**
466
+ * System One decision models served from the Zen `/systemone` endpoint.
467
+ *
468
+ * Not chat models: a `serviceKind: "systemone"` row is dispatched by the native
469
+ * System One route. Both the free and billed Zen tiers front the same endpoint;
470
+ * `jev-1.13-free` is the free-tier spelling.
471
+ */
472
+ const systemoneZen = (id: string, free: boolean): ModelDefinition =>
473
+ defineModel({
474
+ id,
475
+ providerId: "opencode",
476
+ serviceKind: "systemone",
477
+ wireFamily: "chat",
478
+ endpoint: `${ZEN_PATH_PREFIX}/systemone`,
479
+ ctx: SYSTEMONE_LIMITS.context,
480
+ out: SYSTEMONE_LIMITS.output,
481
+ toolCall: false,
482
+ ...(free ? { free: true } : {}),
483
+ });
484
+
485
+ /**
486
+ * The System One limits in `ModelDefinition`'s field names, so the discovery
487
+ * path can reclassify a fetched id with this row's own values instead of the
488
+ * chat default. `defineModel` renames them; a `ModelDefinition` does not.
489
+ */
490
+ export function systemoneLimits(): Pick<ModelDefinition, "contextLimit" | "outputLimit"> {
491
+ return { contextLimit: SYSTEMONE_LIMITS.context, outputLimit: SYSTEMONE_LIMITS.output };
492
+ }
493
+
494
+ export const OPENCODE_FREE_MODELS: readonly ModelDefinition[] = [
495
+ ...sharedZenChat,
496
+ // Free-tier chat models the shared Zen listing advertises with the `-free`
497
+ // convention. Each was dispatched through this adapter (its own fingerprint
498
+ // headers, agent tools) and answered, so the row is routable, not merely
499
+ // listed. Limits come from the models.dev snapshot under `opencode`; a row
500
+ // the snapshot does not file keeps an explicit limit rather than the generic
501
+ // default.
502
+ defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
503
+ defineModel({ id: "nemotron-3.5-lightning-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
504
+ // Absent from the snapshot: keep declared limits rather than the generic default.
505
+ defineModel({ id: "longcat-2.5-preview-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, ctx: 1_000_000, out: 131_072, reasoning: true, free: true }),
506
+ systemoneZen("jev-1.13-free", true),
507
+ ];
508
+
509
+ export const OPENCODE_ZEN_MODELS: readonly ModelDefinition[] = [
510
+ ...sharedZenChat,
511
+ defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true }),
512
+ defineModel({ id: "muse-spark-1.2", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
513
+ defineModel({ id: "muse-spark-1.3", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
514
+ systemoneZen("jev-1.13", false),
515
+ ];
516
+
517
+ export const OPENCODE_GO_MODELS: readonly ModelDefinition[] = [
518
+ // `muse-spark-1.2` is retired upstream; the catalog files
519
+ // `muse-spark-1.2-contributor` at 1048576/131072, which is what this row
520
+ // serves.
521
+ defineModel({ id: "muse-spark-1.2-contributor", providerId: "opencode-go", wireFamily: "responses", endpoint: `${GO_PATH_PREFIX}/responses`, ctx: 1048576, out: 131072, vision: true, reasoning: true }),
522
+ defineModel({ id: "grok-4.7", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 500000, out: 500000, reasoning: true }),
523
+ defineModel({ id: "glm-5.2", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
524
+ defineModel({ id: "kimi-k3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
525
+ defineModel({ id: "kimi-k2.7-code", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
526
+ defineModel({ id: "mimo-v2.5-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
527
+ defineModel({ id: "qwen3.8-max", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 131072, reasoning: true }),
528
+ defineModel({ id: "minimax-m3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
529
+ defineModel({ id: "deepseek-v4-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
530
+ defineModel({ id: "deepseek-v4.1-flash", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 384000, reasoning: true }),
531
+ defineModel({ id: "hy3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
532
+ ];