cartethyia 2.7.2 → 2.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (405) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
  10. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
  40. package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
  41. package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-a159k0ub.css +2 -0
  43. package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/assert-DbRA8EG2.js +15 -0
  47. package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
  49. package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
  51. package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
  53. package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
  54. package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
  55. package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
  59. package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
  61. package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
  63. package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
  65. package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
  67. package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
  71. package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
  75. package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
  77. package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
  79. package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
  81. package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
  83. package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
  84. package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
  85. package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
  86. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  87. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  88. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
  89. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  90. package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
  91. package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
  92. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
  93. package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
  94. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
  95. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  96. package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
  97. package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
  98. package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
  99. package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
  100. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
  101. package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
  102. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  103. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  104. package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
  105. package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
  106. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
  107. package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
  108. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
  109. package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
  110. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
  111. package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
  112. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
  113. package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
  114. package/dist/dashboard/index.html +3 -2
  115. package/dist/dashboard/index.html.gz +0 -0
  116. package/dist/dashboard/precompressed-assets.json +118 -115
  117. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  118. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
  119. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  123. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  124. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  125. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  126. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  127. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  128. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
  129. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  130. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  131. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  132. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  133. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  134. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  135. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  136. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  137. package/package.json +2 -1
  138. package/src/boot/app.ts +9 -8
  139. package/src/config/network.ts +7 -3
  140. package/src/console/api-keys/bucket-spend.ts +7 -7
  141. package/src/console/api-keys/contracts.ts +475 -410
  142. package/src/console/api-keys/routes.ts +211 -23
  143. package/src/console/audit/routes.ts +3 -3
  144. package/src/console/auth/account-reset.ts +4 -4
  145. package/src/console/auth/session.ts +3 -3
  146. package/src/console/backup/contracts.ts +2 -0
  147. package/src/console/backup/export-endpoint.ts +14 -17
  148. package/src/console/backup/routes.ts +43 -11
  149. package/src/console/backup/service.ts +11 -6
  150. package/src/console/backup/store.ts +384 -88
  151. package/src/console/cli-tools/routes.ts +3 -3
  152. package/src/console/console-router-guards.ts +2 -2
  153. package/src/console/console-router.ts +4 -4
  154. package/src/console/dashboard-assets.ts +16 -1
  155. package/src/console/data-management/routes.ts +3 -3
  156. package/src/console/domain-registration.ts +4 -2
  157. package/src/console/observability/contracts.ts +4 -0
  158. package/src/console/observability/live.ts +3 -3
  159. package/src/console/observability/logs.ts +3 -3
  160. package/src/console/observability/performance.ts +3 -3
  161. package/src/console/observability/routes.ts +3 -3
  162. package/src/console/observability/store.ts +16 -16
  163. package/src/console/providers/catalog/contracts.ts +43 -22
  164. package/src/console/providers/catalog/provider-operations.ts +17 -2
  165. package/src/console/providers/catalog/routes.ts +3 -3
  166. package/src/console/providers/catalog/store-helpers.ts +349 -326
  167. package/src/console/providers/catalog/store.ts +1523 -1292
  168. package/src/console/providers/detail/routes.ts +3 -3
  169. package/src/console/providers/oauth/routes.ts +3 -3
  170. package/src/console/quota/account-quota-tenant.ts +34 -2
  171. package/src/console/quota/account-quota.ts +4 -1
  172. package/src/console/quota/account-test-egress.ts +4 -4
  173. package/src/console/quota/native-identity.ts +189 -0
  174. package/src/console/remote-support/contracts.ts +12 -6
  175. package/src/console/remote-support/service.ts +2 -2
  176. package/src/console/routing/model/routes.ts +3 -3
  177. package/src/console/routing/pools/contracts.ts +11 -8
  178. package/src/console/routing/pools/operations.ts +14 -7
  179. package/src/console/routing/pools/probe-verdict.ts +8 -3
  180. package/src/console/routing/pools/relay-deploy.ts +9 -9
  181. package/src/console/routing/pools/routes.ts +3 -3
  182. package/src/console/routing/pools/store.ts +21 -1
  183. package/src/console/settings/routes.ts +3 -3
  184. package/src/console/share/share-router.ts +51 -18
  185. package/src/console/share/share-stats.ts +158 -49
  186. package/src/console/share/share-usage.ts +24 -7
  187. package/src/console/shared/read-through-cache.ts +38 -2
  188. package/src/console/system-prompt/contracts.ts +102 -0
  189. package/src/console/system-prompt/routes.ts +165 -0
  190. package/src/console/updates/routes.ts +7 -4
  191. package/src/main.ts +11 -0
  192. package/src/network/outbound-fetch.ts +75 -4
  193. package/src/network/pool/resolver.ts +39 -39
  194. package/src/network/pool-cooldown-store.ts +71 -16
  195. package/src/network/pool-health-machine.ts +21 -1
  196. package/src/network/rate-limit-verdict.ts +68 -0
  197. package/src/network/ssrf.ts +4 -4
  198. package/src/observability/event-query.ts +437 -22
  199. package/src/observability/telemetry-status.ts +1 -1
  200. package/src/persistence/db-pg.ts +42 -0
  201. package/src/persistence/db-sqlite.ts +7 -2
  202. package/src/persistence/readiness.ts +18 -6
  203. package/src/persistence/schema.ts +162 -1
  204. package/src/persistence/stores/api-key-store.ts +849 -581
  205. package/src/persistence/stores/share-store.ts +65 -13
  206. package/src/persistence/stores/telemetry-store.ts +58 -2
  207. package/src/persistence/system-prompt-store.ts +104 -0
  208. package/src/protocol/codecs.ts +6 -6
  209. package/src/protocol/gateway-error.ts +6 -3
  210. package/src/protocol/image-generation.ts +2 -2
  211. package/src/protocol/reasoning.ts +65 -1
  212. package/src/protocol/request/chat.ts +1 -0
  213. package/src/protocol/response/chat.ts +2 -2
  214. package/src/protocol/response/codex.ts +1 -1
  215. package/src/protocol/response/gemini.ts +1 -1
  216. package/src/protocol/response/messages.ts +5 -5
  217. package/src/protocol/response/responses.ts +2 -2
  218. package/src/protocol/sse-decoder.ts +14 -3
  219. package/src/protocol/translation/capabilities.ts +2 -2
  220. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  221. package/src/providers/adapters/base-adapter.ts +1 -1
  222. package/src/providers/adapters/compatible-adapter.ts +2 -2
  223. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  224. package/src/providers/configured-provider.ts +1 -1
  225. package/src/providers/discovery/probe-phases.ts +2 -2
  226. package/src/providers/discovery/probing-service.ts +12 -12
  227. package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
  228. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  229. package/src/providers/integrations/anthropic.ts +4 -11
  230. package/src/providers/integrations/antigravity/antigravity.ts +33 -47
  231. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
  232. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  233. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  234. package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
  235. package/src/providers/integrations/buddy/buddy-media.ts +21 -17
  236. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  237. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
  238. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  239. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  240. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  241. package/src/providers/integrations/claude/claude.ts +3 -10
  242. package/src/providers/integrations/cline/cline.ts +8 -1
  243. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  244. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  245. package/src/providers/integrations/codex/codex.ts +14 -15
  246. package/src/providers/integrations/commandcode/commandcode.ts +4 -4
  247. package/src/providers/integrations/commandcode/quota.ts +5 -1
  248. package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
  249. package/src/providers/integrations/cursor/cursor.ts +15 -15
  250. package/src/providers/integrations/factory/factory.ts +4 -4
  251. package/src/providers/integrations/gemini.ts +11 -11
  252. package/src/providers/integrations/github/github.ts +26 -7
  253. package/src/providers/integrations/grok/grok.ts +2 -6
  254. package/src/providers/integrations/hermes.ts +5 -1
  255. package/src/providers/integrations/inferhub.ts +4 -11
  256. package/src/providers/integrations/kilo/kilo.ts +4 -0
  257. package/src/providers/integrations/kimi/kimi.ts +4 -11
  258. package/src/providers/integrations/kiro/kiro.ts +10 -10
  259. package/src/providers/integrations/muse/muse.ts +6 -9
  260. package/src/providers/integrations/openrouter/provider.ts +4 -0
  261. package/src/providers/integrations/perplexity.ts +2 -2
  262. package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
  263. package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
  264. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
  265. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  266. package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
  267. package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
  268. package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
  269. package/src/providers/integrations/qoder/qoder.ts +192 -24
  270. package/src/providers/integrations/tokenharbor.ts +2 -2
  271. package/src/providers/integrations/trae/trae.ts +6 -9
  272. package/src/providers/integrations/zcode/oauth.ts +1 -1
  273. package/src/providers/operations/account-error-classification.ts +6 -8
  274. package/src/providers/operations/account-health-service.ts +14 -16
  275. package/src/providers/operations/client-versions.ts +37 -22
  276. package/src/providers/operations/provider-catalog-service.ts +4 -4
  277. package/src/providers/operations/provider-credential-service.ts +16 -4
  278. package/src/providers/operations/upstream-deadline.ts +3 -3
  279. package/src/providers/provider-contracts.ts +55 -3
  280. package/src/providers/provider-credential-resolver.ts +1 -1
  281. package/src/providers/provider-registry.ts +4 -4
  282. package/src/providers/quota/quota-contracts.ts +24 -10
  283. package/src/providers/quota/refresh.ts +2 -2
  284. package/src/providers/search/search-provider.ts +5 -5
  285. package/src/security/abuse.ts +4 -4
  286. package/src/security/access-control.ts +19 -0
  287. package/src/security/admission/reasons.ts +17 -17
  288. package/src/security/api-key-auth.ts +18 -5
  289. package/src/shared/coerce.ts +19 -0
  290. package/src/transport/dispatch/attempt-finalize.ts +4 -3
  291. package/src/transport/dispatch/attempt-loop.ts +10 -7
  292. package/src/transport/dispatch/empty-stream.ts +6 -6
  293. package/src/transport/dispatch/generation.ts +9 -9
  294. package/src/transport/dispatch/leases.ts +8 -8
  295. package/src/transport/dispatch/proxy-request.ts +38 -9
  296. package/src/transport/dispatch/responses-compact.ts +11 -11
  297. package/src/transport/dispatch/retry-policy.ts +4 -67
  298. package/src/transport/dispatch/stream-bridge.ts +1 -1
  299. package/src/transport/dispatch/streaming/finalize.ts +9 -8
  300. package/src/transport/dispatch/streaming/stream.ts +16 -1
  301. package/src/transport/dispatch/systemone.ts +8 -8
  302. package/src/transport/dispatch/token-count.ts +9 -9
  303. package/src/transport/dispatch/upstream.ts +10 -0
  304. package/src/transport/dispatch/websearch.ts +6 -6
  305. package/src/transport/drain-endpoint.ts +2 -2
  306. package/src/transport/failure-policy.ts +13 -6
  307. package/src/transport/guards/auth-guard.ts +6 -5
  308. package/src/transport/guards/body-policy.ts +4 -4
  309. package/src/transport/guards/error-lifecycle.ts +18 -3
  310. package/src/transport/guards/guard-errors.ts +7 -3
  311. package/src/transport/guards/headroom-guard.ts +1 -1
  312. package/src/transport/guards/pipeline.ts +6 -6
  313. package/src/transport/guards/readiness-guard.ts +16 -2
  314. package/src/transport/guards/request-context.ts +3 -3
  315. package/src/transport/request/preparer.ts +37 -21
  316. package/src/transport/request/state.ts +4 -7
  317. package/src/transport/request/system-prompt-injection.ts +89 -0
  318. package/src/transport/request/tenant-preferences.ts +43 -0
  319. package/src/transport/routing/admission.ts +13 -3
  320. package/src/transport/routing/route-catalog.ts +104 -2
  321. package/src/transport/routing/route-model.ts +60 -7
  322. package/src/transport/routing/router.ts +306 -256
  323. package/src/transport/shutdown-notice.ts +19 -7
  324. package/src/transport/surface/completion.ts +1 -1
  325. package/src/transport/surface/messages/encode.ts +1 -1
  326. package/src/workers/daily-checkin.ts +2 -0
  327. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  328. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  329. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  330. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  331. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  332. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  333. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
  334. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  335. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  336. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  337. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  338. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  339. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  340. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  341. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  342. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  343. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  344. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  345. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  346. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  347. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  348. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
  349. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  350. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
  351. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  352. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  353. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  354. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  355. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  356. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
  357. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  358. package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
  359. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  360. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  361. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  362. package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
  363. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  364. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  365. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  366. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  367. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
  368. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  369. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  370. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  371. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  372. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  373. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  374. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  375. package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
  376. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  377. package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
  378. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  379. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  380. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  381. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  382. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  383. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  384. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  385. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  386. package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
  387. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  388. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  389. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  390. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  391. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  392. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  393. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  394. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  395. package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
  396. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  397. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  398. package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
  399. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  400. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  401. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  402. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  403. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  404. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
  405. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
