cartethyia 2.5.1 → 2.5.3

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 (235) hide show
  1. package/.env.example +5 -1
  2. package/dist/dashboard/assets/{AccountCooldown-ChpdzxRH.js → AccountCooldown-Bjo92Qat.js} +1 -1
  3. package/dist/dashboard/assets/AccountCooldown-Bjo92Qat.js.gz +0 -0
  4. package/dist/dashboard/assets/{BannedPage-ChqnTRpZ.js → BannedPage-hfCzi-Ux.js} +1 -1
  5. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +1 -0
  7. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BXUaRCQQ.js → CliToolsPage-DEDyKBlC.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  14. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js +1 -0
  15. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  18. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js +1 -0
  19. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  26. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +1 -0
  27. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  28. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +7 -0
  29. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  30. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +1 -0
  31. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  32. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
  33. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  34. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js +1 -0
  35. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  36. package/dist/dashboard/assets/SortableList-DWvKiuSl.js +1 -0
  37. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-DqK5E26W.js → StudioPage-CmkUrgdj.js} +3 -3
  39. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  40. package/dist/dashboard/assets/UsagePage-B47uOydr.js +56 -0
  41. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BmDbsior.js → api-keys-uNywYlfv.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  44. package/dist/dashboard/assets/{app-C-Y4oZT0.js → app-CafWtVXA.js} +1 -1
  45. package/dist/dashboard/assets/{app-C-Y4oZT0.js.gz → app-CafWtVXA.js.gz} +0 -0
  46. package/dist/dashboard/assets/app-DxivpfYG.css +2 -0
  47. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  48. package/dist/dashboard/assets/{assert-BHfaei-X.js → assert-NHwYq95t.js} +1 -1
  49. package/dist/dashboard/assets/{assert-BHfaei-X.js.gz → assert-NHwYq95t.js.gz} +0 -0
  50. package/dist/dashboard/assets/badge-6uzDDvqs.js +1 -0
  51. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  52. package/dist/dashboard/assets/banner-4_WNg2WZ.js +1 -0
  53. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  54. package/dist/dashboard/assets/card--AtFedAq.js +1 -0
  55. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  56. package/dist/dashboard/assets/{cli-tools-B0wBq-RL.js → cli-tools-B3e7dx3N.js} +1 -1
  57. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  58. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js +1 -0
  59. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  60. package/dist/dashboard/assets/dialog-BtpsJU98.js +1 -0
  61. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  62. package/dist/dashboard/assets/drawer-WwwtzihF.js +1 -0
  63. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CkryrIea.js +4 -0
  65. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-TJIye_jT.js +1 -0
  67. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  68. package/dist/dashboard/assets/{entry-D4O2229X.js → entry-jy9_4-Ke.js} +1 -1
  69. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  70. package/dist/dashboard/assets/github-badge-D_eM3IgG.js +1 -0
  71. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  72. package/dist/dashboard/assets/{index-CcD1eSJ6.js → index-BydyKaDz.js} +2 -2
  73. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  74. package/dist/dashboard/assets/inline-BcNGF8Gr.js +1 -0
  75. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  76. package/dist/dashboard/assets/input-BG41et9G.js +1 -0
  77. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  78. package/dist/dashboard/assets/layout-C8mer_Ak.js +1 -0
  79. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  80. package/dist/dashboard/assets/list-row-Gh_W4OR_.js +1 -0
  81. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  82. package/dist/dashboard/assets/{live-yfEJFF9T.js → live-BJGYRBnR.js} +1 -1
  83. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  84. package/dist/dashboard/assets/{model-bans-CT-TxSNX.js → model-bans-DeVLDA7I.js} +1 -1
  85. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  86. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +2 -0
  87. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-Cs6jl49L.js → routing-CcusqXA7.js} +1 -1
  89. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  90. package/dist/dashboard/assets/select-BO6h8yNv.js +41 -0
  91. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  92. package/dist/dashboard/assets/{settings-CfcTcOyU.js → settings-mpmXTgUa.js} +1 -1
  93. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  94. package/dist/dashboard/assets/stack-DwLmjUFK.js +1 -0
  95. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  96. package/dist/dashboard/assets/state-DYdQmdAl.js +1 -0
  97. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  98. package/dist/dashboard/assets/switch-CidSfbG7.js +1 -0
  99. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-BQKMcJ1H.js → useMutation-w4u4CVue.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -54
  105. package/package.json +1 -1
  106. package/scripts/cli/command/console-menu.ts +248 -246
  107. package/scripts/cli/command/tray.ts +208 -204
  108. package/src/boot/app.ts +3 -2
  109. package/src/config/spec.ts +26 -2
  110. package/src/console/backup/routes.ts +52 -1
  111. package/src/console/backup/service.ts +60 -4
  112. package/src/console/observability/contracts.ts +11 -1
  113. package/src/console/observability/store.ts +9 -6
  114. package/src/console/share/share-router.ts +6 -9
  115. package/src/observability/payload-capture.ts +366 -344
  116. package/src/observability/payload-store.ts +12 -2
  117. package/src/observability/telemetry-status.ts +28 -16
  118. package/src/persistence/stores/api-key-store.ts +6 -0
  119. package/src/persistence/stores/share-store.ts +583 -556
  120. package/src/persistence/stores/telemetry-store.ts +1 -1
  121. package/src/protocol/response/gemini.ts +18 -4
  122. package/src/providers/discovery/openrouter-free-roster.ts +3 -11
  123. package/src/providers/discovery/probe-phases.ts +42 -5
  124. package/src/providers/discovery/probing-service.ts +25 -2
  125. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  126. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  127. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  128. package/src/providers/integrations/claude/claude.ts +3 -1
  129. package/src/providers/integrations/cline/cline-quota.ts +8 -11
  130. package/src/providers/integrations/cloudflareai/cloudflareai-models.ts +3 -1
  131. package/src/providers/integrations/inferhub.ts +3 -1
  132. package/src/providers/integrations/kilo/kilo.ts +0 -1
  133. package/src/providers/integrations/kiro/kiro-catalog.ts +8 -2
  134. package/src/providers/integrations/opencode/provider.ts +532 -532
  135. package/src/providers/integrations/openrouter/provider.ts +5 -15
  136. package/src/providers/integrations/tokenharbor.ts +2 -2
  137. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  138. package/src/providers/operations/account-health-service.ts +23 -0
  139. package/src/providers/operations/client-versions.ts +9 -2
  140. package/src/providers/provider-metadata.ts +7 -1
  141. package/src/security/api-key-auth.ts +23 -0
  142. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  143. package/src/transport/dispatch/proxy-request.ts +9 -0
  144. package/src/transport/dispatch/streaming/stream.ts +16 -2
  145. package/src/transport/guards/auth-guard.ts +2 -3
  146. package/src/transport/routing/route-catalog.ts +806 -799
  147. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  148. package/dist/dashboard/assets/BannedPage-ChqnTRpZ.js.gz +0 -0
  149. package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js +0 -1
  150. package/dist/dashboard/assets/CliToolDetailPage-Bb9CNuoh.js.gz +0 -0
  151. package/dist/dashboard/assets/CliToolsPage-BXUaRCQQ.js.gz +0 -0
  152. package/dist/dashboard/assets/CombosPage-CisNZOUt.js +0 -1
  153. package/dist/dashboard/assets/CombosPage-CisNZOUt.js.gz +0 -0
  154. package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js +0 -1
  155. package/dist/dashboard/assets/ConsoleLogPage-BOpI_LOH.js.gz +0 -0
  156. package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js +0 -1
  157. package/dist/dashboard/assets/CustomizationPage-DAkdvJ4k.js.gz +0 -0
  158. package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js +0 -1
  159. package/dist/dashboard/assets/HealthEventsModal-DeT-VXHd.js.gz +0 -0
  160. package/dist/dashboard/assets/LoginPage-CytxSeGP.js +0 -1
  161. package/dist/dashboard/assets/LoginPage-CytxSeGP.js.gz +0 -0
  162. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +0 -1
  163. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  164. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js +0 -1
  165. package/dist/dashboard/assets/OverviewPage-CU7S1wJs.js.gz +0 -0
  166. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js +0 -3
  167. package/dist/dashboard/assets/ProviderDetailPage-DLm2baot.js.gz +0 -0
  168. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js +0 -1
  169. package/dist/dashboard/assets/ProvidersPage-DOXfuzfL.js.gz +0 -0
  170. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js +0 -7
  171. package/dist/dashboard/assets/ProxyPage-wvk0ciQN.js.gz +0 -0
  172. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js +0 -1
  173. package/dist/dashboard/assets/QuotaPage-Cn_fcUHE.js.gz +0 -0
  174. package/dist/dashboard/assets/SettingsPage-8-eVadhc.js +0 -1
  175. package/dist/dashboard/assets/SettingsPage-8-eVadhc.js.gz +0 -0
  176. package/dist/dashboard/assets/SetupPage-uEIXPyGO.js +0 -1
  177. package/dist/dashboard/assets/SetupPage-uEIXPyGO.js.gz +0 -0
  178. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js +0 -1
  179. package/dist/dashboard/assets/SortableList-DCY0TPTZ.js.gz +0 -0
  180. package/dist/dashboard/assets/StudioPage-DqK5E26W.js.gz +0 -0
  181. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js +0 -56
  182. package/dist/dashboard/assets/UsagePage-38JfiZ0H.js.gz +0 -0
  183. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  184. package/dist/dashboard/assets/app-BbpSarVV.css +0 -2
  185. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  186. package/dist/dashboard/assets/badge-DpXT4Ixt.js +0 -1
  187. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  188. package/dist/dashboard/assets/banner-CSAAWLJv.js +0 -1
  189. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  190. package/dist/dashboard/assets/card-DEhRXXw4.js +0 -1
  191. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  192. package/dist/dashboard/assets/check-D69FzdvC.js +0 -1
  193. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  194. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  195. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +0 -1
  196. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  197. package/dist/dashboard/assets/copy-ngmQ9zcZ.js +0 -1
  198. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  199. package/dist/dashboard/assets/dialog-BvQeBMfP.js +0 -1
  200. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  201. package/dist/dashboard/assets/drawer-CMwPXwQB.js +0 -1
  202. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  203. package/dist/dashboard/assets/entry--yj84zk3.js +0 -4
  204. package/dist/dashboard/assets/entry--yj84zk3.js.gz +0 -0
  205. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  206. package/dist/dashboard/assets/entry-DKvr5Q9K.js +0 -1
  207. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  208. package/dist/dashboard/assets/github-badge-zliLvBwr.js +0 -1
  209. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  210. package/dist/dashboard/assets/index-CcD1eSJ6.js.gz +0 -0
  211. package/dist/dashboard/assets/inline-D1IlZZ8H.js +0 -1
  212. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  213. package/dist/dashboard/assets/input-BpVZdrbc.js +0 -1
  214. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  215. package/dist/dashboard/assets/layout-BioLW7gN.js +0 -1
  216. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  217. package/dist/dashboard/assets/list-row-CwVcGAIc.js +0 -1
  218. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  219. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  220. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  221. package/dist/dashboard/assets/numbers-CFL4_Dqc.js +0 -1
  222. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  223. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  224. package/dist/dashboard/assets/select-XPJ-dIw3.js +0 -41
  225. package/dist/dashboard/assets/select-XPJ-dIw3.js.gz +0 -0
  226. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  227. package/dist/dashboard/assets/stack-C9_uUjaA.js +0 -1
  228. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  229. package/dist/dashboard/assets/state-DA0B95UQ.js +0 -1
  230. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  231. package/dist/dashboard/assets/switch-Cittxy-x.js +0 -1
  232. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  233. package/dist/dashboard/assets/toast-CNt_jzO4.js +0 -2
  234. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  235. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
