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,263 +1,310 @@
1
- /**
2
- * Qoder device-code OAuth client.
3
- *
4
- * Qoder's login is a custom device flow, not RFC 8628: the PKCE pair, nonce,
5
- * and machine id are generated locally, the user opens
6
- * `qoder.com/device/selectAccounts` in a browser, and the gateway polls the
7
- * region's deviceToken endpoint until a `dt-...` token appears. The flow uses
8
- * the same endpoints as the upstream client.
9
- *
10
- * The issued token is used directly at dispatch: `dt-...` tokens skip the
11
- * PAT→jobToken exchange. There is no working refresh grant —
12
- * the upstream refresh endpoint answers 403 for this flow — so no refresher
13
- * is registered and the token is used exactly as issued until the operator
14
- * re-runs login. The account's Qoder user id and machine id travel in
15
- * `auth_state`, because the token itself carries neither and every dispatch
16
- * needs both for COSY signing.
17
- */
18
- import { createHash, randomBytes, randomUUID } from "node:crypto";
19
- import {
20
- nonEmptyTrimmedString,
21
- record,
22
- } from "../../authentication/oauth-flow-store";
23
- import type {
24
- OAuthDeviceFlowContext,
25
- OAuthDevicePollResult,
26
- OAuthDeviceStartResult,
27
- OAuthExchangeResult,
28
- } from "../../authentication/oauth-flow-store";
29
- import type { OAuthTokenRefreshResult } from "../../authentication/oauth-refresh-service";
30
- import { OAuthDeviceFlow } from "../../authentication/oauth-device-flow";
31
-
32
- export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts" as const;
33
- export const QODER_DEVICE_TOKEN_URL = "https://openapi.qoder.sh/api/v1/deviceToken/poll" as const;
34
- export const QODER_USERINFO_URL = "https://openapi.qoder.sh/api/v1/userinfo" as const;
35
-
36
- /** Device authorizations live 5 minutes in the dashboard before expiring. */
37
- const DEVICE_EXPIRY_SECONDS = 300;
38
- /** Dashboard poll cadence for the device authorization. */
39
- const DEVICE_INTERVAL_SECONDS = 5;
40
- /** Individual poll request timeout; a stalled socket is a failed attempt, retried next tick. */
41
- const POLL_TIMEOUT_MS = 15_000;
42
- /** Token lifetime assumed when the upstream omits every expiry hint. */
43
- const EXPIRY_FALLBACK_MS = 30 * 24 * 60 * 60 * 1000;
44
-
45
- /** Non-secret per-account Qoder identity persisted in `auth_state`. */
46
- export interface QoderOAuthState {
47
- readonly userId: string;
48
- readonly machineId: string;
49
- }
50
-
51
- /** Reads the persisted Qoder identity; `undefined` when the account predates it. */
52
- export function parseQoderOAuthState(value: unknown): QoderOAuthState | undefined {
53
- const root = record(value);
54
- if (root === undefined) return undefined;
55
- const userId = nonEmptyTrimmedString(root.userId);
56
- const machineId = nonEmptyTrimmedString(root.machineId);
57
- if (userId === undefined || machineId === undefined) return undefined;
58
- return { userId, machineId };
59
- }
60
-
61
- function base64Url(buffer: Buffer): string {
62
- return buffer
63
- .toString("base64")
64
- .replace(/=/g, "")
65
- .replace(/\+/g, "-")
66
- .replace(/\//g, "_");
67
- }
68
-
69
- /**
70
- * Converts the upstream's expiry hint into a Unix-millisecond timestamp.
71
- *
72
- * Accepts a numeric ms-epoch (number or numeric string), an RFC3339 string,
73
- * or seconds-from-now. Falls back to now + 30 days when both are missing.
74
- * Numeric strings are checked before `Date.parse` because it accepts short
75
- * numerics like "2026" as years and would otherwise return a misleading date.
76
- */
77
- export function parseQoderExpiry(expiresAt: unknown, expiresInSeconds: unknown): number {
78
- if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > 0) {
79
- return expiresAt;
80
- }
81
- const trimmed = typeof expiresAt === "string" ? expiresAt.trim() : "";
82
- if (trimmed) {
83
- if (/^\d+$/.test(trimmed)) {
84
- const ms = Number.parseInt(trimmed, 10);
85
- if (Number.isFinite(ms) && ms > 0) return ms;
86
- }
87
- const parsed = Date.parse(trimmed);
88
- if (!Number.isNaN(parsed)) return parsed;
89
- }
90
- if (
91
- typeof expiresInSeconds === "number" &&
92
- Number.isFinite(expiresInSeconds) &&
93
- expiresInSeconds >= 0
94
- ) {
95
- return Date.now() + expiresInSeconds * 1000;
96
- }
97
- return Date.now() + EXPIRY_FALLBACK_MS;
98
- }
99
-
100
- interface QoderDeviceState {
101
- readonly verifier: string;
102
- readonly machineId: string;
103
- }
104
-
105
- function parseDeviceState(value: string | undefined): QoderDeviceState | undefined {
106
- if (!value) return undefined;
107
- let parsed: unknown;
108
- try {
109
- parsed = JSON.parse(value) as unknown;
110
- } catch {
111
- return undefined;
112
- }
113
- const root = record(parsed);
114
- const verifier = root === undefined ? undefined : nonEmptyTrimmedString(root.verifier);
115
- const machineId = root === undefined ? undefined : nonEmptyTrimmedString(root.machineId);
116
- if (verifier === undefined || machineId === undefined) return undefined;
117
- return { verifier, machineId };
118
- }
119
-
120
- export class QoderOAuthClient extends OAuthDeviceFlow {
121
- override readonly supportsDeviceCode = true;
122
- override readonly supportsBrowserCode = false;
123
-
124
- protected override readonly providerLabel = "Qoder";
125
- // The device endpoints take no client id; the abstract field stays empty
126
- // rather than inventing one, and every request below omits it.
127
- protected override readonly clientId = "";
128
- protected override readonly tokenUrl = QODER_DEVICE_TOKEN_URL;
129
- protected override readonly scopes = "";
130
-
131
- override async startDeviceAuth(
132
- _context?: OAuthDeviceFlowContext,
133
- ): Promise<OAuthDeviceStartResult> {
134
- const verifier = base64Url(randomBytes(32));
135
- const challenge = base64Url(createHash("sha256").update(verifier).digest());
136
- const nonce = randomUUID();
137
- const machineId = randomUUID();
138
- const params = new URLSearchParams({
139
- challenge,
140
- challenge_method: "S256",
141
- machine_id: machineId,
142
- nonce,
143
- });
144
- return {
145
- verificationUri: `${QODER_LOGIN_URL}?${params.toString()}`,
146
- // This flow shows no user code: approval happens against the
147
- // nonce-bound browser session, so there is nothing to type back.
148
- userCode: "",
149
- deviceAuthId: nonce,
150
- intervalSeconds: DEVICE_INTERVAL_SECONDS,
151
- expiresInSeconds: DEVICE_EXPIRY_SECONDS,
152
- providerState: JSON.stringify({ verifier, machineId }),
153
- };
154
- }
155
-
156
- override async pollDeviceAuth(
157
- deviceAuthId: string,
158
- context?: OAuthDeviceFlowContext,
159
- ): Promise<OAuthDevicePollResult> {
160
- const state = parseDeviceState(context?.providerState);
161
- if (state === undefined) return { status: "failed", reason: "unknown device authorization" };
162
- const url =
163
- `${QODER_DEVICE_TOKEN_URL}?nonce=${encodeURIComponent(deviceAuthId)}` +
164
- `&verifier=${encodeURIComponent(state.verifier)}&challenge_method=S256`;
165
- let response: Response;
166
- try {
167
- response = await this.fetchFn(url, {
168
- method: "GET",
169
- headers: { accept: "application/json", "user-agent": "Go-http-client/2.0" },
170
- signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
171
- });
172
- } catch (error) {
173
- return {
174
- status: "failed",
175
- reason: error instanceof Error ? error.message : "Qoder device polling failed",
176
- };
177
- }
178
- // 202/404 are the endpoint's "not approved yet" answers, not failures.
179
- if (response.status === 202 || response.status === 404) return { status: "pending" };
180
- let payload: unknown;
181
- try {
182
- payload = await response.json();
183
- } catch {
184
- return { status: "failed", reason: "Qoder device polling returned invalid JSON" };
185
- }
186
- if (!response.ok) {
187
- const message = record(payload);
188
- return {
189
- status: "failed",
190
- reason:
191
- nonEmptyTrimmedString(message?.message) ??
192
- `Qoder device polling failed (${response.status})`,
193
- };
194
- }
195
- const body = record(payload);
196
- const accessToken = body === undefined ? undefined : nonEmptyTrimmedString(body.token);
197
- // A 200 without a token means the upstream changed shape — fail loudly
198
- // rather than storing an empty credential that 401s every dispatch.
199
- if (accessToken === undefined) {
200
- return { status: "failed", reason: "Qoder device polling returned no token" };
201
- }
202
- const refreshToken = nonEmptyTrimmedString(body?.refresh_token);
203
- const expireTime = parseQoderExpiry(body?.expires_at, body?.expires_in);
204
- const info = await this.fetchUserInfo(accessToken);
205
- const label = info.name || info.email || undefined;
206
- // The token response does not always name the user id; the profile
207
- // endpoint does, so it is the fallback before giving up on the identity
208
- // dispatch needs for COSY signing.
209
- const resolvedUserId = nonEmptyTrimmedString(body?.user_id) ?? info.id;
210
- const result: OAuthExchangeResult = {
211
- access: accessToken,
212
- // No refresh grant exists, so the upstream refresh token (when present)
213
- // is mirrored here for the identity fingerprint that keys a repeated
214
- // login to the same account. It is never sent to a token endpoint —
215
- // no refresher is registered for this provider.
216
- refresh: refreshToken ?? accessToken,
217
- expiresAt: new Date(expireTime),
218
- ...(label === undefined ? {} : { accountLabel: label }),
219
- // The token carries neither the user id nor the machine id, and every
220
- // dispatch needs both for COSY signing — persist them beside the tokens.
221
- auth_state: { userId: resolvedUserId, machineId: state.machineId },
222
- };
223
- return { status: "complete", result };
224
- }
225
-
226
- /** Best-effort profile lookup for the account label; never blocks login. */
227
- async fetchUserInfo(accessToken: string): Promise<{ name: string; email: string; id: string }> {
228
- try {
229
- const response = await this.fetchFn(QODER_USERINFO_URL, {
230
- method: "GET",
231
- headers: {
232
- authorization: `Bearer ${accessToken}`,
233
- accept: "application/json",
234
- "user-agent": "Go-http-client/2.0",
235
- },
236
- signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
237
- });
238
- if (!response.ok) return { name: "", email: "", id: "" };
239
- const body = record(await response.json());
240
- if (body === undefined) return { name: "", email: "", id: "" };
241
- return {
242
- name: (nonEmptyTrimmedString(body.name) ?? nonEmptyTrimmedString(body.username) ?? "").trim(),
243
- email: (nonEmptyTrimmedString(body.email) ?? "").trim(),
244
- id:
245
- nonEmptyTrimmedString(body.id) ??
246
- nonEmptyTrimmedString(body.userId) ??
247
- nonEmptyTrimmedString(body.user_id) ??
248
- "",
249
- };
250
- } catch {
251
- return { name: "", email: "", id: "" };
252
- }
253
- }
254
-
255
- override async refresh(
256
- _refreshToken: string,
257
- _signal?: AbortSignal,
258
- ): Promise<OAuthTokenRefreshResult> {
259
- throw new Error("Qoder does not support token refresh; sign in again");
260
- }
261
- }
262
-
263
- export const qoderOAuthClient = new QoderOAuthClient();
1
+ /**
2
+ * Qoder device-code OAuth client.
3
+ *
4
+ * Qoder's login is a custom device flow, not RFC 8628: the PKCE pair, nonce,
5
+ * and machine id are generated locally, the user opens
6
+ * `qoder.com/device/selectAccounts` in a browser, and the gateway polls the
7
+ * region's deviceToken endpoint until a `dt-...` token appears. The flow uses
8
+ * the same endpoints as the upstream client.
9
+ *
10
+ * The issued token is used directly at dispatch: `dt-...` tokens skip the
11
+ * PAT→jobToken exchange. There is no working refresh grant —
12
+ * the upstream refresh endpoint answers 403 for this flow — so no refresher
13
+ * is registered and the token is used exactly as issued until the operator
14
+ * re-runs login. The account's Qoder user id and machine id travel in
15
+ * `auth_state`, because the token itself carries neither and every dispatch
16
+ * needs both for COSY signing.
17
+ */
18
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
19
+ import { QODER_REALMS, type QoderRealm } from "./qoder-realm";
20
+ import {
21
+ base64UrlEncode,
22
+ nonEmptyTrimmedString,
23
+ record,
24
+ } from "../../authentication/oauth-flow-store";
25
+ import type {
26
+ OAuthDeviceFlowContext,
27
+ OAuthDevicePollResult,
28
+ OAuthDeviceStartResult,
29
+ OAuthExchangeResult,
30
+ } from "../../authentication/oauth-flow-store";
31
+ import type { OAuthTokenRefreshResult } from "../../authentication/oauth-refresh-service";
32
+ import { OAuthDeviceFlow } from "../../authentication/oauth-device-flow";
33
+
34
+ /** Global/intl endpoints. Kept as the default realm's values, unchanged. */
35
+ export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts" as const;
36
+ export const QODER_DEVICE_TOKEN_URL = "https://openapi.qoder.sh/api/v1/deviceToken/poll" as const;
37
+ export const QODER_USERINFO_URL = "https://openapi.qoder.sh/api/v1/userinfo" as const;
38
+
39
+ /**
40
+ * Per-realm device endpoints, derived from `QODER_REALMS`.
41
+ *
42
+ * Every realm's openapi host differs, so the three URLs are built from the
43
+ * table rather than hardcoded — a CN login pointed at `openapi.qoder.sh` is
44
+ * refused before it ever reaches an approval screen.
45
+ */
46
+ export function qoderOAuthUrls(realm: QoderRealm): {
47
+ readonly loginUrl: string;
48
+ readonly deviceTokenUrl: string;
49
+ readonly userinfoUrl: string;
50
+ } {
51
+ const config = QODER_REALMS[realm];
52
+ return {
53
+ loginUrl: `${config.website}/device/selectAccounts`,
54
+ deviceTokenUrl: `${config.openapi}/api/v1/deviceToken/poll`,
55
+ userinfoUrl: `${config.openapi}/api/v1/userinfo`,
56
+ };
57
+ }
58
+
59
+ /** Device authorizations live 5 minutes in the dashboard before expiring. */
60
+ const DEVICE_EXPIRY_SECONDS = 300;
61
+ /** Dashboard poll cadence for the device authorization. */
62
+ const DEVICE_INTERVAL_SECONDS = 5;
63
+ /** Individual poll request timeout; a stalled socket is a failed attempt, retried next tick. */
64
+ const POLL_TIMEOUT_MS = 15_000;
65
+ /** Token lifetime assumed when the upstream omits every expiry hint. */
66
+ const EXPIRY_FALLBACK_MS = 30 * 24 * 60 * 60 * 1000;
67
+
68
+ /** Non-secret per-account Qoder identity persisted in `auth_state`. */
69
+ export interface QoderOAuthState {
70
+ readonly userId: string;
71
+ readonly machineId: string;
72
+ }
73
+
74
+ /** Reads the persisted Qoder identity; `undefined` when the account predates it. */
75
+ export function parseQoderOAuthState(value: unknown): QoderOAuthState | undefined {
76
+ const root = record(value);
77
+ if (root === undefined) return undefined;
78
+ const userId = nonEmptyTrimmedString(root.userId);
79
+ const machineId = nonEmptyTrimmedString(root.machineId);
80
+ if (userId === undefined || machineId === undefined) return undefined;
81
+ return { userId, machineId };
82
+ }
83
+
84
+
85
+ /**
86
+ * Converts the upstream's expiry hint into a Unix-millisecond timestamp.
87
+ *
88
+ * Accepts a numeric ms-epoch (number or numeric string), an RFC3339 string,
89
+ * or seconds-from-now. Falls back to now + 30 days when both are missing.
90
+ * Numeric strings are checked before `Date.parse` because it accepts short
91
+ * numerics like "2026" as years and would otherwise return a misleading date.
92
+ */
93
+ export function parseQoderExpiry(expiresAt: unknown, expiresInSeconds: unknown): number {
94
+ if (typeof expiresAt === "number" && Number.isFinite(expiresAt) && expiresAt > 0) {
95
+ return expiresAt;
96
+ }
97
+ const trimmed = typeof expiresAt === "string" ? expiresAt.trim() : "";
98
+ if (trimmed) {
99
+ if (/^\d+$/.test(trimmed)) {
100
+ const ms = Number.parseInt(trimmed, 10);
101
+ if (Number.isFinite(ms) && ms > 0) return ms;
102
+ }
103
+ const parsed = Date.parse(trimmed);
104
+ if (!Number.isNaN(parsed)) return parsed;
105
+ }
106
+ if (
107
+ typeof expiresInSeconds === "number" &&
108
+ Number.isFinite(expiresInSeconds) &&
109
+ expiresInSeconds >= 0
110
+ ) {
111
+ return Date.now() + expiresInSeconds * 1000;
112
+ }
113
+ return Date.now() + EXPIRY_FALLBACK_MS;
114
+ }
115
+
116
+ interface QoderDeviceState {
117
+ readonly verifier: string;
118
+ readonly machineId: string;
119
+ }
120
+
121
+ function parseDeviceState(value: string | undefined): QoderDeviceState | undefined {
122
+ if (!value) return undefined;
123
+ let parsed: unknown;
124
+ try {
125
+ parsed = JSON.parse(value) as unknown;
126
+ } catch {
127
+ return undefined;
128
+ }
129
+ const root = record(parsed);
130
+ const verifier = root === undefined ? undefined : nonEmptyTrimmedString(root.verifier);
131
+ const machineId = root === undefined ? undefined : nonEmptyTrimmedString(root.machineId);
132
+ if (verifier === undefined || machineId === undefined) return undefined;
133
+ return { verifier, machineId };
134
+ }
135
+
136
+ export class QoderOAuthClient extends OAuthDeviceFlow {
137
+ override readonly supportsDeviceCode = true;
138
+ override readonly supportsBrowserCode = false;
139
+
140
+ /**
141
+ * Which deployment this login talks to.
142
+ *
143
+ * Defaults to intl, whose parameter set is left exactly as it was working:
144
+ * the CN shape below is the only branch that changes behavior, so adding a
145
+ * realm cannot regress an existing global login.
146
+ */
147
+ private readonly realm: QoderRealm;
148
+
149
+ constructor(fetchFn: typeof fetch = globalThis.fetch, realm: QoderRealm = "intl") {
150
+ super(fetchFn);
151
+ this.realm = realm;
152
+ }
153
+
154
+ /** The three device endpoints for this client's realm. */
155
+ get urls(): { loginUrl: string; deviceTokenUrl: string; userinfoUrl: string } {
156
+ return qoderOAuthUrls(this.realm);
157
+ }
158
+
159
+ protected override readonly providerLabel = "Qoder";
160
+ // The device endpoints take no client id; the abstract field stays empty
161
+ // rather than inventing one, and every request below omits it.
162
+ protected override readonly clientId = "";
163
+ protected override readonly tokenUrl = QODER_DEVICE_TOKEN_URL;
164
+ protected override readonly scopes = "";
165
+
166
+ override async startDeviceAuth(
167
+ _context?: OAuthDeviceFlowContext,
168
+ ): Promise<OAuthDeviceStartResult> {
169
+ const verifier = base64UrlEncode(randomBytes(32));
170
+ const challenge = base64UrlEncode(createHash("sha256").update(verifier).digest());
171
+ // CN carries a dashed nonce and additionally states the client id and
172
+ // native redirect; intl stays on the 32-hex nonce and the two params it
173
+ // has always sent. Both shapes are upstream fact from the donor, and only
174
+ // the CN branch is new, so the working intl URL is untouched.
175
+ const nonce = this.realm === "cn" ? randomUUID() : randomUUID().replaceAll("-", "");
176
+ const machineId = randomUUID();
177
+ const params = new URLSearchParams({
178
+ challenge,
179
+ challenge_method: "S256",
180
+ machine_id: machineId,
181
+ nonce,
182
+ });
183
+ if (this.realm === "cn") {
184
+ const config = QODER_REALMS.cn;
185
+ params.set("client_id", config.clientId);
186
+ params.set("redirect_uri", config.redirectUri);
187
+ }
188
+ return {
189
+ verificationUri: `${qoderOAuthUrls(this.realm).loginUrl}?${params.toString()}`,
190
+ // This flow shows no user code: approval happens against the
191
+ // nonce-bound browser session, so there is nothing to type back.
192
+ userCode: "",
193
+ deviceAuthId: nonce,
194
+ intervalSeconds: DEVICE_INTERVAL_SECONDS,
195
+ expiresInSeconds: DEVICE_EXPIRY_SECONDS,
196
+ providerState: JSON.stringify({ verifier, machineId }),
197
+ };
198
+ }
199
+
200
+ override async pollDeviceAuth(
201
+ deviceAuthId: string,
202
+ context?: OAuthDeviceFlowContext,
203
+ ): Promise<OAuthDevicePollResult> {
204
+ const state = parseDeviceState(context?.providerState);
205
+ if (state === undefined) return { status: "failed", reason: "unknown device authorization" };
206
+ const url =
207
+ `${qoderOAuthUrls(this.realm).deviceTokenUrl}?nonce=${encodeURIComponent(deviceAuthId)}` +
208
+ `&verifier=${encodeURIComponent(state.verifier)}&challenge_method=S256`;
209
+ let response: Response;
210
+ try {
211
+ response = await this.fetchFn(url, {
212
+ method: "GET",
213
+ headers: { accept: "application/json", "user-agent": "Go-http-client/2.0" },
214
+ signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
215
+ });
216
+ } catch (error) {
217
+ return {
218
+ status: "failed",
219
+ reason: error instanceof Error ? error.message : "Qoder device polling failed",
220
+ };
221
+ }
222
+ // 202/404 are the endpoint's "not approved yet" answers, not failures.
223
+ if (response.status === 202 || response.status === 404) return { status: "pending" };
224
+ let payload: unknown;
225
+ try {
226
+ payload = await response.json();
227
+ } catch {
228
+ return { status: "failed", reason: "Qoder device polling returned invalid JSON" };
229
+ }
230
+ if (!response.ok) {
231
+ const message = record(payload);
232
+ return {
233
+ status: "failed",
234
+ reason:
235
+ nonEmptyTrimmedString(message?.message) ??
236
+ `Qoder device polling failed (${response.status})`,
237
+ };
238
+ }
239
+ const body = record(payload);
240
+ const accessToken = body === undefined ? undefined : nonEmptyTrimmedString(body.token);
241
+ // A 200 without a token means the upstream changed shape — fail loudly
242
+ // rather than storing an empty credential that 401s every dispatch.
243
+ if (accessToken === undefined) {
244
+ return { status: "failed", reason: "Qoder device polling returned no token" };
245
+ }
246
+ const refreshToken = nonEmptyTrimmedString(body?.refresh_token);
247
+ const expireTime = parseQoderExpiry(body?.expires_at, body?.expires_in);
248
+ const info = await this.fetchUserInfo(accessToken);
249
+ const label = info.name || info.email || undefined;
250
+ // The token response does not always name the user id; the profile
251
+ // endpoint does, so it is the fallback before giving up on the identity
252
+ // dispatch needs for COSY signing.
253
+ const resolvedUserId = nonEmptyTrimmedString(body?.user_id) ?? info.id;
254
+ const result: OAuthExchangeResult = {
255
+ access: accessToken,
256
+ // No refresh grant exists, so the upstream refresh token (when present)
257
+ // is mirrored here for the identity fingerprint that keys a repeated
258
+ // login to the same account. It is never sent to a token endpoint —
259
+ // no refresher is registered for this provider.
260
+ refresh: refreshToken ?? accessToken,
261
+ expiresAt: new Date(expireTime),
262
+ ...(label === undefined ? {} : { accountLabel: label }),
263
+ // The token carries neither the user id nor the machine id, and every
264
+ // dispatch needs both for COSY signing — persist them beside the tokens.
265
+ auth_state: { userId: resolvedUserId, machineId: state.machineId },
266
+ };
267
+ return { status: "complete", result };
268
+ }
269
+
270
+ /** Best-effort profile lookup for the account label; never blocks login. */
271
+ async fetchUserInfo(accessToken: string): Promise<{ name: string; email: string; id: string }> {
272
+ try {
273
+ const response = await this.fetchFn(qoderOAuthUrls(this.realm).userinfoUrl, {
274
+ method: "GET",
275
+ headers: {
276
+ authorization: `Bearer ${accessToken}`,
277
+ accept: "application/json",
278
+ "user-agent": "Go-http-client/2.0",
279
+ },
280
+ signal: AbortSignal.timeout(POLL_TIMEOUT_MS),
281
+ });
282
+ if (!response.ok) return { name: "", email: "", id: "" };
283
+ const body = record(await response.json());
284
+ if (body === undefined) return { name: "", email: "", id: "" };
285
+ return {
286
+ name: (nonEmptyTrimmedString(body.name) ?? nonEmptyTrimmedString(body.username) ?? "").trim(),
287
+ email: (nonEmptyTrimmedString(body.email) ?? "").trim(),
288
+ id:
289
+ nonEmptyTrimmedString(body.id) ??
290
+ nonEmptyTrimmedString(body.userId) ??
291
+ nonEmptyTrimmedString(body.user_id) ??
292
+ "",
293
+ };
294
+ } catch {
295
+ return { name: "", email: "", id: "" };
296
+ }
297
+ }
298
+
299
+ override async refresh(
300
+ _refreshToken: string,
301
+ _signal?: AbortSignal,
302
+ ): Promise<OAuthTokenRefreshResult> {
303
+ throw new Error("Qoder does not support token refresh; sign in again");
304
+ }
305
+ }
306
+
307
+ export const qoderOAuthClient = new QoderOAuthClient();
308
+
309
+ /** Device-login client for the China deployment. */
310
+ export const qoderCnOAuthClient = new QoderOAuthClient(globalThis.fetch, "cn");