@@ -69,11 +69,11 @@ export function createPerplexityAdapter(fetchImpl?: typeof fetch): ProviderAdapt
69
69
  void _candidate;
70
70
  const query = extractSearchQuery(request);
71
71
  if (!query) {
72
- throw new GatewayError("invalid_request", 400, "Search query is required.");
72
+ throw new GatewayError("invalid_request", 400, "Search query is required (request {requestId}). Check the shape of what you sent — then resend 🪞");
73
73
  }
74
74
  const token = tokenFromContext(context);
75
75
  if (!token) {
76
- throw new GatewayError("authentication_failed", 401, "Missing Perplexity API key");
76
+ throw new GatewayError("authentication_failed", 401, "Missing Perplexity API key (request {requestId}). Wrong key, right door — check the key 🔑");
77
77
  }
78
78
 
79
79
  const fetchFn = (context.outbound_fetch as unknown as typeof fetch | undefined) ?? fetchImpl ?? globalThis.fetch;
@@ -133,13 +133,15 @@ export const QODER_INTL_MODEL_CONFIGS: Readonly<Record<string, QoderModelConfig>
133
133
  qfmodel: { id: "qfmodel", display_name: "Qwen3.8-Flash", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: true, is_reasoning: true },
134
134
  qmodel_latest: { id: "qmodel_latest", display_name: "Qwen3.7-Max", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: false },