@@ -1,204 +1,208 @@
1
- import { isNewerRelease } from "../../../src/console/updates/version-compare";
2
- import { resolvePackagePath } from "../../../src/config/package-paths";
3
- import { resolvePort } from "../../../src/config/runtime";
4
- import { ensureDrainToken } from "../gateway/drain-token";
5
- import { startGateway } from "../gateway/spawn";
6
- import { stopGateway } from "../gateway/stop";
7
- import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
8
- import { reportTrayStartup } from "../support/startup-signal";
9
- import { createTray } from "../tray";
10
- import { openUrl } from "../ui/background";
11
- import { exportBackup, resetConsoleAccount } from "./admin";
12
- import { printUpdateInstructions } from "./update";
13
-
14
- /**
15
- * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
16
- * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
17
- * an empty console tab open for the tray's whole lifetime, and that window is
18
- * owned by Windows Terminal — not by this process, so it cannot be found and
19
- * hidden from inside.
20
- *
21
- * The gateway it starts is detached, which is what makes the health poll below
22
- * necessary — see {@link TRAY_HEALTH_POLL_MS}.
23
- */
24
-
25
- /**
26
- * How often the tray asks whether the gateway is still answering.
27
- *
28
- * 10 s: often enough that an operator who stops the gateway by hand sees the
29
- * icon go away, and far below the cost of a request. A detached gateway can no
30
- * longer be waited on, so this poll is the only signal left.
31
- */
32
- const TRAY_HEALTH_POLL_MS = 10_000;
33
-
34
- /**
35
- * Consecutive misses to forgive before a dead gateway closes the tray.
36
- *
37
- * One refusal is not a stopped gateway — a probe can lose a race with startup
38
- * or a slow request — and closing the icon on one would make startup feel
39
- * random.
40
- */
41
- const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
-
43
- export async function startTray(): Promise<void> {
44
- const port = resolvePort();
45
- const drainToken = ensureDrainToken();
46
- const dashboardUrl = `http://127.0.0.1:${port}/console`;
47
- const repositoryUrl = "https://github.com/risunCode/cartethyia";
48
- const currentVersion = await readInstalledVersion();
49
-
50
- let gateway: Bun.Subprocess | null = null;
51
- let closing = false;
52
- let busy = false;
53
- let resetArmed = false;
54
- let latestVersion: string | null = null;
55
- let closePromise: Promise<void> | undefined;
56
-
57
- const tray = createTray({
58
- iconPath: resolvePackagePath(
59
- "scripts",
60
- "cli",
61
- "tray",
62
- "assets",
63
- process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
64
- ),
65
- tooltip: `Cartethyia ${currentVersion}`,
66
- items: [
67
- { kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
68
- { kind: "separator" },
69
- { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
- { kind: "action", id: "reset", title: "Reset console password" },
71
- { kind: "action", id: "backup", title: "Backup" },
72
- { kind: "separator" },
73
- { kind: "action", id: "exit", title: "Exit" },
74
- ],
75
- onSelect: (id: string) => void handleSelect(id),
76
- onExit: () => void close(1),
77
- });
78
-
79
- const close = (exitCode = 0): Promise<void> => {
80
- if (closePromise) return closePromise;
81
- closing = true;
82
- closePromise = (async () => {
83
- const current: Bun.Subprocess | null = gateway;
84
- gateway = null;
85
- if (current) await stopGateway(current, port, drainToken);
86
- await tray.close();
87
- process.exitCode = exitCode;
88
- })();
89
- return closePromise;
90
- };
91
-
92
- const refreshVersion = async (): Promise<void> => {
93
- const fetched = await readLatestPublishedVersion();
94
- if (closing) return;
95
- latestVersion = fetched;
96
- if (fetched === null) {
97
- tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
98
- return;
99
- }
100
- const newer = isNewerRelease(currentVersion, fetched);
101
- tray.update(
102
- "update",
103
- newer
104
- ? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
105
- : `Cartethyia ${currentVersion} \u00b7 up to date`,
106
- // Always clickable: the row opens the repository, and only offers the
107
- // update command when there is something newer.
108
- true,
109
- );
110
- };
111
-
112
- const runUpdate = async (): Promise<void> => {
113
- if (busy) return;
114
- busy = true;
115
- const latest = await readLatestPublishedVersion();
116
- await close();
117
- printUpdateInstructions(currentVersion, latest);
118
- process.exit(0);
119
- };
120
-
121
- async function handleSelect(id: string): Promise<void> {
122
- if (closing) return;
123
- if (id === "exit") return void (await close());
124
- if (id === "console") return void (await openUrl(dashboardUrl));
125
- if (id === "backup") {
126
- const result = await exportBackup(port, drainToken);
127
- if (result !== null) {
128
- console.log(
129
- `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
130
- );
131
- }
132
- return;
133
- }
134
- if (id === "reset") {
135
- // A tray click cannot ask for the typed confirmation the menu uses, so
136
- // the first click arms the action and the second one runs it.
137
- if (!resetArmed) {
138
- resetArmed = true;
139
- tray.update("reset", "Reset console password \u00b7 click again to delete", true);
140
- return;
141
- }
142
- resetArmed = false;
143
- tray.update("reset", "Reset console password", true);
144
- await resetConsoleAccount(port, drainToken);
145
- return;
146
- }
147
- if (id === "update") {
148
- // Nothing to install: the row is the way to the repository instead.
149
- if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
150
- await openUrl(repositoryUrl);
151
- return;
152
- }
153
- await runUpdate();
154
- }
155
- }
156
-
157
- process.once("SIGINT", () => void close());
158
- process.once("SIGTERM", () => void close());
159
-
160
- try {
161
- await tray.ready();
162
- // Detached, unlike every other caller: the gateway must outlive this
163
- // process. Attached, it dies with the tray — and the tray dies with the
164
- // terminal that launched it, so closing that terminal took the gateway
165
- // down too and "Hide to tray" left nothing serving. `stopGateway` drains
166
- // over HTTP before it ever reaches for `kill()`, so detaching costs the
167
- // exit path nothing: the tray can still stop the gateway on Exit.
168
- gateway = await startGateway(port, drainToken, { detached: true });
169
- // The launcher that spawned this tray is blocked on a signal file: it
170
- // cannot read our stdio, because the tray is detached. Without this write
171
- // it waits out its 60 s deadline and reports a failure for a tray that is
172
- // running fine.
173
- reportTrayStartup({ status: "ready", url: dashboardUrl });
174
- console.log(`Cartethyia is running at ${dashboardUrl}`);
175
- void refreshVersion();
176
-
177
- let misses = 0;
178
- // A detached child's `exited` never settles on Windows, so the old
179
- // `await current.exited` would have parked here forever and the tray would
180
- // never notice the gateway going away. Poll its health endpoint instead:
181
- // that answers for a process we no longer own, which is the whole point of
182
- // detaching it. The probe is the same one startup used, so a gateway that
183
- // is up reads as up regardless of who spawned it.
184
- while (!closing) {
185
- await Bun.sleep(TRAY_HEALTH_POLL_MS);
186
- if (closing || gateway === null) continue;
187
- const alive = await fetch(`http://127.0.0.1:${port}/health`, {
188
- signal: AbortSignal.timeout(2_000),
189
- }).then((response) => response.ok).catch(() => false);
190
- if (alive) {
191
- misses = 0;
192
- continue;
193
- }
194
- misses += 1;
195
- if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
196
- gateway = null;
197
- console.error("Cartethyia stopped answering; the tray is closing.");
198
- await close(1);
199
- }
200
- } catch (error) {
201
- await close(1);
202
- throw error;
203
- }
204
- }
1
+ import { isNewerRelease } from "../../../src/console/updates/version-compare";
2
+ import { resolvePackagePath } from "../../../src/config/package-paths";
3
+ import { resolvePort } from "../../../src/config/runtime";
4
+ import { ensureDrainToken } from "../gateway/drain-token";
5
+ import { startGateway } from "../gateway/spawn";
6
+ import { stopGateway } from "../gateway/stop";
7
+ import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
8
+ import { reportTrayStartup } from "../support/startup-signal";
9
+ import { createTray } from "../tray";
10
+ import { openUrl } from "../ui/background";
11
+ import { exportBackup, resetConsoleAccount } from "./admin";
12
+ import { printUpdateInstructions } from "./update";
13
+
14
+ /**
15
+ * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
16
+ * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
17
+ * an empty console tab open for the tray's whole lifetime, and that window is
18
+ * owned by Windows Terminal — not by this process, so it cannot be found and
19
+ * hidden from inside.
20
+ *
21
+ * The gateway it starts is detached, which is what makes the health poll below
22
+ * necessary — see {@link TRAY_HEALTH_POLL_MS}.
23
+ */
24
+
25
+ /**
26
+ * How often the tray asks whether the gateway is still answering.
27
+ *
28
+ * 10 s: often enough that an operator who stops the gateway by hand sees the
29
+ * icon go away, and far below the cost of a request. A detached gateway can no
30
+ * longer be waited on, so this poll is the only signal left.
31
+ */
32
+ const TRAY_HEALTH_POLL_MS = 10_000;
33
+
34
+ /**
35
+ * Consecutive misses to forgive before a dead gateway closes the tray.
36
+ *
37
+ * One refusal is not a stopped gateway — a probe can lose a race with startup
38
+ * or a slow request — and closing the icon on one would make startup feel
39
+ * random.
40
+ */
41
+ const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
+
43
+ export async function startTray(): Promise<void> {
44
+ const port = resolvePort();
45
+ const drainToken = ensureDrainToken();
46
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
47
+ const repositoryUrl = "https://github.com/risunCode/cartethyia";
48
+ const currentVersion = await readInstalledVersion();
49
+
50
+ let gateway: Bun.Subprocess | null = null;
51
+ let closing = false;
52
+ let busy = false;
53
+ let resetArmed = false;
54
+ let latestVersion: string | null = null;
55
+ let closePromise: Promise<void> | undefined;
56
+
57
+ const tray = createTray({
58
+ iconPath: resolvePackagePath(
59
+ "scripts",
60
+ "cli",
61
+ "tray",
62
+ "assets",
63
+ process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
64
+ ),
65
+ tooltip: `Cartethyia ${currentVersion}`,
66
+ items: [
67
+ { kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
68
+ { kind: "separator" },
69
+ { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
+ { kind: "action", id: "reset", title: "Reset console password" },
71
+ { kind: "action", id: "backup", title: "Backup" },
72
+ { kind: "separator" },
73
+ { kind: "action", id: "exit", title: "Exit" },
74
+ ],
75
+ onSelect: (id: string) => void handleSelect(id),
76
+ onExit: () => void close(1),
77
+ });
78
+
79
+ const close = (exitCode = 0): Promise<void> => {
80
+ if (closePromise) return closePromise;
81
+ closing = true;
82
+ closePromise = (async () => {
83
+ const current: Bun.Subprocess | null = gateway;
84
+ gateway = null;
85
+ if (current) await stopGateway(current, port, drainToken);
86
+ await tray.close();
87
+ process.exitCode = exitCode;
88
+ })();
89
+ return closePromise;
90
+ };
91
+
92
+ const refreshVersion = async (): Promise<void> => {
93
+ const fetched = await readLatestPublishedVersion();
94
+ if (closing) return;
95
+ latestVersion = fetched;
96
+ if (fetched === null) {
97
+ tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
98
+ return;
99
+ }
100
+ const newer = isNewerRelease(currentVersion, fetched);
101
+ tray.update(
102
+ "update",
103
+ newer
104
+ ? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
105
+ : `Cartethyia ${currentVersion} \u00b7 up to date`,
106
+ // Always clickable: the row opens the repository, and only offers the
107
+ // update command when there is something newer.
108
+ true,
109
+ );
110
+ };
111
+
112
+ const runUpdate = async (): Promise<void> => {
113
+ if (busy) return;
114
+ busy = true;
115
+ const latest = await readLatestPublishedVersion();
116
+ await close();
117
+ printUpdateInstructions(currentVersion, latest);
118
+ // The tray has no terminal of its own, so these instructions are only
119
+ // visible when it was started from one. Hold that terminal open long
120
+ // enough to copy the command, then exit.
121
+ await new Promise<void>((resolve) => setTimeout(resolve, 180_000));
122
+ process.exit(0);
123
+ };
124
+
125
+ async function handleSelect(id: string): Promise<void> {
126
+ if (closing) return;
127
+ if (id === "exit") return void (await close());
128
+ if (id === "console") return void (await openUrl(dashboardUrl));
129
+ if (id === "backup") {
130
+ const result = await exportBackup(port, drainToken);
131
+ if (result !== null) {
132
+ console.log(
133
+ `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
134
+ );
135
+ }
136
+ return;
137
+ }
138
+ if (id === "reset") {
139
+ // A tray click cannot ask for the typed confirmation the menu uses, so
140
+ // the first click arms the action and the second one runs it.
141
+ if (!resetArmed) {
142
+ resetArmed = true;
143
+ tray.update("reset", "Reset console password \u00b7 click again to delete", true);
144
+ return;
145
+ }
146
+ resetArmed = false;
147
+ tray.update("reset", "Reset console password", true);
148
+ await resetConsoleAccount(port, drainToken);
149
+ return;
150
+ }
151
+ if (id === "update") {
152
+ // Nothing to install: the row is the way to the repository instead.
153
+ if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
154
+ await openUrl(repositoryUrl);
155
+ return;
156
+ }
157
+ await runUpdate();
158
+ }
159
+ }
160
+
161
+ process.once("SIGINT", () => void close());
162
+ process.once("SIGTERM", () => void close());
163
+
164
+ try {
165
+ await tray.ready();
166
+ // Detached, unlike every other caller: the gateway must outlive this
167
+ // process. Attached, it dies with the tray — and the tray dies with the
168
+ // terminal that launched it, so closing that terminal took the gateway
169
+ // down too and "Hide to tray" left nothing serving. `stopGateway` drains
170
+ // over HTTP before it ever reaches for `kill()`, so detaching costs the
171
+ // exit path nothing: the tray can still stop the gateway on Exit.
172
+ gateway = await startGateway(port, drainToken, { detached: true });
173
+ // The launcher that spawned this tray is blocked on a signal file: it
174
+ // cannot read our stdio, because the tray is detached. Without this write
175
+ // it waits out its 60 s deadline and reports a failure for a tray that is
176
+ // running fine.
177
+ reportTrayStartup({ status: "ready", url: dashboardUrl });
178
+ console.log(`Cartethyia is running at ${dashboardUrl}`);
179
+ void refreshVersion();
180
+
181
+ let misses = 0;
182
+ // A detached child's `exited` never settles on Windows, so the old
183
+ // `await current.exited` would have parked here forever and the tray would
184
+ // never notice the gateway going away. Poll its health endpoint instead:
185
+ // that answers for a process we no longer own, which is the whole point of
186
+ // detaching it. The probe is the same one startup used, so a gateway that
187
+ // is up reads as up regardless of who spawned it.
188
+ while (!closing) {
189
+ await Bun.sleep(TRAY_HEALTH_POLL_MS);
190
+ if (closing || gateway === null) continue;
191
+ const alive = await fetch(`http://127.0.0.1:${port}/health`, {
192
+ signal: AbortSignal.timeout(2_000),
193
+ }).then((response) => response.ok).catch(() => false);
194
+ if (alive) {
195
+ misses = 0;
196
+ continue;
197
+ }
198
+ misses += 1;
199
+ if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
200
+ gateway = null;
201
+ console.error("Cartethyia stopped answering; the tray is closing.");
202
+ await close(1);
203
+ }
204
+ } catch (error) {
205
+ await close(1);
206
+ throw error;
207
+ }
208
+ }
package/src/boot/app.ts CHANGED
@@ -15,6 +15,7 @@ import { GatewayError, publicGatewayErrorBody } from "../protocol/gateway-error"
15
15
  import { shutdownError, shutdownNotice } from "../transport/shutdown-notice";
16
16
  import type { CanonicalAdapter } from "../transport/contracts";
17
17
  import type { ApiKeyAuthorizationSnapshot } from "../security/api-key-auth";
18
+ import { unknownApiKeyError } from "../security/api-key-auth";
18
19
  import { getDbHandle } from "../persistence/boot";
19
20
  import { isPgHandle } from "../persistence/db-pg";
20
21
  import { resolveRedisClient } from "../persistence/redis";
@@ -408,7 +409,7 @@ export function createGatewayApp(deps: GatewayAppDeps) {
408
409
  const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
409
410
  const state = requestStateStore.require(request);
410
411
  const auth = state.authorization;
411
- if (!auth) throw new GatewayError("invalid_request", 401, "This API key is unknown, revoked, or expired. Issue a new key and send it as: Authorization: Bearer <key>.");
412
+ if (!auth) throw unknownApiKeyError();
412
413
  const data = await queryAllowedModels(auth);
413
414
  return new Response(JSON.stringify({ object: "list", data }), {
414
415
  headers: {
@@ -431,7 +432,7 @@ export function createGatewayApp(deps: GatewayAppDeps) {
431
432
  }): Promise<Response> => {
432
433
  const state = requestStateStore.require(request);
433
434
  const auth = state.authorization;
434
- if (!auth) throw new GatewayError("invalid_request", 401, "This API key is unknown, revoked, or expired. Issue a new key and send it as: Authorization: Bearer <key>.");
435
+ if (!auth) throw unknownApiKeyError();
435
436
  const raw = params?.["*"] ?? params?.id ?? "";
436
437
  const targetId = new URL(request.url).searchParams.get("id") ?? raw;
437
438
  const found = await publicModelStore.getPublicModelDetail(
@@ -157,7 +157,14 @@ export const CONFIG_SPEC = {
157
157
  },
158
158
 
159
159
  // Upstream request timeout + retry backoff
160
- CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 120_000, min: 5_000, max: 600_000 },
160
+ //
161
+ // Strictly under 90s: a client whose own deadline is 90s must never be
162
+ // waiting on a gateway that is still holding a request open past it, and a
163
+ // provider that has not answered within 75s is not answering — the request
164
+ // is queued upstream or the egress is stuck, and the honest move is to fail
165
+ // over to another account rather than burn the caller's whole budget on one.
166
+ // 120s outlived the clients that call this, which cap lower.
167
+ CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 75_000, min: 5_000, max: 600_000 },
161
168
  CARTETHYIA_STREAM_STALL_TIMEOUT_MS: {
162
169
  kind: "int",
163
170
  default: 360_000,
@@ -181,7 +188,24 @@ export const CONFIG_SPEC = {
181
188
  CARTETHYIA_ACCOUNT_QUOTA_COOLDOWN_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
182
189
  CARTETHYIA_ACCOUNT_MODEL_CAPACITY_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
183
190
  CARTETHYIA_ACCOUNT_TRANSIENT_COOLDOWN_MS: { kind: "int", default: 30_000, min: 0, max: 86_400_000 },
184
- CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: { kind: "int", default: 60_000, min: 0, max: 86_400_000 },
191
+ /**
192
+ * An unmapped upstream failure cools the account down for 15 minutes.
193
+ *
194
+ * It used to be 60s, which put the account straight back into rotation and
195
+ * re-dialled an upstream that had just answered something this gateway does
196
+ * not understand — so the same unknown error was retried every minute, and
197
+ * an account that was actually broken contributed a steady stream of failed
198
+ * attempts instead of one. Fifteen minutes is long enough for a transient
199
+ * provider condition to clear on its own and short enough that no operator
200
+ * action is needed to recover it. It is still a cooldown with a deadline,
201
+ * never `disabled`, so nothing here can strand an account.
202
+ */
203
+ CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: {
204
+ kind: "int",
205
+ default: 900_000,
206
+ min: 0,
207
+ max: 86_400_000,
208
+ },
185
209
  CARTETHYIA_ACCOUNT_COOLDOWN_ESCALATION_MAX_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
186
210
  CARTETHYIA_POOL_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
187
211
  CARTETHYIA_ACCOUNT_AUTH_REJECTED_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
@@ -78,6 +78,24 @@ const BACKUP_SCOPES = ["dashboard:write"] as const;
78
78
  const importBody = t.Object({
79
79
  password: t.String(),
80
80
  backup: t.Unknown(),
81
+ /**
82
+ * Table names to restore. Absent or empty restores every table in the file,
83
+ * which is what the dashboard's checklist defaults to and what the API did
84
+ * before selection existed.
85
+ */
86
+ tables: t.Optional(t.Array(t.String())),
87
+ });
88
+
89
+ /**
90
+ * The inspect body: a file, no password.
91
+ *
92
+ * Inspection writes nothing and reads no secret — it only reports which tables
93
+ * the file carries and which of them the live schema has — so it needs no
94
+ * re-authentication. Requiring the console password here would mean typing it
95
+ * twice for one restore.
96
+ */
97
+ const inspectBody = t.Object({
98
+ backup: t.Unknown(),
81
99
  });
82
100
 
83
101
  const exportQuery = t.Object({
@@ -164,7 +182,12 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
164
182
  // from nowhere else. A request body may not name the tenant it writes
165
183
  // into: that would let any holder of the scope import into, and
166
184
  // overwrite, a tenant they are not.
167
- const result = await config.backupFor(request).restore(body.password, body.backup, access.tenantId);
185
+ const result = await config.backupFor(request).restore(
186
+ body.password,
187
+ body.backup,
188
+ access.tenantId,
189
+ body.tables === undefined ? undefined : { tables: body.tables },
190
+ );
168
191
  // Routing-visible write: the restore has committed, so the cached
169
192
  // snapshot must be rebuilt before the next `/v1/*` dispatch.
170
193
  await config.snapshotInvalidator?.invalidate();
@@ -214,5 +237,33 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
214
237
  } catch (error) {
215
238
  return errorResponse(error, set, "Delete all failed");
216
239
  }
240
+ })
241
+ .post("/inspect", { body: inspectBody }, async ({ request, body, set }) => {
242
+ try {
243
+ const access = requireAnyScope(config.accessResolver(request), BACKUP_SCOPES);
244
+ if (access.tenantId === null) {
245
+ throw new ConsoleDomainError("tenant_required", 403, "Tenant isolation required");
246
+ }
247
+ // Same byte bound as the import, for the same reason: the file is
248
+ // parsed here too, and an unbounded one would be buffered before any
249
+ // validation could refuse it.
250
+ const declared = Number(request.headers.get("content-length") ?? "0");
251
+ const actualBytes = JSON.stringify(body ?? null).length;
252
+ if (
253
+ (Number.isFinite(declared) && declared > MAX_BACKUP_BYTES) ||
254
+ actualBytes > MAX_BACKUP_BYTES
255
+ ) {
256
+ set.status = 413;
257
+ return {
258
+ error: `backup exceeds the ${MAX_BACKUP_BYTES} byte limit`,
259
+ code: "request_too_large",
260
+ };
261
+ }
262
+ // Reads nothing and writes nothing: the checklist the restore wizard
263
+ // shows, so an operator can pick sections before typing a password.
264
+ return config.backupFor(request).inspect(body.backup);
265
+ } catch (error) {
266
+ return errorResponse(error, set, "Backup inspection failed");
267
+ }
217
268
  }) as unknown as Elysia;
218
269
  }