cartethyia 2.6.7 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-DE-F1nxZ.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-ChmiSLwO.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-fKAWd-OQ.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DoOUthbG.js → LoginPage-B_0FUKWc.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  22. package/dist/dashboard/assets/{ProviderDetailPage-CFu1qGcT.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-CnlJZbxF.js → ProvidersPage-CEplAR-i.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-B9mBTweh.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-BjldQwIU.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-DQ9oDRpi.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-CHVZqskZ.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
  43. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  44. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
  45. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  46. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
  47. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  48. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
  49. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  50. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
  51. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  52. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
  53. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  54. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
  55. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  56. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
  57. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  58. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
  59. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  60. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
  61. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-CWBY5A0f.js} +3 -3
  63. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
  65. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  66. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
  67. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  68. package/dist/dashboard/assets/{index-BeLRhsmt.js → index-CDzP3ovJ.js} +2 -2
  69. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  70. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
  71. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  72. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
  73. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  74. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
  75. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  76. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
  77. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  78. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
  79. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  80. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  81. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  82. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
  83. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
  85. package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
  86. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
  87. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
  89. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  90. package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-KIT0rC6G.js} +1 -1
  91. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  92. package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
  93. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  94. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
  95. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  96. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
  97. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  98. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
  99. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BgoxdoMk.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 -51
  105. package/package.json +96 -96
  106. package/src/boot/app.ts +714 -722
  107. package/src/boot/dependencies.ts +729 -750
  108. package/src/config/resources.ts +70 -137
  109. package/src/config/security.ts +37 -73
  110. package/src/config/spec.ts +358 -378
  111. package/src/console/console-router.ts +225 -233
  112. package/src/console/domain-registration.ts +381 -391
  113. package/src/console/remote-support/contracts.ts +3 -1
  114. package/src/console/remote-support/service.ts +115 -6
  115. package/src/main.ts +167 -169
  116. package/src/observability/metrics.ts +467 -457
  117. package/src/observability/runtime-metrics.ts +269 -254
  118. package/src/protocol/gateway-error.ts +232 -233
  119. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  120. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  121. package/src/providers/integrations/qoder/qoder.ts +597 -23
  122. package/src/security/abuse.ts +17 -0
  123. package/src/transport/guards/auth-guard.ts +109 -127
  124. package/src/transport/guards/pipeline.ts +220 -226
  125. package/src/transport/guards/request-context.ts +238 -318
  126. package/src/transport/request/inflight.ts +21 -2
  127. package/dist/dashboard/assets/BannedPage-Dskhyp0l.js.gz +0 -0
  128. package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
  129. package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
  130. package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
  131. package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
  132. package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
  133. package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
  134. package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
  135. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  136. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
  137. package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
  138. package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js.gz +0 -0
  139. package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
  140. package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
  141. package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
  142. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
  143. package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
  144. package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
  145. package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
  146. package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
  147. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
  148. package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
  149. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  150. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  151. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  152. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  153. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  154. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  155. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  156. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  157. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  158. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  159. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  160. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  161. package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  163. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  164. package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
  165. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  166. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  167. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  168. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  169. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  170. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  171. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  172. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  173. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  174. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  175. package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
  176. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  177. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  178. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  179. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  180. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  181. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  182. package/src/console/admin/model-abuse/contracts.ts +0 -38
  183. package/src/console/admin/model-abuse/routes.ts +0 -97
  184. package/src/security/model-abuse.ts +0 -704
  185. package/src/transport/guards/concurrency-guard.ts +0 -125
  186. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  187. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -1,378 +1,358 @@