135
135
  qmodel: { id: "qmodel", display_name: "Qwen3.7-Plus", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: false },
136
- kmodel_latest: { id: "kmodel_latest", display_name: "Kimi-K3", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: false, is_reasoning: false },
136
+ kmodel_latest: { id: "kmodel_latest", display_name: "Kimi-K3.1-Pro", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: false, is_reasoning: false },
137
137
  kmodel: { id: "kmodel", display_name: "Kimi-K2.8-Preview", max_input_tokens: 1000000, max_output_tokens: 65_536, is_vl: true, is_free: false, is_reasoning: false },
138
138
  gmodel: { id: "gmodel", display_name: "GLM-5.3", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_free: false, is_reasoning: true },
139
139
  gfmodel: { id: "gfmodel", display_name: "GLM-5.3-Flash", max_input_tokens: 1000000, max_output_tokens: 131_072, is_vl: true, is_reasoning: true },
140
140
  dmodel: { id: "dmodel", display_name: "DeepSeek-V4-Pro", max_input_tokens: 1000000, max_output_tokens: 384_000, is_vl: true, is_free: false, is_reasoning: true },
141
- dfmodel: { id: "dfmodel", display_name: "DeepSeek-Flash", max_input_tokens: 1000000, max_output_tokens: 384_000, is_vl: true, is_free: true, is_reasoning: true },
142
- mmodel: { id: "mmodel", display_name: "MiniMax-M3", max_input_tokens: 1000000, max_output_tokens: 512_000, is_vl: true, is_free: false, is_reasoning: false },
141
+ // Live roster (2026-11-01) reports dfmodel as billed (is_free false); it was
142
+ // previously marked free. Display names are corrected from the same capture.
143
+ dfmodel: { id: "dfmodel", display_name: "DeepSeek-Flash", max_input_tokens: 1000000, max_output_tokens: 384_000, is_vl: true, is_free: false, is_reasoning: true },
144
+ mmodel: { id: "mmodel", display_name: "MiniMax-M3.1-Flash", max_input_tokens: 1000000, max_output_tokens: 512_000, is_vl: true, is_free: false, is_reasoning: false },
143
145
  };
144
146
 
145
147
  /**
@@ -353,6 +355,14 @@ interface CachedRoster {
353
355
  /** Per-realm live-list cache; keyed by realm, not by account. */
354
356
  const discoveryCache = new Map<QoderRealm, CachedRoster>();
355
357
 
358
+ /**
359
+ * Per-realm max-context window per roster key, taken from the live list's
360
+ * `context_config` (the widest selectable window). This is what dispatch reads
361
+ * to send `model_config.max_input_tokens`; without it the request would use the
362
+ * static roster's *currently active* window (often 180k) instead of the max.
363
+ */
364
+ const discoveryMaxContextByKey = new Map<QoderRealm, Map<string, number>>();
365
+
356
366
  /**
357
367
  * Drops one realm's cached list.
358
368
  *
@@ -361,8 +371,21 @@ const discoveryCache = new Map<QoderRealm, CachedRoster>();
361
371
  * previous assertion left behind.
362
372
  */
