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,318 +1,238 @@
1
- // Per-request context, client identity, canonical parse, and route preparation.
2
- import { Elysia } from "elysia";
3
- import { GatewayError } from "../../protocol/gateway-error";
4
- import type { TrustedProxyBoundary } from "../../config/network";
5
- import { resolveUpstreamTimeoutMs } from "../../config/timeouts";
6
- import type { ProxyRequestStateStore } from "../request/state";
7
- import { fastPathname } from "../pathname";
8
- import type { ProxyRequestPreparer } from "../request/preparer";
9
- import type { CanonicalAdapter } from "../contracts";
10
- import type { SurfaceAdapterRegistry } from "../surface/adapters";
11
- import { resolveClientIdentity } from "../../security/ip-boundary";
12
- import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
13
- import { pushStructuredConsoleLog } from "../../observability/log-ring";
14
- import { log } from "../../observability/logger";
15
- import { isJsonProxyRoutePath, isProxyDispatchRoute } from "./body-policy";
16
- import { isOpaqueBodyRoute } from "../contracts";
17
- import {
18
- modelAbuseBannedError,
19
- modelWarningMessage,
20
- type ModelAbuseOutcome,
21
- type ModelStrikeService,
22
- } from "../../security/model-abuse";
23
- import { peerAddressUnavailableError } from "./guard-errors";
24
-
25
- /**
26
- * Whether a thrown error is a *model* rejection the strike layer counts: the
27
- * key may not use the named model (`isModelAllowed` → 404), or the name
28
- * resolves to nothing (`modelNotFoundError` → 404). Both surface as
29
- * `model_not_found`; every other failure — an aborted request, a store outage,
30
- * a capability mismatch — is not the caller probing for models and is not
31
- * counted.
32
- */
33
- function isModelRejection(error: unknown): boolean {
34
- return error instanceof GatewayError && error.code === "model_not_found";
35
- }
36
-
37
- /**
38
- * Re-throws a model rejection carrying the escalating strike warning, so an
39
- * honest client that mistyped sees "Warning 1 of 3" instead of the same generic
40
- * 404 three times and then a ban. The code/status are unchanged — only the
41
- * message gains the warning, and the count rides in `details` for the console.
42
- */
43
- function withModelWarning(
44
- error: unknown,
45
- outcome: ModelAbuseOutcome,
46
- limit: number,
47
- opts?: { allowedModels?: readonly string[] | undefined },
48
- ): GatewayError {
49
- if (!(error instanceof GatewayError)) return error as GatewayError;
50
- const model = String(error.details.model ?? "");
51
- const message = modelWarningMessage(model, outcome, limit);
52
- const hint = opts?.allowedModels?.slice(0, 8);
53
- return new GatewayError(error.code, error.status, message, {
54
- ...error.details,
55
- strikes: outcome.strikes,
56
- strike_limit: limit,
57
-
58
- remaining_before_ban: Math.max(0, limit - outcome.strikes),
59
- ...(hint && hint.length > 0 ? { allowed_models_hint: hint } : {}),
60
- }, error.origin);
61
- }
62
-
63
- interface RequestApp {
64
- request(
65
- handler: (context: { request: Request; set?: { headers: Record<string, string> } }) => void,
66
- ): RequestApp;
67
- }
68
-
69
- export interface RequestContextDeps {
70
- readonly stateStore: ProxyRequestStateStore;
71
- readonly clock?: () => number;
72
- /** Explicit deadline override; defaults to `CARTETHYIA_UPSTREAM_TIMEOUT_MS` (120s). */
73
- readonly requestDeadlineMs?: number;
74
- readonly maxBodyBytes?: number;
75
- }
76
-
77
- /**
78
- * Initializes per-request `ProxyRequestState` before any other hook runs.
79
- * Only gateway (`/v1/*`) requests get state: this middleware is mounted at
80
- * the composition root so it also observes health checks, console traffic,
81
- * and dashboard assets, but none of those use proxy state and none of them
82
- * run the `/v1`-scoped cleanup hooks — initializing for them leaked a state,
83
- * a deadline timer, a live-controller entry, and an in-flight count per
84
- * request that was never reclaimed. Cleanup (abort + WeakMap eviction) is
85
- * intentionally NOT done here via `afterResponse`: Elysia's afterResponse
86
- * hook order across plugins is not something this composition can guarantee
87
- * ahead of `createTelemetryLifecycleMiddleware`'s afterResponse (now in
88
- * lifecycle.ts), which still needs to read the state. The WeakMap entry is
89
- * reclaimed by GC once `request` is unreferenced; nothing needs to run after
90
- * the response is sent.
91
- */
92
- export function createRequestContextMiddleware(deps: RequestContextDeps): Elysia {
93
- const app = new Elysia() as unknown as RequestApp;
94
- app.request(({ request, set }) => {
95
- const pathname = fastPathname(request.url);
96
- if (pathname !== "/v1" && !pathname.startsWith("/v1/")) return;
97
- const state = deps.stateStore.initialize(
98
- request,
99
- (deps.clock ?? (() => Date.now()))(),
100
- deps.requestDeadlineMs ?? resolveUpstreamTimeoutMs(),
101
- );
102
- if (set) {
103
- set.headers["x-request-id"] = state.requestId;
104
- Object.assign(set.headers, GATEWAY_SECURITY_HEADERS);
105
- }
106
- });
107
- return app as unknown as Elysia;
108
- }
109
-
110
- export function createClientIdentityMiddleware(deps: {
111
- readonly stateStore: ProxyRequestStateStore;
112
- readonly trustedProxyBoundary: TrustedProxyBoundary;
113
- readonly resolvePeerAddress?: (request: Request) => string | null;
114
- }): Elysia {
115
- // `beforeHandle`, not `onRequest`: plugin-scoped `onRequest` hooks do not
116
- // fire for these routes under Elysia 2 beta (same scoping gap that muted
117
- // plugin `afterResponse` before `registerTelemetryLifecycle`), which left
118
- // `clientIdentity` unset and every gateway row without a client IP.
119
- // `beforeHandle` hooks from `.use()` plugins run in registration order, so
120
- // pipeline position still guarantees this executes before IP-abuse checks.
121
- return new Elysia()
122
- .beforeHandle(({ request, server }) => {
123
- const peer = deps.resolvePeerAddress
124
- ? deps.resolvePeerAddress(request)
125
- : (server?.requestIP(request)?.address ?? null);
126
- if (!peer)
127
- throw peerAddressUnavailableError();
128
- const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
129
- const state = deps.stateStore.require(request);
130
- state.clientIdentity = {
131
- address,
132
- source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
133
- };
134
- const path = state.ingressPath;
135
- if (!isProxyDispatchRoute(path)) return;
136
- // The ingress policy stage already decoded the body, so the client's
137
- // requested model is readable here — before canonical parsing runs. It is
138
- // read defensively and only for display: routing still resolves the model
139
- // through the surface adapter, and a body that is not an object simply
140
- // yields no model rather than failing the request.
141
- const body = state.ingressBody;
142
- const requestedModel =
143
- body !== null && typeof body === "object" && !Array.isArray(body)
144
- ? (body as Record<string, unknown>)["model"]
145
- : undefined;
146
- const userAgent = request.headers.get("user-agent");
147
- pushStructuredConsoleLog("info", "Incoming proxy request", {
148
- event: "request_start",
149
- requestId: state.requestId,
150
- method: request.method,
151
- endpoint: path,
152
- clientIp: address,
153
- ...(typeof requestedModel === "string" && requestedModel.length > 0
154
- ? { model: requestedModel }
155
- : {}),
156
- ...(userAgent === null || userAgent.length === 0
157
- ? {}
158
- : { userAgent: userAgent.slice(0, 512) }),
159
- });
160
- })
161
- .as("plugin");
162
- }
163
-
164
- export function createCanonicalRequestMiddleware(deps: {
165
- readonly stateStore: ProxyRequestStateStore;
166
- readonly surfaceRegistry: SurfaceAdapterRegistry;
167
- readonly adapters: ReadonlyMap<string, CanonicalAdapter>;
168
- /**
169
- * Whether this tenant's payload-capture mode keeps request bodies
170
- * (`full`). `none`/`metadata` never read the request body at finalize, so
171
- * the parsed ingress body can be released as soon as the canonical request
172
- * exists instead of being held — across the whole upstream stream. Injected
173
- * so this stage does not import the capture policy or the database.
174
- */
175
- readonly keepsIngressBody: (tenantId: string | null) => Promise<boolean>;
176
- }): Elysia {
177
- return new Elysia()
178
- .beforeHandle(async ({ request }) => {
179
- const path = fastPathname(request.url);
180
- if (!path.startsWith("/v1/")) return;
181
- if (request.method === "GET" || request.method === "HEAD") return;
182
- // `/v1/completions` needs no separate arm: it is already in
183
- // the JSON routes table, so the body-policy predicate covers it.
184
- // Native routes (compact, System One) read a body but are never parsed
185
- // into a canonical request — their bodies are opaque by contract.
186
- if (!isJsonProxyRoutePath(path) || path === "/v1/responses/compact" || isOpaqueBodyRoute(path))
187
- return;
188
- // Model discovery and other GET/multimodal routes must not go through canonical parsing.
189
- const state = deps.stateStore.require(request);
190
- // Single-read invariant: the ingress policy middleware already decoded the
191
- // body once into `state.ingressBody`. Never re-read `request.body` here —
192
- // it is already consumed/locked. `undefined` means the ingress stage did
193
- // not run (reduced composition); fail closed. An explicit `null` (JSON
194
- // literal `null` body) is a valid single-read value and flows to the
195
- // surface adapter's parse error rather than triggering a second read.
196
- if (state.ingressBody === undefined)
197
- throw new GatewayError("admission_unavailable", 503, "The request body was never read, so this route cannot be served. Resend the request.");
198
- const body = state.ingressBody;
199
- const headers: Record<string, string> = {};
200
- request.headers.forEach((value, key) => {
201
- headers[key] = value;
202
- });
203
- const detection = deps.surfaceRegistry.detectOnce({ body, headers, path });
204
- if (detection.disagreement) {
205
- // A path-vs-body disagreement is expected traffic, not a fault: a
206
- // standard chat body ({model, messages[], max_tokens}) also matches
207
- // the messages body shape, so nearly every chat request with
208
- // max_tokens "disagrees". Warn only when the explicit header is
209
- // involved — a marker contradicting the path means the client is
210
- // genuinely confused about which surface it wants.
211
- const markerInvolved = detection.signals.some((s) => s.source === "explicit_marker");
212
- if (markerInvolved) {
213
- log.warn("[surface] conflicting signals detected for surface routing", {
214
- resolved: detection.surface,
215
- winning_source: detection.winning_source,
216
- signals: detection.signals,
217
- });
218
- } else {
219
- log.debug("[surface] ambiguous body shape resolved by endpoint path", {
220
- resolved: detection.surface,
221
- signals: detection.signals,
222
- });
223
- }
224
- }
225
- const adapter = deps.adapters.get(detection.surface);
226
- if (!adapter)
227
- throw new GatewayError(
228
- "capability_unsupported",
229
- 400,
230
- `Surface adapter unavailable: ${detection.surface}`,
231
- { surface: detection.surface },
232
- );
233
- const ingress = deps.stateStore.require(request);
234
- const userAgent = headers["user-agent"];
235
- if (userAgent) ingress.clientUserAgent = userAgent.slice(0, 512);
236
- ingress.canonicalRequest = adapter.parse({ body, headers, path });
237
- // The parsed body is the largest per-request value, and once the
238
- // canonical request exists the only later reader is payload capture — and
239
- // only in `full` mode (see `captureTerminalPayload`). For `none`/`metadata`
240
- // drop the reference now: held until cleanup, it would otherwise stay
241
- // resident for the entire upstream stream.
242
- if (!(await deps.keepsIngressBody(ingress.authorization?.tenantId ?? null))) {
243
- ingress.releaseIngressBody();
244
- }
245
- })
246
- .as("plugin");
247
- }
248
-
249
- export function createProxyRoutePreparationMiddleware(deps: {
250
- readonly stateStore: ProxyRequestStateStore;
251
- readonly preparer: ProxyRequestPreparer;
252
- /**
253
- * Graduated strikes for repeated invalid-model requests. The preparation step
254
- * is the single choke point where a request is rejected for naming a model the
255
- * key may not use (`isModelAllowed`) or a model that resolves to nothing
256
- * (`modelNotFoundError`), so it is where a strike is recorded and where a
257
- * valid model clears one.
258
- */
259
- readonly modelStrikes?: ModelStrikeService;
260
- }): Elysia {
261
- return new Elysia()
262
- .beforeHandle(async ({ request }) => {
263
- const path = fastPathname(request.url);
264
- if (!path.startsWith("/v1/")) return;
265
- if (request.method === "GET" || request.method === "HEAD") return;
266
- const isJsonRoute = isJsonProxyRoutePath(path);
267
- if (!isJsonRoute || path === "/v1/responses/compact" || isOpaqueBodyRoute(path)) return;
268
- const state = deps.stateStore.require(request);
269
- if (!state.authorization || !state.canonicalRequest)
270
- throw new GatewayError("admission_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served. Resend the request.");
271
- const strikes = deps.modelStrikes;
272
- const ip = state.clientIdentity?.address;
273
- // The key rides along so a strike is charged to the caller that made it.
274
- // Without it, one recipient of a shared link could escalate a ban onto
275
- // every other caller behind the same address.
276
- const strikeIdentity =
277
- strikes && ip !== undefined
278
- ? { strikes, ip, apiKeyId: state.authorization?.id ?? null }
279
- : undefined;
280
- try {
281
- state.preparedRequest = await deps.preparer.prepare({
282
- canonicalRequest: state.canonicalRequest,
283
- authorization: state.authorization,
284
- deadlineMs: state.deadlineMs,
285
- signal: state.abortController.signal,
286
- ...(state.clientUserAgent === undefined
287
- ? {}
288
- : { clientUserAgent: state.clientUserAgent }),
289
- });
290
- } catch (error) {
291
- // Only a *model* rejection is a strike: an invalid model the key may not
292
- // use, or a name that resolves to nothing. A store outage, an aborted
293
- // request, or any other failure is not the caller probing for models.
294
- if (strikeIdentity && isModelRejection(error)) {
295
- const outcome = await strikeIdentity.strikes
296
- .noteInvalid({ ip: strikeIdentity.ip, apiKeyId: strikeIdentity.apiKeyId })
297
- .catch(() => null);
298
- if (outcome?.banned === true) throw modelAbuseBannedError();
299
- if (outcome) {
300
- const snap = state.authorization?.snapshot;
301
- const raw = snap?.model_list;
302
- const hint = Array.isArray(raw) ? (raw as readonly string[]) : raw instanceof Set ? [...raw] : undefined;
303
- throw withModelWarning(error, outcome, strikeIdentity.strikes.limit, {
304
- allowedModels: hint,
305
- });
306
- }
307
- }
308
- throw error;
309
- }
310
- // A valid model does not reset the invalid-attempt window. Old attempts
311
- // leave only when they age out of that window.
312
- if (strikeIdentity)
313
- void strikeIdentity.strikes
314
- .noteValid({ ip: strikeIdentity.ip })
315
- .catch(() => undefined);
316
- })
317
- .as("plugin");
318
- }
1
+ // Per-request context, client identity, canonical parse, and route preparation.
2
+ import { Elysia } from "elysia";
3
+ import { GatewayError } from "../../protocol/gateway-error";
4
+ import type { TrustedProxyBoundary } from "../../config/network";
5
+ import { resolveUpstreamTimeoutMs } from "../../config/timeouts";
6
+ import type { ProxyRequestStateStore } from "../request/state";
7
+ import { fastPathname } from "../pathname";
8
+ import type { ProxyRequestPreparer } from "../request/preparer";
9
+ import type { CanonicalAdapter } from "../contracts";
10
+ import type { SurfaceAdapterRegistry } from "../surface/adapters";
11
+ import { resolveClientIdentity } from "../../security/ip-boundary";
12
+ import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
13
+ import { pushStructuredConsoleLog } from "../../observability/log-ring";
14
+ import { log } from "../../observability/logger";
15
+ import { isJsonProxyRoutePath, isProxyDispatchRoute } from "./body-policy";
16
+ import { isOpaqueBodyRoute } from "../contracts";
17
+ import { peerAddressUnavailableError } from "./guard-errors";
18
+
19
+
20
+
21
+ interface RequestApp {
22
+ request(
23
+ handler: (context: { request: Request; set?: { headers: Record<string, string> } }) => void,
24
+ ): RequestApp;
25
+ }
26
+
27
+ export interface RequestContextDeps {
28
+ readonly stateStore: ProxyRequestStateStore;
29
+ readonly clock?: () => number;
30
+ /** Explicit deadline override; defaults to `CARTETHYIA_UPSTREAM_TIMEOUT_MS` (120s). */
31
+ readonly requestDeadlineMs?: number;
32
+ readonly maxBodyBytes?: number;
33
+ }
34
+
35
+ /**
36
+ * Initializes per-request `ProxyRequestState` before any other hook runs.
37
+ * Only gateway (`/v1/*`) requests get state: this middleware is mounted at
38
+ * the composition root so it also observes health checks, console traffic,
39
+ * and dashboard assets, but none of those use proxy state and none of them
40
+ * run the `/v1`-scoped cleanup hooks — initializing for them leaked a state,
41
+ * a deadline timer, a live-controller entry, and an in-flight count per
42
+ * request that was never reclaimed. Cleanup (abort + WeakMap eviction) is
43
+ * intentionally NOT done here via `afterResponse`: Elysia's afterResponse
44
+ * hook order across plugins is not something this composition can guarantee
45
+ * ahead of `createTelemetryLifecycleMiddleware`'s afterResponse (now in
46
+ * lifecycle.ts), which still needs to read the state. The WeakMap entry is
47
+ * reclaimed by GC once `request` is unreferenced; nothing needs to run after
48
+ * the response is sent.
49
+ */
50
+ export function createRequestContextMiddleware(deps: RequestContextDeps): Elysia {
51
+ const app = new Elysia() as unknown as RequestApp;
52
+ app.request(({ request, set }) => {
53
+ const pathname = fastPathname(request.url);
54
+ if (pathname !== "/v1" && !pathname.startsWith("/v1/")) return;
55
+ const state = deps.stateStore.initialize(
56
+ request,
57
+ (deps.clock ?? (() => Date.now()))(),
58
+ deps.requestDeadlineMs ?? resolveUpstreamTimeoutMs(),
59
+ );
60
+ if (set) {
61
+ set.headers["x-request-id"] = state.requestId;
62
+ Object.assign(set.headers, GATEWAY_SECURITY_HEADERS);
63
+ }
64
+ });
65
+ return app as unknown as Elysia;
66
+ }
67
+
68
+ export function createClientIdentityMiddleware(deps: {
69
+ readonly stateStore: ProxyRequestStateStore;
70
+ readonly trustedProxyBoundary: TrustedProxyBoundary;
71
+ readonly resolvePeerAddress?: (request: Request) => string | null;
72
+ }): Elysia {
73
+ // `beforeHandle`, not `onRequest`: plugin-scoped `onRequest` hooks do not
74
+ // fire for these routes under Elysia 2 beta (same scoping gap that muted
75
+ // plugin `afterResponse` before `registerTelemetryLifecycle`), which left
76
+ // `clientIdentity` unset and every gateway row without a client IP.
77
+ // `beforeHandle` hooks from `.use()` plugins run in registration order, so
78
+ // pipeline position still guarantees this executes before IP-abuse checks.
79
+ return new Elysia()
80
+ .beforeHandle(({ request, server }) => {
81
+ const peer = deps.resolvePeerAddress
82
+ ? deps.resolvePeerAddress(request)
83
+ : (server?.requestIP(request)?.address ?? null);
84
+ if (!peer)
85
+ throw peerAddressUnavailableError();
86
+ const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
87
+ const state = deps.stateStore.require(request);
88
+ state.clientIdentity = {
89
+ address,
90
+ source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
91
+ };
92
+ const path = state.ingressPath;
93
+ if (!isProxyDispatchRoute(path)) return;
94
+ // The ingress policy stage already decoded the body, so the client's
95
+ // requested model is readable here — before canonical parsing runs. It is
96
+ // read defensively and only for display: routing still resolves the model
97
+ // through the surface adapter, and a body that is not an object simply
98
+ // yields no model rather than failing the request.
99
+ const body = state.ingressBody;
100
+ const requestedModel =
101
+ body !== null && typeof body === "object" && !Array.isArray(body)
102
+ ? (body as Record<string, unknown>)["model"]
103
+ : undefined;
104
+ const userAgent = request.headers.get("user-agent");
105
+ pushStructuredConsoleLog("info", "Incoming proxy request", {
106
+ event: "request_start",
107
+ requestId: state.requestId,
108
+ method: request.method,
109
+ endpoint: path,
110
+ clientIp: address,
111
+ ...(typeof requestedModel === "string" && requestedModel.length > 0
112
+ ? { model: requestedModel }
113
+ : {}),
114
+ ...(userAgent === null || userAgent.length === 0
115
+ ? {}
116
+ : { userAgent: userAgent.slice(0, 512) }),
117
+ });
118
+ })
119
+ .as("plugin");
120
+ }
121
+
122
+ export function createCanonicalRequestMiddleware(deps: {
123
+ readonly stateStore: ProxyRequestStateStore;
124
+ readonly surfaceRegistry: SurfaceAdapterRegistry;
125
+ readonly adapters: ReadonlyMap<string, CanonicalAdapter>;
126
+ /**
127
+ * Whether this tenant's payload-capture mode keeps request bodies
128
+ * (`full`). `none`/`metadata` never read the request body at finalize, so
129
+ * the parsed ingress body can be released as soon as the canonical request
130
+ * exists instead of being held — across the whole upstream stream. Injected
131
+ * so this stage does not import the capture policy or the database.
132
+ */
133
+ readonly keepsIngressBody: (tenantId: string | null) => Promise<boolean>;
134
+ }): Elysia {
135
+ return new Elysia()
136
+ .beforeHandle(async ({ request }) => {
137
+ const path = fastPathname(request.url);
138
+ if (!path.startsWith("/v1/")) return;
139
+ if (request.method === "GET" || request.method === "HEAD") return;
140
+ // `/v1/completions` needs no separate arm: it is already in
141
+ // the JSON routes table, so the body-policy predicate covers it.
142
+ // Native routes (compact, System One) read a body but are never parsed
143
+ // into a canonical request — their bodies are opaque by contract.
144
+ if (!isJsonProxyRoutePath(path) || path === "/v1/responses/compact" || isOpaqueBodyRoute(path))
145
+ return;
146
+ // Model discovery and other GET/multimodal routes must not go through canonical parsing.
147
+ const state = deps.stateStore.require(request);
148
+ // Single-read invariant: the ingress policy middleware already decoded the
149
+ // body once into `state.ingressBody`. Never re-read `request.body` here —
150
+ // it is already consumed/locked. `undefined` means the ingress stage did
151
+ // not run (reduced composition); fail closed. An explicit `null` (JSON
152
+ // literal `null` body) is a valid single-read value and flows to the
153
+ // surface adapter's parse error rather than triggering a second read.
154
+ if (state.ingressBody === undefined)
155
+ throw new GatewayError("admission_unavailable", 503, "The request body was never read, so this route cannot be served. Resend the request.");
156
+ const body = state.ingressBody;
157
+ const headers: Record<string, string> = {};
158
+ request.headers.forEach((value, key) => {
159
+ headers[key] = value;
160
+ });
161
+ const detection = deps.surfaceRegistry.detectOnce({ body, headers, path });
162
+ if (detection.disagreement) {
163
+ // A path-vs-body disagreement is expected traffic, not a fault: a
164
+ // standard chat body ({model, messages[], max_tokens}) also matches
165
+ // the messages body shape, so nearly every chat request with
166
+ // max_tokens "disagrees". Warn only when the explicit header is
167
+ // involved — a marker contradicting the path means the client is
168
+ // genuinely confused about which surface it wants.
169
+ const markerInvolved = detection.signals.some((s) => s.source === "explicit_marker");
170
+ if (markerInvolved) {
171
+ log.warn("[surface] conflicting signals detected for surface routing", {
172
+ resolved: detection.surface,
173
+ winning_source: detection.winning_source,
174
+ signals: detection.signals,
175
+ });
176
+ } else {
177
+ log.debug("[surface] ambiguous body shape resolved by endpoint path", {
178
+ resolved: detection.surface,
179
+ signals: detection.signals,
180
+ });
181
+ }
182
+ }
183
+ const adapter = deps.adapters.get(detection.surface);
184
+ if (!adapter)
185
+ throw new GatewayError(
186
+ "capability_unsupported",
187
+ 400,
188
+ `Surface adapter unavailable: ${detection.surface}`,
189
+ { surface: detection.surface },
190
+ );
191
+ const ingress = deps.stateStore.require(request);
192
+ const userAgent = headers["user-agent"];
193
+ if (userAgent) ingress.clientUserAgent = userAgent.slice(0, 512);
194
+ ingress.canonicalRequest = adapter.parse({ body, headers, path });
195
+ // The parsed body is the largest per-request value, and once the
196
+ // canonical request exists the only later reader is payload capture — and
197
+ // only in `full` mode (see `captureTerminalPayload`). For `none`/`metadata`
198
+ // drop the reference now: held until cleanup, it would otherwise stay
199
+ // resident for the entire upstream stream.
200
+ if (!(await deps.keepsIngressBody(ingress.authorization?.tenantId ?? null))) {
201
+ ingress.releaseIngressBody();
202
+ }
203
+ })
204
+ .as("plugin");
205
+ }
206
+
207
+ export function createProxyRoutePreparationMiddleware(deps: {
208
+ readonly stateStore: ProxyRequestStateStore;
209
+ readonly preparer: ProxyRequestPreparer;
210
+ }): Elysia {
211
+ return new Elysia()
212
+ .beforeHandle(async ({ request }) => {
213
+ const path = fastPathname(request.url);
214
+ if (!path.startsWith("/v1/")) return;
215
+ if (request.method === "GET" || request.method === "HEAD") return;
216
+ const isJsonRoute = isJsonProxyRoutePath(path);
217
+ if (!isJsonRoute || path === "/v1/responses/compact" || isOpaqueBodyRoute(path)) return;
218
+ const state = deps.stateStore.require(request);
219
+ if (!state.authorization || !state.canonicalRequest)
220
+ throw new GatewayError("admission_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served. Resend the request.");
221
+ // The key rides along so a strike is charged to the caller that made it.
222
+ // Without it, one recipient of a shared link could escalate a ban onto
223
+ try {
224
+ state.preparedRequest = await deps.preparer.prepare({
225
+ canonicalRequest: state.canonicalRequest,
226
+ authorization: state.authorization,
227
+ deadlineMs: state.deadlineMs,
228
+ signal: state.abortController.signal,
229
+ ...(state.clientUserAgent === undefined
230
+ ? {}
231
+ : { clientUserAgent: state.clientUserAgent }),
232
+ });
233
+ } catch (error) {
234
+ throw error;
235
+ }
236
+ })
237
+ .as("plugin");
238
+ }
@@ -22,12 +22,23 @@ import { metrics } from "../../observability/metrics";
22
22
  export interface InFlightSnapshot {
23
23
  readonly inFlight: number;
24
24
  readonly uniqueIps: number;
25
+ /**
26
+ * How long the longest-lived flight has been tracked, in milliseconds.
27
+ *
28
+ * The count alone cannot tell a healthy queue from a leak: six flights
29
+ * could be six requests in progress or six that never finished. The age
30
+ * separates them — a flight far past its own deadline is the backstop's
31
+ * job, so a growing age with a flat count means the sweep is not running.
32
+ */
33
+ readonly oldestAgeMs: number;
25
34
  }