1
- /**
2
- * Spec-driven configuration reader shared by the `src/config/` domain modules.
3
- *
4
- * `CONFIG_SPEC` is the canonical declaration of every environment variable
5
- * `src/config/` reads: name, kind, default, and bounds in one place. The
6
- * resolvers in the sibling domain modules are thin readers over it, so a new
7
- * knob cannot be added without declaring a row here plus a `.env.example`
8
- * line. Bounds are enforced on read so a malformed operator value throws at
9
- * the boundary instead of silently coercing.
10
- *
11
- * Module-specific environment reads that only affect a single subsystem (e.g.
12
- * logger level, telemetry payload storage, Postgres pool tuning) still live
13
- * next to their consumer. No I/O, no caching — readers are pure with respect
14
- * to `process.env` at call time.
15
- */
16
-
17
- /** Default directory for the built dashboard static assets. */
18
- const DEFAULT_DASHBOARD_DIST = "./dist/dashboard";
19
- /** Default upstream client version host for Kimi's OAuth origin. */
20
- const DEFAULT_KIMI_OAUTH_HOST = "https://auth.kimi.com";
21
- /** Base for Kimi Code's quota/billing surface; the usage path joins on this. */
22
- const DEFAULT_KIMI_QUOTA_BASE_URL = "https://api.kimi.com/coding/v1";
23
-
24
- // ─── Config spec ────────────────────────────────────────────────────────────
25
-
26
- interface IntConfigEntry {
27
- readonly kind: "int";
28
- readonly default: number;
29
- readonly min: number;
30
- readonly max: number;
31
- }
32
-
33
- interface TextConfigEntry {
34
- readonly kind: "text";
35
- readonly default: string;
36
- }
37
-
38
- /** Absent means "no value"; an empty/whitespace value also reads as absent. */
39
- interface OptionalTextConfigEntry {
40
- readonly kind: "optional-text";
41
- }
42
-
43
- /**
44
- * Absent or empty means "unset" — distinct from an explicit value, because
45
- * consumers apply their own fallback (cgroup detection, egress default) rather
46
- * than a fixed default here.
47
- */
48
- interface OptionalIntConfigEntry {
49
- readonly kind: "optional-int";
50
- readonly min: number;
51
- readonly max: number;
52
- }
53
-
54
- interface RequiredTextConfigEntry {
55
- readonly kind: "required";
56
- readonly error: string;
57
- }
58
-
59
- /** Enabled unless the literal string `"false"` is set. */
60
- interface FlagConfigEntry {
61
- readonly kind: "flag";
62
- }
63
-
64
- /** Comma-separated list; an empty result reads as absent. */
65
- interface ListConfigEntry {
66
- readonly kind: "list";
67
- }
68
-
69
- export type ConfigEntry =
70
- | IntConfigEntry
71
- | TextConfigEntry
72
- | OptionalTextConfigEntry
73
- | OptionalIntConfigEntry
74
- | RequiredTextConfigEntry
75
- | FlagConfigEntry
76
- | ListConfigEntry;
77
-
78
- /**
79
- * The single declaration of every environment variable `src/config/` reads.
80
- *
81
- * Adding a knob means adding a row here plus a `.env.example` line. Bounds are
82
- * enforced on read so a malformed operator value throws at the boundary
83
- * instead of silently coercing.
84
- */
85
- export const CONFIG_SPEC = {
86
- // HTTP listener + dashboard runtime
87
- PORT: { kind: "int", default: 12_800, min: 1, max: 65_535 },
88
- DASHBOARD_DIST: { kind: "text", default: DEFAULT_DASHBOARD_DIST },
89
- CARTETHYIA_API_KEY: { kind: "optional-text" },
90
- CARTETHYIA_SERVER_MAX_BODY_BYTES: {
91
- kind: "int",
92
- // A 500K-token context is roughly 2 MB of text, so 4 MB admits every real
93
- // request while halving the worst-case hold. Worst-case retained bytes
94
- // across the process is `PROCESS_CONCURRENCY_CAP × this`, so on a 1 GB box
95
- // the product should stay within about 40% of the limit; an operator with
96
- // larger payloads raises this.
97
- default: 4 * 1024 * 1024,
98
- min: 1024,
99
- max: Number.MAX_SAFE_INTEGER,
100
- },
101
- CARTETHYIA_SERVER_IDLE_TIMEOUT: { kind: "int", default: 60, min: 0, max: 86_400 },
102
-
103
- // Graceful-shutdown drain: how long in-flight requests may finish naturally
104
- // before the drain aborts the stragglers. Sized above the common case (a
105
- // typical response) but below the stream stall bound so a wedged stream is
106
- // still cut within the process budget.
107
- CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS: {
108
- kind: "int",
109
- default: 20_000,
110
- min: 0,
111
- max: 600_000,
112
- },
113
- // Optional ceiling on in-flight requests per process. Left unset, the limit
114
- // is derived from the memory limit and the body ceiling (see
115
- // config/resources.ts): 2 GiB/4 MiB → 64, 512 MiB/4 MiB → 16, 256 MiB/16 MiB
116
- // → 2. Set it to admit more than memory alone would allow — which is the
117
- // operator's call, and is reported rather than corrected — or less, to keep
118
- // a slow upstream from being handed more concurrency than it can serve.
119
- // Optional-int, not int-with-a-zero-default: "unset" and "explicitly zero"
120
- // must not be the same thing. Zero is not a valid ceiling, so a default of 0
121
- // would need a second sentinel to mean "derive".
122
- CARTETHYIA_PROCESS_CONCURRENCY_LIMIT: { kind: "optional-int", min: 1, max: 64 },
123
- // Slack above each key's concurrency limit. Effective ceiling is
124
- // limit + tolerance; only requests past it are refused.
125
- CARTETHYIA_CONCURRENCY_TOLERANCE: { kind: "optional-int", min: 0, max: 1024 },
126
- // How many gateway processes serve this deployment, and which one this is.
127
- // Count > 1 requires Linux, CARTETHYIA_DB_MODE=full, and a REACHABLE Redis —
128
- // checked at boot, because a half-configured multi-process deploy starts
129
- // fine and then enforces every per-key limit N times over. Lite stays
130
- // single-process: two processes on one SQLite file corrupt it.
131
- CARTETHYIA_PROCESS_COUNT: { kind: "int", default: 1, min: 1, max: 16 },
132
- // Which process this is (0 – count-1). Mandatory when count > 1: a process
133
- // that does not know its index cannot own a shard of anything.
134
- CARTETHYIA_PROCESS_INDEX: { kind: "int", default: 0, min: 0, max: 15 },
135
- // Optional secret for the operator drain endpoint (`POST /admin/drain`). When
136
- // set, a process can be stopped gracefully on platforms where a catchable
137
- // signal cannot be delivered to it (notably Windows, where `process.kill` of
138
- // a console-less process runs no JS handler). The endpoint additionally
139
- // requires a loopback peer and a timing-safe token match; unset = the route
140
- // does not exist.
141
- CARTETHYIA_DRAIN_TOKEN: { kind: "optional-text" },
142
-
143
- // Network policy (trusted proxy + SSRF)
144
- TRUSTED_PROXY_CIDRS: { kind: "list" },
145
- CARTETHYIA_ALLOW_PRIVATE_UPSTREAMS: { kind: "flag" },
146
- CARTETHYIA_ALLOWED_NETWORKS: { kind: "list" },
147
- CARTETHYIA_MAX_REDIRECTS: { kind: "optional-int", min: 0, max: 10 },
148
-
149
- // Runtime memory
150
- CARTETHYIA_MEMORY_LIMIT_BYTES: {
151
- kind: "optional-int",
152
- min: 1,
153
- max: Number.MAX_SAFE_INTEGER,
154
- },
155
- // Opt-in forced garbage collection under memory pressure. Off by default:
156
- // see `observability/runtime-metrics.ts` — a forced collection is not
157
- // reclamation, and a collection that runs while requests are in flight adds
158
- // a latency spike. This gates the pressure path only; idle reclaim runs
159
- // unconditionally, because reclaiming when nothing is in flight stalls
160
- // nothing.
161
- CARTETHYIA_FORCED_GC: { kind: "flag" },
162
- // How often runtime gauges (memory + bounded-collection sizes) are sampled.
163
- CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS: {
164
- kind: "int",
165
- default: 30_000,
166
- min: 1_000,
167
- max: 300_000,
168
- },
169
- /**
170
- * Periodic maintenance tasks switched off entirely, by name. A small
171
- * deployment that does not need live quota or version polling can cut the
172
- * heaviest sweeps here instead of carrying one flag per task.
173
- */
174
- CARTETHYIA_DISABLED_TASKS: { kind: "list" },
175
-
176
- /**
177
- * How long a day's telemetry is kept, in days.
178
- *
179
- * Whole raw event files past this age are removed. No summary or compacted
180
- * form replaces the individual requests.
181
- *
182
- * This is the knob that decides how long the request list, per-request
183
- * filters, and the detail drawer stay complete.
184
- */
185
- CARTETHYIA_TELEMETRY_RETENTION_DAYS: { kind: "int", default: 30, min: 3, max: 365 },
186
- CARTETHYIA_TELEMETRY_PAYLOAD_MAX_BYTES: {
187
- kind: "int",
188
- default: 16 * 1024 * 1024,
189
- min: 1 * 1024 * 1024,
190
- max: 16 * 1024 * 1024,
191
- },
192
-
193
- // Upstream request timeout + retry backoff
194
- //
195
- // Strictly under 90s: a client whose own deadline is 90s must never be
196
- // waiting on a gateway that is still holding a request open past it, and a
197
- // provider that has not answered within 75s is not answering — the request
198
- // is queued upstream or the egress is stuck, and the honest move is to fail
199
- // over to another account rather than burn the caller's whole budget on one.
200
- // 120s outlived the clients that call this, which cap lower.
201
- CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 75_000, min: 5_000, max: 600_000 },
202
- CARTETHYIA_STREAM_STALL_TIMEOUT_MS: {
203
- kind: "int",
204
- default: 360_000,
205
- min: 60_000,
206
- max: 600_000,
207
- },
208
- CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS: {
209
- kind: "int",
210
- default: 200_000,
211
- min: 30_000,
212
- max: 300_000,
213
- },
214
- CARTETHYIA_FALLBACK_RETRY_BASE_MS: { kind: "int", default: 100, min: 0, max: 60_000 },
215
- CARTETHYIA_FALLBACK_RETRY_CAP_MS: { kind: "int", default: 2_000, min: 0, max: 300_000 },
216
- CARTETHYIA_ROUTE_MAX_ATTEMPTS: { kind: "int", default: 8, min: 1, max: 64 },
217
-
218
- // Account/pool health cooldown delays. Each is the fallback used when the
219
- // upstream error states no explicit reset window; an upstream
220
- // `Retry-After`/reset header or message always wins over these.
221
- CARTETHYIA_ACCOUNT_RATE_LIMIT_COOLDOWN_MS: { kind: "int", default: 900_000, min: 0, max: 86_400_000 },
222
- CARTETHYIA_ACCOUNT_QUOTA_COOLDOWN_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
223
- CARTETHYIA_ACCOUNT_MODEL_CAPACITY_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
224
- CARTETHYIA_ACCOUNT_TRANSIENT_COOLDOWN_MS: { kind: "int", default: 30_000, min: 0, max: 86_400_000 },
225
- /**
226
- * An unmapped upstream failure cools the account down for 15 minutes.
227
- *
228
- * A shorter window puts the account straight back into rotation and re-dials
229
- * an upstream that has just answered something this gateway does not
230
- * understand — so the same unknown error is retried every minute, and an
231
- * account that is actually broken contributes a steady stream of failed
232
- * attempts instead of one. Fifteen minutes is long enough for a transient
233
- * provider condition to clear on its own and short enough that no operator
234
- * action is needed to recover it. It is still a cooldown with a deadline,
235
- * never `disabled`, so nothing here can strand an account.
236
- */
237
- CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: {
238
- kind: "int",
239
- default: 900_000,
240
- min: 0,
241
- max: 86_400_000,
242
- },
243
- CARTETHYIA_ACCOUNT_COOLDOWN_ESCALATION_MAX_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
244
- CARTETHYIA_POOL_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
245
- CARTETHYIA_ACCOUNT_AUTH_REJECTED_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
246
- CARTETHYIA_ACCOUNT_POLICY_BLOCK_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
247
- CARTETHYIA_ACCOUNT_DISABLED_RECOVERY_MS: { kind: "int", default: 86_400_000, min: 0, max: 604_800_000 },
248
-
249
- // Proxy pool agents
250
- CARTETHYIA_PROXY_MAX_SOCKETS: { kind: "int", default: 100, min: 1, max: 100_000 },
251
- CARTETHYIA_PROXY_MAX_FREE_SOCKETS: { kind: "int", default: 20, min: 0, max: 100_000 },
252
-
253
- // Security (encryption key, public origin, abuse ceiling)
254
- IP_RATE_MAX_PER_WINDOW: { kind: "int", default: 240, min: 1, max: Number.MAX_SAFE_INTEGER },
255
- // Invalid-model attempts inside the sliding window that ban the client
256
- // address, how long an attempt stays countable, and how long the ban lasts.
257
- // A valid request does not reset the window.
258
- CARTETHYIA_MODEL_STRIKE_THRESHOLD: { kind: "int", default: 10, min: 1, max: 100 },
259
- CARTETHYIA_MODEL_STRIKE_WINDOW_MS: { kind: "int", default: 1_800_000, min: 1_000, max: 86_400_000 },
260
- CARTETHYIA_MODEL_BAN_TTL_MS: { kind: "int", default: 21_600_000, min: 1_000, max: 604_800_000 },
261
- /**
262
- * The longest ban the console will record, however long a request asks for.
263
- * The "Permanent" choice is a very long TTL rather than an unbounded one,
264
- * and this is what keeps a typo in a duration from parking a caller for a
265
- * decade.
266
- */
267
- CARTETHYIA_MODEL_BAN_MAX_TTL_MS: {
268
- kind: "int",
269
- default: 604_800_000,
270
- min: 1_000,
271
- max: 604_800_000,
272
- },
273
- CARTETHYIA_ENCRYPTION_KEY: {
274
- kind: "required",
275
- error:
276
- "CARTETHYIA_ENCRYPTION_KEY is required to encrypt credentials and hash API keys",
277
- },
278
- CARTETHYIA_PUBLIC_ORIGIN: {
279
- kind: "required",
280
- error:
281
- "CARTETHYIA_PUBLIC_ORIGIN is required to build OAuth redirect URIs (e.g. https://cartethyia.example.com)",
282
- },
283
-
284
- // Provider OAuth hosts
285
- CARTETHYIA_KIMI_OAUTH_HOST: { kind: "text", default: DEFAULT_KIMI_OAUTH_HOST },
286
- /**
287
- * Loopback port the browser-OAuth callback listener binds.
288
- *
289
- * Configurable because the default is a fixed number: a busy port would strand
290
- * the operator, since the login could not start and there would be no way to
291
- * move it. Codex's authorization server still expects 59653, so changing this
292
- * only helps the providers whose redirect URI is not allowlisted upstream.
293
- */
294
- CARTETHYIA_OAUTH_CALLBACK_PORT: { kind: "int", default: 59_653, min: 1_024, max: 65_535 },
295
- /**
296
- * Kimi Code quota/billing base. The `/usages` path joins on this, so the
297
- * default includes the `/v1` segment the bundled provider metadata implies.
298
- */
299
- CARTETHYIA_KIMI_QUOTA_BASE_URL: { kind: "text", default: DEFAULT_KIMI_QUOTA_BASE_URL },
300
- /**
301
- * GitHub Enterprise domain for the Copilot device login, e.g.
302
- * `company.ghe.com`. Empty (the default) means github.com. GitHub Enterprise
303
- * serves the device flow and the token-mint endpoint from its own domain, so
304
- * an operator on one has no other way to point the login at it.
305
- */
306
- CARTETHYIA_GITHUB_ENTERPRISE_DOMAIN: { kind: "text", default: "" },
307
- /**
308
- * Free-tier OpenCode ids the listing still advertises but upstream refuses.
309
- * Replaces the bundled list outright; the single token `none` trusts the
310
- * listing completely.
311
- *
312
- * The bundled ids were measured, not declared — upstream can start serving
313
- * a refused id or start refusing a served one at any time, and either way
314
- * the correct answer stopped being the one compiled in. This is the escape
315
- * hatch that needs no deploy.
316
- */
317
- CARTETHYIA_OPENCODE_UNAVAILABLE_FREE: { kind: "list" },
318
- } as const satisfies Readonly<Record<string, ConfigEntry>>;
319
-
320
- // ─── Spec readers ───────────────────────────────────────────────────────────
321
-
322
- /** Reads a bounded integer, throwing on a malformed or out-of-range value. */
323
- export function readInt(name: string, entry: IntConfigEntry): number {
324
- const raw = process.env[name];
325
- if (raw === undefined || raw.trim() === "") return entry.default;
326
- const value = Number(raw);
327
- if (!Number.isInteger(value) || value < entry.min || value > entry.max) {
328
- throw new Error(`${name} must be an integer between ${entry.min} and ${entry.max}`);
329
- }
330
- return value;
331
- }
332
-
333
- /** Reads a string with a fallback, treating an empty value as absent. */
334
- export function readText(name: string, entry: TextConfigEntry): string {
335
- const raw = process.env[name]?.trim();
336
- return raw && raw.length > 0 ? raw : entry.default;
337
- }
338
-
339
- /** Reads an optional string; empty or whitespace reads as absent. */
340
- export function readOptionalText(name: string, entry: OptionalTextConfigEntry): string | undefined {
341
- void entry;
342
- const raw = process.env[name]?.trim();
343
- return raw && raw.length > 0 ? raw : undefined;
344
- }
345
-
346
- /** Reads an optional bounded integer; absent or empty reads as `undefined`. */
347
- export function readOptionalInt(name: string, entry: OptionalIntConfigEntry): number | undefined {
348
- const raw = process.env[name];
349
- if (raw === undefined || raw.trim() === "") return undefined;
350
- const value = Number(raw);
351
- if (!Number.isInteger(value) || value < entry.min || value > entry.max) {
352
- throw new Error(`${name} must be an integer between ${entry.min} and ${entry.max}`);
353
- }
354
- return value;
355
- }
356
-
357
- /** Reads a required string, throwing with the entry's documented message. */
358
- export function readRequired(name: string, entry: RequiredTextConfigEntry): string {
359
- const raw = process.env[name];
360
- if (!raw) throw new Error(entry.error);
361
- return raw;
362
- }
363
-
364
- /** Reads a flag: enabled unless the literal `"false"` is set. */
365
- export function readFlag(name: string, entry: FlagConfigEntry): boolean {
366
- void entry;
367
- return process.env[name] !== "false";
368
- }
369
-
370
- /** Reads a comma-separated list, dropping empty entries. */
371
- export function readList(name: string, entry: ListConfigEntry): readonly string[] | undefined {
372
- void entry;
373
- const entries = process.env[name]
374
- ?.split(",")
375
- .map((value) => value.trim())
376
- .filter(Boolean);
377
- return entries && entries.length > 0 ? entries : undefined;
378
- }
1
+ /**
2
+ * Spec-driven configuration reader shared by the `src/config/` domain modules.
3
+ *
4
+ * `CONFIG_SPEC` is the canonical declaration of every environment variable
5
+ * `src/config/` reads: name, kind, default, and bounds in one place. The
6
+ * resolvers in the sibling domain modules are thin readers over it, so a new
7
+ * knob cannot be added without declaring a row here plus a `.env.example`
8
+ * line. Bounds are enforced on read so a malformed operator value throws at
9
+ * the boundary instead of silently coercing.
10
+ *
11
+ * Module-specific environment reads that only affect a single subsystem (e.g.
12
+ * logger level, telemetry payload storage, Postgres pool tuning) still live
13
+ * next to their consumer. No I/O, no caching — readers are pure with respect
14
+ * to `process.env` at call time.
15
+ */
16
+
17
+ /** Default directory for the built dashboard static assets. */
18
+ const DEFAULT_DASHBOARD_DIST = "./dist/dashboard";
19
+ /** Default upstream client version host for Kimi's OAuth origin. */
20
+ const DEFAULT_KIMI_OAUTH_HOST = "https://auth.kimi.com";
21
+ /** Base for Kimi Code's quota/billing surface; the usage path joins on this. */
22
+ const DEFAULT_KIMI_QUOTA_BASE_URL = "https://api.kimi.com/coding/v1";
23
+
24
+ // ─── Config spec ────────────────────────────────────────────────────────────
25
+
26
+ interface IntConfigEntry {
27
+ readonly kind: "int";
28
+ readonly default: number;
29
+ readonly min: number;
30
+ readonly max: number;
31
+ }
32
+
33
+ interface TextConfigEntry {
34
+ readonly kind: "text";
35
+ readonly default: string;
36
+ }
37
+
38
+ /** Absent means "no value"; an empty/whitespace value also reads as absent. */
39
+ interface OptionalTextConfigEntry {
40
+ readonly kind: "optional-text";
41
+ }
42
+
43
+ /**
44
+ * Absent or empty means "unset" — distinct from an explicit value, because
45
+ * consumers apply their own fallback (cgroup detection, egress default) rather
46
+ * than a fixed default here.
47
+ */
48
+ interface OptionalIntConfigEntry {
49
+ readonly kind: "optional-int";
50
+ readonly min: number;
51
+ readonly max: number;
52
+ }
53
+
54
+ interface RequiredTextConfigEntry {
55
+ readonly kind: "required";
56
+ readonly error: string;
57
+ }
58
+
59
+ /** Enabled unless the literal string `"false"` is set. */
60
+ interface FlagConfigEntry {
61
+ readonly kind: "flag";
62
+ }
63
+
64
+ /** Comma-separated list; an empty result reads as absent. */
65
+ interface ListConfigEntry {
66
+ readonly kind: "list";
67
+ }
68
+
69
+ export type ConfigEntry =
70
+ | IntConfigEntry
71
+ | TextConfigEntry
72
+ | OptionalTextConfigEntry
73
+ | OptionalIntConfigEntry
74
+ | RequiredTextConfigEntry
75
+ | FlagConfigEntry
76
+ | ListConfigEntry;
77
+
78
+ /**
79
+ * The single declaration of every environment variable `src/config/` reads.
80
+ *
81
+ * Adding a knob means adding a row here plus a `.env.example` line. Bounds are
82
+ * enforced on read so a malformed operator value throws at the boundary
83
+ * instead of silently coercing.
84
+ */
85
+ export const CONFIG_SPEC = {
86
+ // HTTP listener + dashboard runtime
87
+ PORT: { kind: "int", default: 12_800, min: 1, max: 65_535 },
88
+ DASHBOARD_DIST: { kind: "text", default: DEFAULT_DASHBOARD_DIST },
89
+ CARTETHYIA_API_KEY: { kind: "optional-text" },
90
+ CARTETHYIA_SERVER_MAX_BODY_BYTES: {
91
+ kind: "int",
92
+ // A 500K-token context is roughly 2 MB of text, so 4 MB admits every real
93
+ // request while halving the worst-case hold. How many may be in flight is
94
+ // admission's job, so an operator with larger payloads raises this and
95
+ // sets the ceiling in Settings.
96
+ default: 4 * 1024 * 1024,
97
+ min: 1024,
98
+ max: Number.MAX_SAFE_INTEGER,
99
+ },
100
+ CARTETHYIA_SERVER_IDLE_TIMEOUT: { kind: "int", default: 60, min: 0, max: 86_400 },
101
+
102
+ // Graceful-shutdown drain: how long in-flight requests may finish naturally
103
+ // before the drain aborts the stragglers. Sized above the common case (a
104
+ // typical response) but below the stream stall bound so a wedged stream is
105
+ // still cut within the process budget.
106
+ CARTETHYIA_SHUTDOWN_DRAIN_WINDOW_MS: {
107
+ kind: "int",
108
+ default: 20_000,
109
+ min: 0,
110
+ max: 600_000,
111
+ },
112
+ // Optional ceiling on in-flight requests per process. Left unset, the limit
113
+ // is derived from the memory limit and the body ceiling (see
114
+ // config/resources.ts): 2 GiB/4 MiB → 64, 512 MiB/4 MiB → 16, 256 MiB/16 MiB
115
+ // → 2. Set it to admit more than memory alone would allow — which is the
116
+ // operator's call, and is reported rather than corrected — or less, to keep
117
+ // a slow upstream from being handed more concurrency than it can serve.
118
+ // Optional-int, not int-with-a-zero-default: "unset" and "explicitly zero"
119
+ // must not be the same thing. Zero is not a valid ceiling, so a default of 0
120
+ // would need a second sentinel to mean "derive".
121
+ // Slack above each key's concurrency limit. Effective ceiling is
122
+ // limit + tolerance; only requests past it are refused.
123
+ CARTETHYIA_CONCURRENCY_TOLERANCE: { kind: "optional-int", min: 0, max: 1024 },
124
+ // How many gateway processes serve this deployment, and which one this is.
125
+ // Count > 1 requires Linux, CARTETHYIA_DB_MODE=full, and a REACHABLE Redis —
126
+ // checked at boot, because a half-configured multi-process deploy starts
127
+ // fine and then enforces every per-key limit N times over. Lite stays
128
+ // single-process: two processes on one SQLite file corrupt it.
129
+ CARTETHYIA_PROCESS_COUNT: { kind: "int", default: 1, min: 1, max: 16 },
130
+ // Which process this is (0 – count-1). Mandatory when count > 1: a process
131
+ // that does not know its index cannot own a shard of anything.
132
+ CARTETHYIA_PROCESS_INDEX: { kind: "int", default: 0, min: 0, max: 15 },
133
+ // Optional secret for the operator drain endpoint (`POST /admin/drain`). When
134
+ // set, a process can be stopped gracefully on platforms where a catchable
135
+ // signal cannot be delivered to it (notably Windows, where `process.kill` of
136
+ // a console-less process runs no JS handler). The endpoint additionally
137
+ // requires a loopback peer and a timing-safe token match; unset = the route
138
+ // does not exist.
139
+ CARTETHYIA_DRAIN_TOKEN: { kind: "optional-text" },
140
+
141
+ // Network policy (trusted proxy + SSRF)
142
+ TRUSTED_PROXY_CIDRS: { kind: "list" },
143
+ CARTETHYIA_ALLOW_PRIVATE_UPSTREAMS: { kind: "flag" },
144
+ CARTETHYIA_ALLOWED_NETWORKS: { kind: "list" },
145
+ CARTETHYIA_MAX_REDIRECTS: { kind: "optional-int", min: 0, max: 10 },
146
+
147
+ // Runtime memory
148
+ CARTETHYIA_MEMORY_LIMIT_BYTES: {
149
+ kind: "optional-int",
150
+ min: 1,
151
+ max: Number.MAX_SAFE_INTEGER,
152
+ },
153
+ // Opt-in forced garbage collection under memory pressure. Off by default:
154
+ // see `observability/runtime-metrics.ts` — a forced collection is not
155
+ // reclamation, and a collection that runs while requests are in flight adds
156
+ // a latency spike. This gates the pressure path only; idle reclaim runs
157
+ // unconditionally, because reclaiming when nothing is in flight stalls
158
+ // nothing.
159
+ CARTETHYIA_FORCED_GC: { kind: "flag" },
160
+ // How often runtime gauges (memory + bounded-collection sizes) are sampled.
161
+ CARTETHYIA_RUNTIME_METRICS_INTERVAL_MS: {
162
+ kind: "int",
163
+ default: 30_000,
164
+ min: 1_000,
165
+ max: 300_000,
166
+ },
167
+ /**
168
+ * Periodic maintenance tasks switched off entirely, by name. A small
169
+ * deployment that does not need live quota or version polling can cut the
170
+ * heaviest sweeps here instead of carrying one flag per task.
171
+ */
172
+ CARTETHYIA_DISABLED_TASKS: { kind: "list" },
173
+
174
+ /**
175
+ * How long a day's telemetry is kept, in days.
176
+ *
177
+ * Whole raw event files past this age are removed. No summary or compacted
178
+ * form replaces the individual requests.
179
+ *
180
+ * This is the knob that decides how long the request list, per-request
181
+ * filters, and the detail drawer stay complete.
182
+ */
183
+ CARTETHYIA_TELEMETRY_RETENTION_DAYS: { kind: "int", default: 30, min: 3, max: 365 },
184
+ CARTETHYIA_TELEMETRY_PAYLOAD_MAX_BYTES: {
185
+ kind: "int",
186
+ default: 16 * 1024 * 1024,
187
+ min: 1 * 1024 * 1024,
188
+ max: 16 * 1024 * 1024,
189
+ },
190
+
191
+ // Upstream request timeout + retry backoff
192
+ //
193
+ // Strictly under 90s: a client whose own deadline is 90s must never be
194
+ // waiting on a gateway that is still holding a request open past it, and a
195
+ // provider that has not answered within 75s is not answering — the request
196
+ // is queued upstream or the egress is stuck, and the honest move is to fail
197
+ // over to another account rather than burn the caller's whole budget on one.
198
+ // 120s outlived the clients that call this, which cap lower.
199
+ CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 75_000, min: 5_000, max: 600_000 },
200
+ CARTETHYIA_STREAM_STALL_TIMEOUT_MS: {
201
+ kind: "int",
202
+ default: 360_000,
203
+ min: 60_000,
204
+ max: 600_000,
205
+ },
206
+ CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS: {
207
+ kind: "int",
208
+ default: 200_000,
209
+ min: 30_000,
210
+ max: 300_000,
211
+ },
212
+ CARTETHYIA_FALLBACK_RETRY_BASE_MS: { kind: "int", default: 100, min: 0, max: 60_000 },
213
+ CARTETHYIA_FALLBACK_RETRY_CAP_MS: { kind: "int", default: 2_000, min: 0, max: 300_000 },
214
+ CARTETHYIA_ROUTE_MAX_ATTEMPTS: { kind: "int", default: 8, min: 1, max: 64 },
215
+
216
+ // Account/pool health cooldown delays. Each is the fallback used when the
217
+ // upstream error states no explicit reset window; an upstream
218
+ // `Retry-After`/reset header or message always wins over these.
219
+ CARTETHYIA_ACCOUNT_RATE_LIMIT_COOLDOWN_MS: { kind: "int", default: 900_000, min: 0, max: 86_400_000 },
220
+ CARTETHYIA_ACCOUNT_QUOTA_COOLDOWN_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
221
+ CARTETHYIA_ACCOUNT_MODEL_CAPACITY_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
222
+ CARTETHYIA_ACCOUNT_TRANSIENT_COOLDOWN_MS: { kind: "int", default: 30_000, min: 0, max: 86_400_000 },
223
+ /**
224
+ * An unmapped upstream failure cools the account down for 15 minutes.
225
+ *
226
+ * A shorter window puts the account straight back into rotation and re-dials
227
+ * an upstream that has just answered something this gateway does not
228
+ * understand — so the same unknown error is retried every minute, and an
229
+ * account that is actually broken contributes a steady stream of failed
230
+ * attempts instead of one. Fifteen minutes is long enough for a transient
231
+ * provider condition to clear on its own and short enough that no operator
232
+ * action is needed to recover it. It is still a cooldown with a deadline,
233
+ * never `disabled`, so nothing here can strand an account.
234
+ */
235
+ CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: {
236
+ kind: "int",
237
+ default: 900_000,
238
+ min: 0,
239
+ max: 86_400_000,
240
+ },
241
+ CARTETHYIA_ACCOUNT_COOLDOWN_ESCALATION_MAX_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
242
+ CARTETHYIA_POOL_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
243
+ CARTETHYIA_ACCOUNT_AUTH_REJECTED_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
244
+ CARTETHYIA_ACCOUNT_POLICY_BLOCK_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
245
+ CARTETHYIA_ACCOUNT_DISABLED_RECOVERY_MS: { kind: "int", default: 86_400_000, min: 0, max: 604_800_000 },
246
+
247
+ // Proxy pool agents
248
+ CARTETHYIA_PROXY_MAX_SOCKETS: { kind: "int", default: 100, min: 1, max: 100_000 },
249
+ CARTETHYIA_PROXY_MAX_FREE_SOCKETS: { kind: "int", default: 20, min: 0, max: 100_000 },
250
+
251
+ // Security (encryption key, public origin, abuse ceiling)
252
+ IP_RATE_MAX_PER_WINDOW: { kind: "int", default: 240, min: 1, max: Number.MAX_SAFE_INTEGER },
253
+ CARTETHYIA_ENCRYPTION_KEY: {
254
+ kind: "required",
255
+ error:
256
+ "CARTETHYIA_ENCRYPTION_KEY is required to encrypt credentials and hash API keys",
257
+ },
258
+ CARTETHYIA_PUBLIC_ORIGIN: {
259
+ kind: "required",
260
+ error:
261
+ "CARTETHYIA_PUBLIC_ORIGIN is required to build OAuth redirect URIs (e.g. https://cartethyia.example.com)",
262
+ },
263
+
264
+ // Provider OAuth hosts
265
+ CARTETHYIA_KIMI_OAUTH_HOST: { kind: "text", default: DEFAULT_KIMI_OAUTH_HOST },
266
+ /**
267
+ * Loopback port the browser-OAuth callback listener binds.
268
+ *
269
+ * Configurable because the default is a fixed number: a busy port would strand
270
+ * the operator, since the login could not start and there would be no way to
271
+ * move it. Codex's authorization server still expects 59653, so changing this
272
+ * only helps the providers whose redirect URI is not allowlisted upstream.
273
+ */
274
+ CARTETHYIA_OAUTH_CALLBACK_PORT: { kind: "int", default: 59_653, min: 1_024, max: 65_535 },
275
+ /**
276
+ * Kimi Code quota/billing base. The `/usages` path joins on this, so the
277
+ * default includes the `/v1` segment the bundled provider metadata implies.
278
+ */
279
+ CARTETHYIA_KIMI_QUOTA_BASE_URL: { kind: "text", default: DEFAULT_KIMI_QUOTA_BASE_URL },
280
+ /**
281
+ * GitHub Enterprise domain for the Copilot device login, e.g.
282
+ * `company.ghe.com`. Empty (the default) means github.com. GitHub Enterprise
283
+ * serves the device flow and the token-mint endpoint from its own domain, so
284
+ * an operator on one has no other way to point the login at it.
285
+ */
286
+ CARTETHYIA_GITHUB_ENTERPRISE_DOMAIN: { kind: "text", default: "" },
287
+ /**
288
+ * Free-tier OpenCode ids the listing still advertises but upstream refuses.
289
+ * Replaces the bundled list outright; the single token `none` trusts the
290
+ * listing completely.
291
+ *
292
+ * The bundled ids were measured, not declared — upstream can start serving
293
+ * a refused id or start refusing a served one at any time, and either way
294
+ * the correct answer stopped being the one compiled in. This is the escape
295
+ * hatch that needs no deploy.
296
+ */
297
+ CARTETHYIA_OPENCODE_UNAVAILABLE_FREE: { kind: "list" },
298
+ } as const satisfies Readonly<Record<string, ConfigEntry>>;
299
+
300
+ // ─── Spec readers ───────────────────────────────────────────────────────────
301
+
302
+ /** Reads a bounded integer, throwing on a malformed or out-of-range value. */
303
+ export function readInt(name: string, entry: IntConfigEntry): number {
304
+ const raw = process.env[name];
305
+ if (raw === undefined || raw.trim() === "") return entry.default;
306
+ const value = Number(raw);
307
+ if (!Number.isInteger(value) || value < entry.min || value > entry.max) {
308
+ throw new Error(`${name} must be an integer between ${entry.min} and ${entry.max}`);
309
+ }
310
+ return value;
311
+ }
312
+
313
+ /** Reads a string with a fallback, treating an empty value as absent. */
314
+ export function readText(name: string, entry: TextConfigEntry): string {
315
+ const raw = process.env[name]?.trim();
316
+ return raw && raw.length > 0 ? raw : entry.default;
317
+ }
318
+
319
+ /** Reads an optional string; empty or whitespace reads as absent. */
320
+ export function readOptionalText(name: string, entry: OptionalTextConfigEntry): string | undefined {
321
+ void entry;
322
+ const raw = process.env[name]?.trim();
323
+ return raw && raw.length > 0 ? raw : undefined;
324
+ }
325
+
326
+ /** Reads an optional bounded integer; absent or empty reads as `undefined`. */
327
+ export function readOptionalInt(name: string, entry: OptionalIntConfigEntry): number | undefined {
328
+ const raw = process.env[name];
329
+ if (raw === undefined || raw.trim() === "") return undefined;
330
+ const value = Number(raw);
331
+ if (!Number.isInteger(value) || value < entry.min || value > entry.max) {
332
+ throw new Error(`${name} must be an integer between ${entry.min} and ${entry.max}`);
333
+ }
334
+ return value;
335
+ }
336
+
337
+ /** Reads a required string, throwing with the entry's documented message. */
338
+ export function readRequired(name: string, entry: RequiredTextConfigEntry): string {
339
+ const raw = process.env[name];
340
+ if (!raw) throw new Error(entry.error);
341
+ return raw;
342
+ }
343
+
344
+ /** Reads a flag: enabled unless the literal `"false"` is set. */
345
+ export function readFlag(name: string, entry: FlagConfigEntry): boolean {
346
+ void entry;
347
+ return process.env[name] !== "false";
348
+ }
349
+
350
+ /** Reads a comma-separated list, dropping empty entries. */
351
+ export function readList(name: string, entry: ListConfigEntry): readonly string[] | undefined {
352
+ void entry;
353
+ const entries = process.env[name]
354
+ ?.split(",")
355
+ .map((value) => value.trim())
356
+ .filter(Boolean);
357
+ return entries && entries.length > 0 ? entries : undefined;
358
+ }