363
373
  export function resetQoderDiscoveryCache(realm?: QoderRealm): void {
364
- if (realm === undefined) discoveryCache.clear();
365
- else discoveryCache.delete(realm);
374
+ if (realm === undefined) {
375
+ discoveryCache.clear();
376
+ discoveryMaxContextByKey.clear();
377
+ } else {
378
+ discoveryCache.delete(realm);
379
+ discoveryMaxContextByKey.delete(realm);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * The widest selectable context window per roster key for one realm, from the
385
+ * live model list. Empty when discovery has not succeeded for that realm yet.
386
+ */
387
+ export function qoderDiscoveredMaxContexts(realm: QoderRealm): ReadonlyMap<string, number> {
388
+ return discoveryMaxContextByKey.get(realm) ?? new Map();
366
389
  }
367
390
 
368
391
  export async function discoverQoderModels(input: {
@@ -389,7 +412,7 @@ export async function discoverQoderModels(input: {
389
412
  throw new GatewayError(
390
413
  "authentication_failed",
391
414
  401,
392
- "Qoder model discovery requires an account credential.",
415
+ "Qoder model discovery requires an account credential (request {requestId}). Wrong key, right door — check the credential 🔑",
393
416
  { providerId, realm: input.realm },
394
417
  );
395
418
  }
@@ -398,7 +421,7 @@ export async function discoverQoderModels(input: {
398
421
  throw new GatewayError(
399
422
  "invalid_request",
400
423
  400,
401
- "Qoder model discovery requires a validated outbound fetch",
424
+ "Qoder model discovery requires a validated outbound fetch (request {requestId}). No bridge to cross — configure a pool first 🏗️",
402
425
  { providerId, realm: input.realm },
403
426
  );
404
427
  }
@@ -409,6 +432,11 @@ export async function discoverQoderModels(input: {
409
432
  const url = `${host}${QODER_MODELS_PATH}`;
410
433
  try {
411
434
  const headers = await input.headers(url, signedBody);
435
+ // GET, not POST: upstream refuses POST on the model-list surface (400
436
+ // "Request method 'POST' not supported"). The signature covers the body,
437
+ // so the body must accompany the GET. The gateway's validated egress
438
+ // dials GET+body through the Node http(s) client (the runtime fetch
439
+ // rejects GET bodies), which is exactly the shape the donor client sends.
412
440
  const response = await fetcher(url, {
413
441
  method: "GET",
414
442
  headers: headers as unknown as HeadersInit,
@@ -430,9 +458,19 @@ export async function discoverQoderModels(input: {
430
458
  continue;
431
459
  }
432
460
  const models = enabled.map((entry) => qoderCatalogModel(entry, providerId));
461
+ // Index the widest selectable window per key (from `context_config`,
462
+ // already computed into `maxInputTokens` by `mapQoderCatalogResponse`) so
463
+ // dispatch can send the maximum rather than the currently active one.
464
+ const maxCtx = new Map<string, number>();
465
+ for (const entry of enabled) {
466
+ if (entry.maxInputTokens !== undefined && entry.key.length > 0) {
467
+ maxCtx.set(entry.key, entry.maxInputTokens);
468
+ }
469
+ }
433
470
  // Cached only after a host actually answered: a failure must stay
434
471
  // retryable on the next call rather than being frozen for five minutes.
435
472
  discoveryCache.set(input.realm, { at: Date.now(), models });
473
+ discoveryMaxContextByKey.set(input.realm, maxCtx);
436
474
  return models;
437
475
  } catch (error) {
438
476
  lastError = error;
@@ -441,9 +479,9 @@ export async function discoverQoderModels(input: {
441
479
  throw new GatewayError(
442
480
  "transport_unavailable",
443
481
  502,
444
- `Qoder model discovery failed for ${input.realm}: ${
482
+ `Qoder model discovery failed for ${input.realm} (request {requestId}): ${
445
483
  lastError instanceof Error ? lastError.message : "unknown error"
446
- }`,
484
+ }. The connection ghosted us — come back in a moment 👻`,
447
485
  { providerId, realm: input.realm },
448
486
  "upstream",
449
487
  );
@@ -17,6 +17,18 @@
17
17
  * the console surface treat Qoder like any other daily-grant provider.
18
18
  */
19
19
  import { QODER_REALMS, qoderRealmForProvider } from "./qoder-realm";
20
+ import { getQoderVersion } from "../../operations/client-versions";
21
+ import {
22
+ qoderMachineHeaders,
23
+ type QoderNativeIdentity,
24
+ } from "./qoder-umid";
25
+ import {
26
+ allocateQoderNativeIdentity,
27
+ ensureQoderNativeIdentity,
28
+ type QoderNativeIdentityStore,
29
+ } from "./qoder-native-identity-store";
30
+ import type { NativeIdentityRecord } from "../../../persistence/schema";
31
+ import { log } from "../../../observability/logger";
20
32
 
21
33
  /** Structural fetch bound; Bun's own fetch type carries extra host props. */
22
34
  type Fetcher = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -37,6 +49,36 @@ const CHECKIN_CLAIM_PATH = "/sash/api/v1/me/daily-check-in/claim";
37
49
  const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
38
50
  const CAMPAIGN_CLAIM_PATH = "/sash/api/v1/me/campaigns/%s/claim";
39
51
 
52
+ /**
53
+ * Headers the official desktop client sends to the `/sash` campaign surface.
54
+ * Without these — notably `cosy-clienttype`, `cosy-version`, and the `Qoder`
55
+ * user agent — the upstream hides the campaign list entirely
56
+ * (`showCampaign:false`), so the daily grant never appears. Verified live:
57
+ * the bare Authorization+Accept request returns an empty list, the desktop
58
+ * header set returns `showCampaign:true` with the campaigns populated.
59
+ *
60
+ * `native` optionally carries the UMID machine headers (see `qoder-umid`);
61
+ * when present they unlock the daily 100-credit campaign, which the server
62
+ * otherwise silently filters out.
63
+ */
64
+ export function buildCampaignHeaders(
65
+ credential: string,
66
+ native?: Record<string, string>,
67
+ ): Record<string, string> {
68
+ return {
69
+ Authorization: `Bearer ${credential}`,
70
+ Accept: "application/json",
71
+ "User-Agent": "Qoder",
72
+ "Cosy-ClientType": "10",
73
+ "Cosy-Version": getQoderVersion(),
74
+ ...(native ?? {}),
75
+ };
76
+ }
77
+
78
+ function campaignHeaders(credential: string): Record<string, string> {
79
+ return buildCampaignHeaders(credential);
80
+ }
81
+
40
82
  function asRecord(value: unknown): Record<string, unknown> | null {
41
83
  if (typeof value !== "object" || value === null) return null;
42
84
  return value as Record<string, unknown>;
@@ -80,19 +122,32 @@ function asState(state: string, today: string, lastClaimedAt: unknown): { active
80
122
  return { active, todayCheckedIn: claimedToday };
81
123
  }
82
124
 
83
- /** GET campaign list; returns `null` when the surface is unreachable/unauthorized. */
84
- async function fetchCampaigns(
125
+ /**
126
+ * Normalizes one upstream campaign row to the snake_case shape the claim loop
127
+ * consumes. The API returns camelCase keys (`campaignId`, `actionType`,
128
+ * `claimStatus`); the fixtures and the donor hub use snake_case — accept both.
129
+ */
130
+ function normalizeCampaign(raw: Record<string, unknown>): Record<string, unknown> {
131
+ const benefit = asRecord(raw["benefit"]);
132
+ return {
133
+ campaign_id: String(raw["campaignId"] ?? raw["campaign_id"] ?? ""),
134
+ campaign_key: String(raw["campaignKey"] ?? raw["campaign_key"] ?? ""),
135
+ action_type: String(raw["actionType"] ?? raw["action_type"] ?? ""),
136
+ claim_status: String(raw["claimStatus"] ?? raw["claim_status"] ?? ""),
137
+ ...(benefit !== null ? { benefit } : {}),
138
+ };
139
+ }
140
+
141
+ /** GET campaign list once with given headers; `null` when unreachable/unauthorized. */
142
+ async function getCampaigns(
85
143
  base: string,
86
- credential: string,
144
+ headers: Record<string, string>,
87
145
  fetcher: Fetcher,
88
146
  ): Promise<Record<string, unknown>[] | null> {
89
147
  try {
90
148
  const response = await fetcher(`${base}${CAMPAIGNS_PATH}`, {
91
149
  method: "GET",
92
- headers: {
93
- Authorization: `Bearer ${credential}`,
94
- Accept: "application/json",
95
- },
150
+ headers,
96
151
  signal: AbortSignal.timeout(TIMEOUT_MS),
97
152
  });
98
153
  if (response.status === 401 || response.status === 403) return null;
@@ -100,12 +155,112 @@ async function fetchCampaigns(
100
155
  const payload: unknown = await response.json().catch(() => null);
101
156
  const rec = asRecord(payload);
102
157
  const list = Array.isArray(rec?.["campaigns"]) ? rec!["campaigns"] : Array.isArray(payload) ? payload : null;
103
- return Array.isArray(list) ? (list as Record<string, unknown>[]) : null;
158
+ if (!Array.isArray(list)) return null;
159
+ return (list as Record<string, unknown>[]).map(normalizeCampaign);
104
160
  } catch {
105
161
  return null;
106
162
  }
107
163
  }
108
164
 
165
+ /**
166
+ * One attempted claim across a header set, returning the same result shape as
167
+ * `claimCampaign` (already-claimed grants resolve to `already_claimed`).
168
+ */
169
+ async function tryClaimDaily(
170
+ base: string,
171
+ headers: Record<string, string>,
172
+ fetcher: Fetcher,
173
+ ): Promise<{ state: DailyCheckinState; amount: number; error: string | null }> {
174
+ const campaigns = await getCampaigns(base, headers, fetcher);
175
+ if (campaigns === null) {
176
+ return { state: "error", amount: 0, error: "Campaign list unavailable." };
177
+ }
178
+ // Claim a `CLAIMABLE` CREDITS-kind campaign. Promo rows (`VIEW_DETAILS`,
179
+ // no benefit) are not credit grants and must not be claimed — claiming one
180
+ // spends the account's daily slot for nothing and makes the sweep report a
181
+ // misleading `claimed` with 0 credits.
182
+ const claimable = campaigns.filter((c) => {
183
+ if (String(c["claim_status"] ?? "").toUpperCase() !== "CLAIMABLE") return false;
184
+ const kind = String(asRecord(c["benefit"])?.["kind"] ?? "").toUpperCase();
185
+ const action = String(c["action_type"] ?? "").toUpperCase();
186
+ return kind === "CREDITS" || action === "CLAIM_BENEFIT";
187
+ });
188
+ for (const campaign of claimable) {
189
+ const benefit = asRecord(campaign["benefit"]);
190
+ const campaignId = String(campaign["campaign_id"] ?? "");
191
+ if (!campaignId) continue;
192
+ const claim = await claimWithHeaders(base, campaignId, headers, fetcher);
193
+ if (claim.state === "claimed") {
194
+ const amount = asNumber(benefit?.["amount"]) ?? claim.amount;
195
+ return { state: "claimed", amount, error: null };
196
+ }
197
+ if (claim.state === "already_claimed") return { state: "already_claimed", amount: 0, error: null };
198
+ if (claim.state === "not_eligible") return { state: "not_eligible", amount: 0, error: null };
199
+ return { state: "error", amount: 0, error: "Campaign claim failed." };
200
+ }
201
+ // Nothing claimable. Only a CREDITS-kind campaign that is `CLAIMED` proves
202
+ // today's credit was already taken; a claimed promo (`VIEW_DETAILS`, no
203
+ // benefit) says nothing about the daily grant, so it must not be reported
204
+ // as already_claimed. When the CREDITS campaign is absent entirely, the
205
+ // daily grant is simply not present (or hidden) for this account.
206
+ const anyCreditsClaimed = campaigns.some((c) => {
207
+ if (String(c["claim_status"] ?? "").toUpperCase() !== "CLAIMED") return false;
208
+ const kind = String(asRecord(c["benefit"])?.["kind"] ?? "").toUpperCase();
209
+ const action = String(c["action_type"] ?? "").toUpperCase();
210
+ return kind === "CREDITS" || action === "CLAIM_BENEFIT";
211
+ });
212
+ if (anyCreditsClaimed) return { state: "already_claimed", amount: 0, error: null };
213
+ return { state: "unavailable", amount: 0, error: "No daily CREDITS campaign claimable." };
214
+ }
215
+
216
+ /**
217
+ * Probes the daily CREDITS grant under a native device identity.
218
+ *
219
+ * The server binds the grant to the device identity and hides it once a
220
+ * device claimed today, so an account claims under its own persisted device
221
+ * (minted seed). A bound identity that resolves to `claimed`/`already_claimed`
222
+ * is terminal; one that resolves to `unavailable`/`not_eligible` is honest
223
+ * (the device is spent for the day or the person is blocked) and is also
224
+ * terminal — the account must not move to a different device, because that is
225
+ * how devices are re-spun. Only an *error* (stored identity stopped working)
226
+ * falls back to a freshly minted identity.
227
+ *
228
+ * Returns `{ result, identity }` where `identity` is the device that produced
229
+ * a terminal `claimed`/`already_claimed` outcome (or `null`).
230
+ */
231
+ async function probeDailyClaim(
232
+ base: string,
233
+ credential: string,
234
+ fetcher: Fetcher,
235
+ options: {
236
+ readonly bound?: QoderNativeIdentity | null;
237
+ readonly alt?: QoderNativeIdentity | null;
238
+ } = {},
239
+ ): Promise<{ result: { state: DailyCheckinState; amount: number; error: string | null }; identity: QoderNativeIdentity | null }> {
240
+ const basic = await tryClaimDaily(base, campaignHeaders(credential), fetcher);
241
+ if (basic.state === "claimed" || basic.state === "already_claimed") {
242
+ // `tryClaimDaily` only claims CREDITS-kind campaigns, so a `claimed` or
243
+ // `already_claimed` basic result is already about the daily credit grant —
244
+ // terminal, no identity probe needed.
245
+ return { result: basic, identity: null };
246
+ }
247
+ if (basic.state === "unavailable") {
248
+ const identities = [options.bound, options.alt].filter((i): i is QoderNativeIdentity => i !== null && i !== undefined);
249
+ for (const identity of identities) {
250
+ const headers = { ...campaignHeaders(credential), ...qoderMachineHeaders(identity) };
251
+ const result = await tryClaimDaily(base, headers, fetcher);
252
+ if (result.state === "claimed" || result.state === "already_claimed") {
253
+ return { result, identity };
254
+ }
255
+ // Only an error falls through to the next identity (e.g. the bound one
256
+ // stopped working). Any honest outcome (unavailable/not_eligible) is
257
+ // terminal.
258
+ if (result.state !== "error") return { result, identity };
259
+ }
260
+ }
261
+ return { result: basic, identity: null };
262
+ }
263
+
109
264
  /**
110
265
  * Claims one campaign and returns the resulting state. Mirrors the donor's
111
266
  * `claim_campaign` idempotence handling: a 409 or `ALREADY_CLAIMED` body is
@@ -113,10 +268,10 @@ async function fetchCampaigns(
113
268
  * same-person block (not a failure of this account's day), and anything else
114
269
  * is an error.
115
270
  */
116
- async function claimCampaign(
271
+ async function claimWithHeaders(
117
272
  base: string,
118
273
  campaignId: string,
119
- credential: string,
274
+ headers: Record<string, string>,
120
275
  fetcher: Fetcher,
121
276
  ): Promise<{ state: DailyCheckinState; amount: number }> {
122
277
  const url = base + CAMPAIGN_CLAIM_PATH.replace("%s", encodeURIComponent(campaignId));
@@ -124,11 +279,7 @@ async function claimCampaign(
124
279
  try {
125
280
  response = await fetcher(url, {
126
281
  method: "POST",
127
- headers: {
128
- Authorization: `Bearer ${credential}`,
129
- "Content-Type": "application/json",
130
- Accept: "application/json",
131
- },
282
+ headers: { ...headers, "Content-Type": "application/json" },
132
283
  body: "{}",
133
284
  signal: AbortSignal.timeout(TIMEOUT_MS),
134
285
  });
@@ -233,6 +384,16 @@ export async function fetchQoderDailyCheckin(args: {
233
384
  readonly credential: string;
234
385
  readonly fetcher: Fetcher;
235
386
  readonly now?: () => Date;
387
+ /**
388
+ * The account's persisted native device identity. When absent and
389
+ * `nativeIdentityStore` is provided, the first check-in mints one and
390
+ * persists it (see `allocateQoderNativeIdentity`). When the store is absent
391
+ * too (tests), `identities` supplies the identities to probe.
392
+ */
393
+ readonly nativeIdentity?: NativeIdentityRecord | null;
394
+ readonly nativeIdentityStore?: QoderNativeIdentityStore;
395
+ /** Test-only: override the identity list (defaults to bridge-minted). */
396
+ readonly identities?: readonly QoderNativeIdentity[];
236
397
  }): Promise<DailyCheckinResult> {
237
398
  const { providerId, accountId, credential, fetcher } = args;
238
399
  const baseResult = baseOutcome(providerId, accountId);
@@ -258,46 +419,76 @@ export async function fetchQoderDailyCheckin(args: {
258
419
  };
259
420
  }
260
421
 
261
- // Legacy surface absent: campaign fallback.
262
- const campaigns = await fetchCampaigns(base, credential, fetcher);
263
- if (campaigns === null) {
264
- return { ...baseResult, state: "error", error: "Check-in surface unavailable (legacy 404 and campaign list failed)." };
422
+ // Legacy surface absent: campaign fallback. Prefer the account's persisted
423
+ // device (loaded from the store); on first check-in with no stored device,
424
+ // mint one before probing so the account claims under the same identity
425
+ // every day.
426
+ const store = args.nativeIdentityStore;
427
+ let bound: QoderNativeIdentity | null = null;
428
+ let boundRecord: NativeIdentityRecord | null = args.nativeIdentity ?? null;
429
+ if (store) {
430
+ boundRecord = (await store.get(providerId, accountId)) ?? boundRecord;
265
431
  }
266
- let alreadySeen = false;
267
- for (const campaign of campaigns) {
268
- const rec = asRecord(campaign);
269
- if (!rec) continue;
270
- const benefit = asRecord(rec["benefit"]);
271
- const kind = String(benefit?.["kind"] ?? "").toUpperCase();
272
- if (kind !== "CREDITS" && kind !== "") continue;
273
- const actionType = String(rec["action_type"] ?? "");
274
- if (actionType !== "" && actionType !== "CLAIM_BENEFIT") continue;
275
- const claimStatus = String(rec["claim_status"] ?? "").toUpperCase();
276
- if (claimStatus === "CLAIMED") {
277
- alreadySeen = true;
278
- continue;
279
- }
280
- if (claimStatus !== "CLAIMABLE") continue;
281
- const campaignId = String(rec["campaign_id"] ?? "");
282
- if (!campaignId) continue;
283
- const result = await claimCampaign(base, campaignId, credential, fetcher);
284
- if (result.state === "claimed") {
285
- const amount = asNumber(benefit?.["amount"]) ?? result.amount;
286
- return { ...baseResult, state: "claimed", credit: amount, streakDays: null };
432
+ if (boundRecord) {
433
+ // Ensure the stored device is active on this host (replay its seed).
434
+ const active = store ? await ensureQoderNativeIdentity(store, providerId, accountId) : true;
435
+ if (active) {
436
+ bound = {
437
+ machineToken: boundRecord.machineToken,
438
+ machineType: boundRecord.machineType,
439
+ machineCode: boundRecord.machineCode,
440
+ isVm: false,
441
+ source: "runtime-info",
442
+ };
287
443
  }
288
- if (result.state === "already_claimed") {
289
- alreadySeen = true;
290
- continue;
444
+ }
445
+ if (!bound && store) {
446
+ const minted = await allocateQoderNativeIdentity(store, providerId, accountId);
447
+ if (minted) {
448
+ boundRecord = minted;
449
+ bound = {
450
+ machineToken: minted.machineToken,
451
+ machineType: minted.machineType,
452
+ machineCode: minted.machineCode,
453
+ isVm: false,
454
+ source: "runtime-info",
455
+ };
291
456
  }
292
- if (result.state === "error") {
293
- return { ...baseResult, state: "error", error: "Campaign claim failed." };
457
+ }
458
+
459
+ const alt = args.identities?.[0] ?? null;
460
+ const probe = await probeDailyClaim(base, credential, fetcher, {
461
+ bound,
462
+ alt,
463
+ });
464
+ const probed = probe.result;
465
+
466
+ // If a non-bound identity won (the bound one errored), persist it as the
467
+ // account's device — but only when its token is actually free, so the
468
+ // one-device-per-account invariant holds.
469
+ if (store && probe.identity !== null && probe.identity.machineToken !== bound?.machineToken) {
470
+ const occupied = await store.occupied(accountId);
471
+ if (!occupied.has(probe.identity.machineToken)) {
472
+ try {
473
+ await store.bind(providerId, accountId, {
474
+ machineToken: probe.identity.machineToken,
475
+ machineType: probe.identity.machineType,
476
+ machineCode: probe.identity.machineCode,
477
+ });
478
+ } catch (error) {
479
+ log.warn(`[qoder] could not bind native identity for account=${accountId}`, error as Error);
480
+ }
294
481
  }
295
- // not_eligible (same-person block): stop trying further campaigns.
482
+ }
483
+
484
+ if (probed.state === "claimed") {
485
+ return { ...baseResult, state: "claimed", credit: probed.amount, streakDays: null };
486
+ }
487
+ if (probed.state === "already_claimed") {
488
+ return { ...baseResult, state: "already_claimed", streakDays: null };
489
+ }
490
+ if (probed.state === "not_eligible") {
296
491
  return { ...baseResult, state: "not_eligible", error: "Same-person claim already used today." };
297
492
  }
298
- return {
299
- ...baseResult,
300
- state: alreadySeen ? "already_claimed" : "unavailable",
301
- error: alreadySeen ? null : "No daily CREDITS campaign claimable.",
302
- };
493
+ return { ...baseResult, state: probed.state, error: probed.error };
303
494
  }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Persistence for the Qoder native machine identity (UMID) bound to an
3
+ * account.
4
+ *
5
+ * Same-account probes found that daily 100-credit grant visibility does not
6
+ * depend on machine headers. This store preserves native identities and seeds
7
+ * for deterministic replay and other surfaces that may use device identity.
8
+ * A native identity is a deterministic function of a seed file
9
+ * (`~/.config/.locale_cfg`). The bound record lives in
10
+ * `provider_accounts.native_identity` (plain JSON, not a secret).
11
+ *
12
+ * Uniqueness of the device is enforced by the partial unique index on the
13
+ * extracted `machineToken`; this store keeps the JS side of that internal
14
+ * identity-allocation invariant and surfaces the DB constraint as the backstop.
15
+ */
16
+ import { and, eq, isNotNull, ne } from "drizzle-orm";
17
+ import type { CartethyiaDatabase } from "../../../persistence/db-handle";
18
+ import { providerAccounts, type NativeIdentityRecord } from "../../../persistence/schema";
19
+ import {
20
+ mintQoderNativeIdentity,
21
+ replayQoderNativeIdentity,
22
+ type QoderNativeIdentity,
23
+ } from "./qoder-umid";
24
+
25
+ /** Stateful read/write boundary for bound native identities. */
26
+ export interface QoderNativeIdentityStore {
27
+ /**
28
+ * The identity bound to one account, or `undefined` when the account has
29
+ * no identity yet.
30
+ */
31
+ get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined>;
32
+ /**
33
+ * Machine tokens already bound to **other** accounts, so a caller can mint
34
+ * a fresh identity without colliding. A bound token represents a device identity, and
35
+ * the unique index that backs this spans all `provider_accounts` rows. The
36
+ * account's own bound token is not "occupied" — reusing one's own device is
37
+ * the point.
38
+ */
39
+ occupied(accountId: string): Promise<ReadonlySet<string>>;
40
+ /** Binds an identity to an account (upsert). Throws on a device collision. */
41
+ bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void>;
42
+ }
43
+
44
+ export class DrizzleQoderNativeIdentityStore implements QoderNativeIdentityStore {
45
+ constructor(private readonly db: CartethyiaDatabase) {}
46
+
47
+ async get(providerId: string, accountId: string): Promise<NativeIdentityRecord | undefined> {
48
+ const rows = await this.db
49
+ .select({ nativeIdentity: providerAccounts.nativeIdentity })
50
+ .from(providerAccounts)
51
+ .where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)))
52
+ .limit(1);
53
+ const row = rows[0];
54
+ return row?.nativeIdentity ?? undefined;
55
+ }
56
+
57
+ async occupied(accountId: string): Promise<ReadonlySet<string>> {
58
+ const rows = await this.db
59
+ .select({ nativeIdentity: providerAccounts.nativeIdentity })
60
+ .from(providerAccounts)
61
+ .where(
62
+ accountId
63
+ ? and(isNotNull(providerAccounts.nativeIdentity), ne(providerAccounts.id, accountId))
64
+ : isNotNull(providerAccounts.nativeIdentity),
65
+ );
66
+ const occupied = new Set<string>();
67
+ for (const row of rows) {
68
+ const token = row.nativeIdentity?.machineToken;
69
+ if (token !== undefined && token !== "") occupied.add(token);
70
+ }
71
+ return occupied;
72
+ }
73
+
74
+ async bind(providerId: string, accountId: string, record: NativeIdentityRecord): Promise<void> {
75
+ await this.db
76
+ .update(providerAccounts)
77
+ .set({ nativeIdentity: record })
78
+ .where(and(eq(providerAccounts.id, accountId), eq(providerAccounts.providerId, providerId)));
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Serializes identity minting across accounts in this process, so two
84
+ * concurrent allocations cannot interleave seed swaps.
85
+ */
86
+ let allocationQueue: Promise<unknown> = Promise.resolve();
87
+ function withAllocationLock<T>(work: () => Promise<T>): Promise<T> {
88
+ const next = allocationQueue.then(work, work);
89
+ allocationQueue = next.catch(() => undefined);
90
+ return next;
91
+ }
92
+
93
+ /** Identity minting injected for tests; defaults to the real bridge. */
94
+ export type NativeIdentityMinter = (accountId: string) => { identity: QoderNativeIdentity; seed: string } | null;
95
+ export type NativeIdentityReplayer = (seed: string, accountId: string) => QoderNativeIdentity | null;
96
+
97
+ const defaultMinter: NativeIdentityMinter = (accountId) => mintQoderNativeIdentity(accountId);
98
+ const defaultReplayer: NativeIdentityReplayer = (seed, accountId) => replayQoderNativeIdentity(seed, accountId);
99
+
100
+ /**
101
+ * Mints and binds a fresh device identity for an account: runs the bridge
102
+ * with a cleared seed, captures the new seed, and persists both. Returns the
103
+ * bound record, or `null` when the bridge fails.
104
+ *
105
+ * `avoidToken` optionally excludes the account's own current token so a
106
+ * regenerate moves to a *different* device.
107
+ */
108
+ export function allocateQoderNativeIdentity(
109
+ store: QoderNativeIdentityStore,
110
+ providerId: string,
111
+ accountId: string,
112
+ options: {
113
+ readonly avoidToken?: string;
114
+ readonly mint?: NativeIdentityMinter;
115
+ readonly replayer?: NativeIdentityReplayer;
116
+ } = {},
117
+ ): Promise<NativeIdentityRecord | null> {
118
+ const mint = options.mint ?? defaultMinter;
119
+ return withAllocationLock(async () => {
120
+ const occupied = await store.occupied(accountId);
121
+ for (let i = 0; i < 6; i += 1) {
122
+ const minted = mint(accountId);
123
+ if (!minted) return null;
124
+ const token = minted.identity.machineToken;
125
+ if (occupied.has(token)) continue; // collision (rare) → mint again
126
+ if (token === options.avoidToken) continue;
127
+ const record: NativeIdentityRecord = {
128
+ machineToken: token,
129
+ machineType: minted.identity.machineType,
130
+ machineCode: minted.identity.machineCode,
131
+ ...(minted.seed ? { seed: minted.seed } : {}),
132
+ source: minted.identity.source,
133
+ };
134
+ await store.bind(providerId, accountId, record);
135
+ return record;
136
+ }
137
+ return null;
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Ensures the bound identity is currently active on this host: replays the
143
+ * stored seed so the bridge yields the same token. Returns `false` when the
144
+ * account has no bound identity or replay failed.
145
+ */
146
+ export async function ensureQoderNativeIdentity(
147
+ store: QoderNativeIdentityStore,
148
+ providerId: string,
149
+ accountId: string,
150
+ replayer: NativeIdentityReplayer = defaultReplayer,
151
+ ): Promise<boolean> {
152
+ const record = await store.get(providerId, accountId);
153
+ if (!record) return false;
154
+ if (!record.seed) return true; // no seed captured → assume already active
155
+ const identity = replayer(record.seed, accountId);
156
+ return identity !== null && identity.machineToken === record.machineToken;
157
+ }