26
35
 
27
36
  interface Flight {
28
37
  readonly clientIp: string;
29
38
  /** Hard deadline after which the backstop sweep releases this flight. */
30
39
  expiresAtMs: number;
40
+ /** When this flight was tracked, for the age reported in a snapshot. */
41
+ readonly startedAtMs: number;
31
42
  }
32
43
 
33
44
  export interface InFlightRegistry {
@@ -50,14 +61,18 @@ export function createInFlightRegistry(): InFlightRegistry {
50
61
 
51
62
  const snapshot = (): InFlightSnapshot => {
52
63
  let uniqueIps = 0;
64
+ let oldest = 0;
65
+ const now = Date.now();
53
66
  const seen = new Set<string>();
54
67
  for (const flight of flights.values()) {
55
68
  if (!seen.has(flight.clientIp)) {
56
69
  seen.add(flight.clientIp);
57
70
  uniqueIps += 1;
58
71
  }
72
+ const age = now - flight.startedAtMs;
73
+ if (age > oldest) oldest = age;
59
74
  }
60
- return { inFlight: flights.size, uniqueIps };
75
+ return { inFlight: flights.size, uniqueIps, oldestAgeMs: oldest };
61
76
  };
62
77
 
63
78
  const publish = (): void => {
@@ -68,7 +83,11 @@ export function createInFlightRegistry(): InFlightRegistry {
68
83
 
69
84
  return {
70
85
  track(requestId, clientIp, expiresAtMs) {
71
- flights.set(requestId, { clientIp: clientIp.length > 0 ? clientIp : "unknown", expiresAtMs });
86
+ flights.set(requestId, {
87
+ clientIp: clientIp.length > 0 ? clientIp : "unknown",
88
+ expiresAtMs,
89
+ startedAtMs: Date.now(),
90
+ });
72
91
  publish();
73
92
  },
74
93
  untrack(requestId) {