cartethyia 2.7.1 → 2.7.2

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 (91) hide show
  1. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-XaUmiBrC.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-CVe6RaBH.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-Dd8ZSamp.js → CliToolsPage-0XUo4UMn.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-CXGS_Ct-.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-DGnGNYnM.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-CKUFTcW4.js → CustomizationPage-DAG5J9EY.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-CzSLiDzr.js → HealthEventsModal-BKtAPYVF.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-BdmRLnq2.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-DuWzAQFL.js → OverviewPage-CqMxibJB.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-Bwotu8UJ.js → ProvidersPage-B9uWu4Nz.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js → ProxyPage-BoWOhpDO.js} +1 -1
  24. package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js.gz → ProxyPage-BoWOhpDO.js.gz} +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-7P2n--Fv.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  27. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CxZ1fTkN.js} +1 -1
  28. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-BdgECjqq.js → SetupPage-Co12Zzaw.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-Dqf4zpS-.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-Byj2lAEm.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-CTkLAuRc.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-ClCMCWWQ.js} +3 -3
  38. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  39. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-CWWZ-uCZ.js} +1 -1
  40. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  41. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-DFVFrnWW.js} +2 -2
  42. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  43. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-DBNVLdUu.js} +1 -1
  44. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  45. package/dist/dashboard/index.html +1 -1
  46. package/dist/dashboard/index.html.gz +0 -0
  47. package/dist/dashboard/precompressed-assets.json +22 -22
  48. package/package.json +1 -1
  49. package/src/console/cli-tools/contracts.ts +10 -4
  50. package/src/network/outbound-fetch.ts +281 -226
  51. package/src/network/pool/agent.ts +7 -0
  52. package/src/providers/default-registry.ts +1 -0
  53. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  54. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  55. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  56. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  57. package/src/providers/integrations/qoder/qoder.ts +1373 -1641
  58. package/src/providers/operations/account-error-classification.ts +1684 -1660
  59. package/src/providers/operations/account-health-service.ts +621 -621
  60. package/src/providers/operations/provider-catalog-service.ts +11 -1
  61. package/src/providers/provider-contracts.ts +7 -2
  62. package/src/security/cli-client-fingerprint.ts +9 -2
  63. package/src/transport/dispatch/empty-stream.ts +224 -224
  64. package/src/transport/failure-policy.ts +33 -0
  65. package/src/transport/routing/route-catalog.ts +34 -12
  66. package/src/transport/routing/router.ts +28 -0
  67. package/src/transport/surface/responses/encode.ts +2 -3
  68. package/src/transport/surface/responses/parse.ts +49 -7
  69. package/src/types/undici.d.ts +33 -0
  70. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  71. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  72. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  73. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  74. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  75. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  76. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  77. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  78. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  79. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  80. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  81. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  82. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  83. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  84. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  85. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  86. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  87. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  88. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  89. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  90. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  91. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
@@ -338,7 +338,17 @@ export async function registerByokProviders(
338
338
  provider_id: providerId,
339
339
  base_url: baseUrl,
340
340
  authentication_header_shape: wireProfile.authHeaderShape,
341
- buildExtraHeaders: (_context, _request, candidate) => {
341
+ buildExtraHeaders: (context, _request, candidate) => {
342
+ // An operator-set route identity wins over both auto modes: Routing
343
+ // Strategy now serves custom providers too, and its value (including
344
+ // the empty "None" choice) must be honored verbatim.
345
+ const routeAgent = context.user_agent;
346
+ if (routeAgent !== undefined) {
347
+ return {
348
+ ...(routeAgent.length > 0 ? { "user-agent": routeAgent } : {}),
349
+ ...(compatibilityProfile?.extra_headers ?? {}),
350
+ };
351
+ }
342
352
  // Explicit gateway identity wins over CLI cloaking, and Codex /
343
353
  // Claude-Code-shaped traffic can never take this path: this hook
344
354
  // only runs for BYOK custom rows, never bundled first-party
@@ -136,7 +136,11 @@ export interface ProviderDispatchContext {
136
136
  readonly outbound_attachment_fetch?: ValidatedOutboundFetch | undefined;
137
137
  /** Validated bidirectional HTTP/2 egress bound to the same selected network pool. */
138
138
  readonly outbound_http2?: ValidatedOutboundHttp2Bidi | undefined;
139
- /** Built-in API-key route-selected fallback User-Agent; custom providers leave this unset. */
139
+ /**
140
+ * Route-selected fallback User-Agent from Routing Strategy; set when the
141
+ * operator configured one (bundled API-key providers default to the Claude
142
+ * CLI identity). Empty means the operator chose "None".
143
+ */
140
144
  readonly user_agent?: string | undefined;
141
145
  /** Gateway request id, stable across provider retries for one logical request. */
142
146
  readonly request_id?: string | undefined;
@@ -188,7 +192,8 @@ export function applyRouteUserAgent(
188
192
  if (
189
193
  credential.credential_kind === "oauth" ||
190
194
  credential.credential_kind === "scoped_access_token" ||
191
- userAgent === undefined
195
+ userAgent === undefined ||
196
+ userAgent.trim().length === 0
192
197
  ) {
193
198
  return;
194
199
  }
@@ -50,13 +50,20 @@ const REMOTE_CLI_TOOL_UA: ReadonlyArray<{
50
50
  // filtering; MCP traffic uses `claude-code/<version>` instead.
51
51
  { toolId: "claude", pattern: /(^|[^a-z0-9])claude-cli\//i },
52
52
  { toolId: "claude", pattern: /(^|[^a-z0-9])claude-code\//i },
53
+ // Codex CLI (TUI/CLI builds) — the Guardian auto-review agent sends a
54
+ // `codex-auto-review` model that is only useful when the gateway can
55
+ // remap it to a serving provider. `codex-tui`, `codex-cli`, and the
56
+ // desktop agent's `opencode`-ish UAs all carry this prefix.
57
+ { toolId: "codex", pattern: /(^|[^a-z0-9])codex-(tui|cli)\//i },
53
58
  ];
54
59
 
55
60
  /**
56
61
  * Returns the remote CLI tool id recognised from the probe, or `null` when
57
62
  * the caller is not a remote-mapping client. Ordinary SDKs, curl, and local
58
- * mapping tools (Codex, …) stay unlabelled so their model ids resolve
59
- * through ordinary aliases / allowlists.
63
+ * mapping tools stay unlabelled so their model ids resolve through ordinary
64
+ * aliases / allowlists. Codex is labelled because its Guardian auto-review
65
+ * agent sends the hardcoded `codex-auto-review` model, which must be remapable
66
+ * to a serving provider for approval reviews to work at all.
60
67
  */
61
68
  export function detectRemoteCliToolId(probe: CliClientProbe): string | null {
62
69
  const userAgent = readUserAgent(probe);
@@ -1,224 +1,224 @@
1
- /**
2
- * Empty-stream replay + pre-start failover.
3
- *
4
- * When a provider returns HTTP 200 but the response body decodes to zero
5
- * meaningful events, the request is replayed byte-identical (same headers,
6
- * same body) up to `maxEmptyReplays` times with exponential backoff. If all
7
- * replays on one endpoint fail, the helper advances to the next endpoint in
8
- * the caller-supplied ordered list.
9
- *
10
- * The helper is transport-layer: it owns fetch, watchdog, backoff, and
11
- * endpoint rotation. The caller owns SSE decoding via the `DrainStream`
12
- * callback and supplies materialized headers + serialized body so replays
13
- * are byte-identical.
14
- *
15
- * Design: the replay count, backoff, and the ordered endpoint list are the
16
- * caller's contract; this helper owns fetch, watchdog, and rotation so a
17
- * provider that answers 200 with no events is retried on the next endpoint
18
- * rather than surfacing an empty body to the client.
19
- */
20
- import { GatewayError } from "../../protocol/gateway-error";
21
- import {
22
- fallbackRetryDelayMs,
23
- MIN_RETRY_BUDGET_MS,
24
- sleep as abortableSleep,
25
- } from "../failure-policy";
26
-
27
- // ─── Public types ──────────────────────────────────────────────────────────
28
-
29
- /** What the caller's drain observed. Counts only dispatched data events:
30
- * comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
31
- export interface StreamTally {
32
- readonly dataEvents: number;
33
- readonly meaningful: boolean;
34
- readonly thoughtOnly: boolean;
35
- }
36
-
37
- /** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
38
- export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
39
-
40
- /** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
41
- export type EmptyStreamFetch = (
42
- url: string,
43
- init: { headers: Record<string, string>; body: string; signal: AbortSignal },
44
- ) => Promise<Response>;
45
-
46
- /**
47
- * Retry-delay policy.
48
- *
49
- * Delays come from `fallbackRetryDelayMs` (capped exponential with full
50
- * jitter), whose base and cap are operator-tunable. There is deliberately no
51
- * per-caller base: the local formula this replaced was uncapped and
52
- * unjittered, so a provider returning 200-with-no-events could drive an
53
- * unbounded wait, and concurrent clients could resynchronize their retries.
54
- */
55
- export interface EmptyStreamPolicy {
56
- readonly maxEmptyReplays?: number;
57
- readonly firstEventTimeoutMs?: number;
58
- readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
59
- }
60
-
61
- export interface EmptyStreamResult {
62
- readonly response: Response;
63
- readonly tally: StreamTally;
64
- readonly endpoint: string;
65
- readonly replaysUsed: number;
66
- }
67
-
68
- // ─── Defaults ──────────────────────────────────────────────────────────────
69
-
70
- const DEFAULT_MAX_REPLAYS = 2;
71
- const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
72
-
73
-
74
- // ─── Helpers ───────────────────────────────────────────────────────────────
75
-
76
- /** Pure predicate: zero data events, or data events with nothing meaningful. */
77
- export function isEmptyStream(tally: StreamTally): boolean {
78
- return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
79
- }
80
-
81
-
82
-
83
- // ─── Main function ─────────────────────────────────────────────────────────
84
-
85
- /**
86
- * Replays the byte-identical request while responses drain empty,
87
- * failing over across ordered endpoints pre-start.
88
- *
89
- * Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
90
- * GatewayError (deadline_exceeded, 504, upstream) when a first event never
91
- * arrives within the window, and GatewayError (transport_closed, 499) on
92
- * client abort.
93
- */
94
- export async function fetchWithEmptyStreamRecovery(
95
- endpoints: readonly string[],
96
- request: { headers: Record<string, string>; body: string },
97
- deps: {
98
- fetch: EmptyStreamFetch;
99
- drain: DrainStream;
100
- signal: AbortSignal;
101
- deadlineMs: number;
102
- policy?: EmptyStreamPolicy;
103
- },
104
- ): Promise<EmptyStreamResult> {
105
- const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
106
- const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
107
- const sleep = deps.policy?.sleep ?? abortableSleep;
108
-
109
- if (endpoints.length === 0) {
110
- throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
111
- }
112
-
113
- for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
114
- const endpoint = endpoints[endpointIndex]!;
115
- const isLastEndpoint = endpointIndex === endpoints.length - 1;
116
- const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
117
-
118
- for (let replay = 0; replay <= replaysAllowed; replay++) {
119
- // Check abort before each fetch
120
- if (deps.signal.aborted) {
121
- throw new GatewayError("transport_closed", 499, "Aborted");
122
- }
123
-
124
- // Check deadline floor
125
- const now = Date.now();
126
- const remaining = deps.deadlineMs - now;
127
- if (remaining < MIN_RETRY_BUDGET_MS) {
128
- throw new GatewayError(
129
- "platform_unavailable",
130
- 502,
131
- "Deadline exhausted before empty-stream replay",
132
- {},
133
- "upstream",
134
- );
135
- }
136
-
137
- const fetchTimeout = Math.min(firstEventTimeout, remaining);
138
-
139
- let response: Response;
140
- try {
141
- response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
142
- } catch (error) {
143
- if (error instanceof GatewayError) throw error;
144
- break; // advance to next endpoint
145
- }
146
-
147
- // Drain the response
148
- let tally: StreamTally;
149
- try {
150
- tally = await deps.drain(response, deps.signal);
151
- } catch (error) {
152
- if (error instanceof GatewayError) throw error;
153
- break; // advance to next endpoint
154
- }
155
-
156
- // Check if empty
157
- if (!isEmptyStream(tally)) {
158
- // Success — return the response and tally
159
- return {
160
- response,
161
- tally,
162
- endpoint,
163
- replaysUsed: replay,
164
- };
165
- }
166
-
167
- // Empty — replay if we haven't exhausted this endpoint's budget
168
- if (replay < replaysAllowed) {
169
- const delay = fallbackRetryDelayMs(replay);
170
- try {
171
- await sleep(delay, deps.signal);
172
- } catch (error) {
173
- if (error instanceof GatewayError) throw error;
174
- throw error;
175
- }
176
- }
177
- }
178
- }
179
-
180
- // All endpoints exhausted
181
- throw new GatewayError(
182
- "platform_unavailable",
183
- 502,
184
- "All endpoints returned empty streams",
185
- {},
186
- "upstream",
187
- );
188
- }
189
-
190
- // ─── Internal helpers ──────────────────────────────────────────────────────
191
-
192
- async function fetchWithTimeout(
193
- fetch: EmptyStreamFetch,
194
- url: string,
195
- request: { headers: Record<string, string>; body: string },
196
- signal: AbortSignal,
197
- timeoutMs: number,
198
- ): Promise<Response> {
199
- // Race the fetch against a first-event timeout
200
- const timeoutPromise = new Promise<never>((_, reject) => {
201
- const timer = setTimeout(() => {
202
- // A first-event timeout is the gateway waiting on a silent upstream (e.g.
203
- // a provider that holds the request in a queue), not the client leaving.
204
- // It must surface as a deadline failure, never as a `transport_closed`
205
- // 499 — that code is reserved for genuine client cancellations and would
206
- // otherwise hide a real upstream wait behind "cancelled by client".
207
- reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
208
- }, timeoutMs);
209
- signal.addEventListener("abort", () => {
210
- clearTimeout(timer);
211
- reject(new GatewayError("transport_closed", 499, "Aborted"));
212
- }, { once: true });
213
- });
214
-
215
- try {
216
- return await Promise.race([
217
- fetch(url, { headers: request.headers, body: request.body, signal }),
218
- timeoutPromise,
219
- ]);
220
- } catch (error) {
221
- if (error instanceof GatewayError) throw error;
222
- throw error;
223
- }
224
- }
1
+ /**
2
+ * Empty-stream replay + pre-start failover.
3
+ *
4
+ * When a provider returns HTTP 200 but the response body decodes to zero
5
+ * meaningful events, the request is replayed byte-identical (same headers,
6
+ * same body) up to `maxEmptyReplays` times with exponential backoff. If all
7
+ * replays on one endpoint fail, the helper advances to the next endpoint in
8
+ * the caller-supplied ordered list.
9
+ *
10
+ * The helper is transport-layer: it owns fetch, watchdog, backoff, and
11
+ * endpoint rotation. The caller owns SSE decoding via the `DrainStream`
12
+ * callback and supplies materialized headers + serialized body so replays
13
+ * are byte-identical.
14
+ *
15
+ * Design: the replay count, backoff, and the ordered endpoint list are the
16
+ * caller's contract; this helper owns fetch, watchdog, and rotation so a
17
+ * provider that answers 200 with no events is retried on the next endpoint
18
+ * rather than surfacing an empty body to the client.
19
+ */
20
+ import { GatewayError } from "../../protocol/gateway-error";
21
+ import {
22
+ fallbackRetryDelayMs,
23
+ MIN_RETRY_BUDGET_MS,
24
+ sleep as abortableSleep,
25
+ } from "../failure-policy";
26
+
27
+ // ─── Public types ──────────────────────────────────────────────────────────
28
+
29
+ /** What the caller's drain observed. Counts only dispatched data events:
30
+ * comments/keepalives, `[DONE]`, and whitespace-only data are excluded. */
31
+ export interface StreamTally {
32
+ readonly dataEvents: number;
33
+ readonly meaningful: boolean;
34
+ readonly thoughtOnly: boolean;
35
+ }
36
+
37
+ /** Drain one response body to a tally. Provider-supplied; uses decodeSseEvents. */
38
+ export type DrainStream = (response: Response, signal: AbortSignal) => Promise<StreamTally>;
39
+
40
+ /** Bound fetch for one endpoint. Receives materialized headers + serialized body. */
41
+ export type EmptyStreamFetch = (
42
+ url: string,
43
+ init: { headers: Record<string, string>; body: string; signal: AbortSignal },
44
+ ) => Promise<Response>;
45
+
46
+ /**
47
+ * Retry-delay policy.
48
+ *
49
+ * Delays come from `fallbackRetryDelayMs` (capped exponential with full
50
+ * jitter), whose base and cap are operator-tunable. There is deliberately no
51
+ * per-caller base: the local formula this replaced was uncapped and
52
+ * unjittered, so a provider returning 200-with-no-events could drive an
53
+ * unbounded wait, and concurrent clients could resynchronize their retries.
54
+ */
55
+ export interface EmptyStreamPolicy {
56
+ readonly maxEmptyReplays?: number;
57
+ readonly firstEventTimeoutMs?: number;
58
+ readonly sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
59
+ }
60
+
61
+ export interface EmptyStreamResult {
62
+ readonly response: Response;
63
+ readonly tally: StreamTally;
64
+ readonly endpoint: string;
65
+ readonly replaysUsed: number;
66
+ }
67
+
68
+ // ─── Defaults ──────────────────────────────────────────────────────────────
69
+
70
+ const DEFAULT_MAX_REPLAYS = 2;
71
+ const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 200_000; // 200s, matches CARTETHYIA_STREAM_FIRST_CHUNK_TIMEOUT_MS default
72
+
73
+
74
+ // ─── Helpers ───────────────────────────────────────────────────────────────
75
+
76
+ /** Pure predicate: zero data events, or data events with nothing meaningful. */
77
+ export function isEmptyStream(tally: StreamTally): boolean {
78
+ return tally.dataEvents === 0 || (!tally.meaningful && !tally.thoughtOnly);
79
+ }
80
+
81
+
82
+
83
+ // ─── Main function ─────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Replays the byte-identical request while responses drain empty,
87
+ * failing over across ordered endpoints pre-start.
88
+ *
89
+ * Throws GatewayError (platform_unavailable, 502, upstream) on exhaustion,
90
+ * GatewayError (deadline_exceeded, 504, upstream) when a first event never
91
+ * arrives within the window, and GatewayError (transport_closed, 499) on
92
+ * client abort.
93
+ */
94
+ export async function fetchWithEmptyStreamRecovery(
95
+ endpoints: readonly string[],
96
+ request: { headers: Record<string, string>; body: string },
97
+ deps: {
98
+ fetch: EmptyStreamFetch;
99
+ drain: DrainStream;
100
+ signal: AbortSignal;
101
+ deadlineMs: number;
102
+ policy?: EmptyStreamPolicy;
103
+ },
104
+ ): Promise<EmptyStreamResult> {
105
+ const maxReplays = deps.policy?.maxEmptyReplays ?? DEFAULT_MAX_REPLAYS;
106
+ const firstEventTimeout = deps.policy?.firstEventTimeoutMs ?? DEFAULT_FIRST_EVENT_TIMEOUT_MS;
107
+ const sleep = deps.policy?.sleep ?? abortableSleep;
108
+
109
+ if (endpoints.length === 0) {
110
+ throw new GatewayError("platform_unavailable", 502, "No endpoints available", {}, "upstream");
111
+ }
112
+
113
+ for (let endpointIndex = 0; endpointIndex < endpoints.length; endpointIndex++) {
114
+ const endpoint = endpoints[endpointIndex]!;
115
+ const isLastEndpoint = endpointIndex === endpoints.length - 1;
116
+ const replaysAllowed = isLastEndpoint ? maxReplays : maxReplays;
117
+
118
+ for (let replay = 0; replay <= replaysAllowed; replay++) {
119
+ // Check abort before each fetch
120
+ if (deps.signal.aborted) {
121
+ throw new GatewayError("transport_closed", 499, "Aborted");
122
+ }
123
+
124
+ // Check deadline floor
125
+ const now = Date.now();
126
+ const remaining = deps.deadlineMs - now;
127
+ if (remaining < MIN_RETRY_BUDGET_MS) {
128
+ throw new GatewayError(
129
+ "platform_unavailable",
130
+ 502,
131
+ "Deadline exhausted before empty-stream replay",
132
+ {},
133
+ "upstream",
134
+ );
135
+ }
136
+
137
+ const fetchTimeout = Math.min(firstEventTimeout, remaining);
138
+
139
+ let response: Response;
140
+ try {
141
+ response = await fetchWithTimeout(deps.fetch, endpoint, request, deps.signal, fetchTimeout);
142
+ } catch (error) {
143
+ if (error instanceof GatewayError) throw error;
144
+ break; // advance to next endpoint
145
+ }
146
+
147
+ // Drain the response
148
+ let tally: StreamTally;
149
+ try {
150
+ tally = await deps.drain(response, deps.signal);
151
+ } catch (error) {
152
+ if (error instanceof GatewayError) throw error;
153
+ break; // advance to next endpoint
154
+ }
155
+
156
+ // Check if empty
157
+ if (!isEmptyStream(tally)) {
158
+ // Success — return the response and tally
159
+ return {
160
+ response,
161
+ tally,
162
+ endpoint,
163
+ replaysUsed: replay,
164
+ };
165
+ }
166
+
167
+ // Empty — replay if we haven't exhausted this endpoint's budget
168
+ if (replay < replaysAllowed) {
169
+ const delay = fallbackRetryDelayMs(replay);
170
+ try {
171
+ await sleep(delay, deps.signal);
172
+ } catch (error) {
173
+ if (error instanceof GatewayError) throw error;
174
+ throw error;
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ // All endpoints exhausted
181
+ throw new GatewayError(
182
+ "platform_unavailable",
183
+ 502,
184
+ "All endpoints returned empty streams",
185
+ {},
186
+ "upstream",
187
+ );
188
+ }
189
+
190
+ // ─── Internal helpers ──────────────────────────────────────────────────────
191
+
192
+ async function fetchWithTimeout(
193
+ fetch: EmptyStreamFetch,
194
+ url: string,
195
+ request: { headers: Record<string, string>; body: string },
196
+ signal: AbortSignal,
197
+ timeoutMs: number,
198
+ ): Promise<Response> {
199
+ // Race the fetch against a first-event timeout
200
+ const timeoutPromise = new Promise<never>((_, reject) => {
201
+ const timer = setTimeout(() => {
202
+ // A first-event timeout is the gateway waiting on a silent upstream (e.g.
203
+ // a provider that holds the request in a queue), not the client leaving.
204
+ // It must surface as a deadline failure, never as a `transport_closed`
205
+ // 499 — that code is reserved for genuine client cancellations and would
206
+ // otherwise hide a real upstream wait behind "cancelled by client".
207
+ reject(new GatewayError("deadline_exceeded", 504, "First event timeout", {}, "upstream"));
208
+ }, timeoutMs);
209
+ signal.addEventListener("abort", () => {
210
+ clearTimeout(timer);
211
+ reject(new GatewayError("transport_closed", 499, "Aborted"));
212
+ }, { once: true });
213
+ });
214
+
215
+ try {
216
+ return await Promise.race([
217
+ fetch(url, { headers: request.headers, body: request.body, signal }),
218
+ timeoutPromise,
219
+ ]);
220
+ } catch (error) {
221
+ if (error instanceof GatewayError) throw error;
222
+ throw error;
223
+ }
224
+ }
@@ -2,6 +2,7 @@ import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "..
2
2
  import { isRecord } from "../protocol/codecs";
3
3
  import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
4
4
  import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
5
+ import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
5
6
 
6
7
  /**
7
8
  * How much of the upstream's error body is kept and surfaced.
@@ -232,6 +233,31 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
232
233
  // DNS failure. Failing the client request instead of failing over is the wrong
233
234
  // outcome for a whole class the table already names as platform-wide.
234
235
  const statusCode = error.status;
236
+ // Qoder answers an exhausted quota by closing the stream with an upstream 499
237
+ // (nginx client-close style). That is a refusal from the upstream — the
238
+ // gateway's own aborts are `transport_closed`, produced locally, and never
239
+ // carry a qoder providerId — so a 499 scoped to qoder is quota, not a
240
+ // malformed request. Without this it fell through to `invalid_request`
241
+ // (non-retryable, no cooldown) and the account was re-dialled on every
242
+ // request inside the exhausted window until a late 429 finally classified it.
243
+ if (
244
+ error.origin === "upstream" &&
245
+ statusCode === 499 &&
246
+ (providerId === "qoder" || providerId === "qodercn")
247
+ ) {
248
+ const cooldownMs = resolveAccountQuotaCooldownMs();
249
+ return {
250
+ retryable: true,
251
+ mutatesAccount: true,
252
+ category: "quota_exceeded",
253
+ scope: "account",
254
+ origin: error.origin,
255
+ ...(providerCode ? { providerCode } : {}),
256
+ ...(providerId ? { providerId } : {}),
257
+ cooldownMs,
258
+ statusCode,
259
+ };
260
+ }
235
261
  const retryableByStatus =
236
262
  statusCode === 401 ||
237
263
  statusCode === 403 ||
@@ -459,6 +485,13 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
459
485
  "model_context_window_exceeded",
460
486
  // InferHub's spelling for the same condition.
461
487
  "context_too_long",
488
+ // Trae answers an overflow on its streaming surface with the bare numeric
489
+ // code `4026` and the prose "We're sorry, your context length has exceeded
490
+ // the maximum limit." — no HTTP status, no symbolic code (verified live
491
+ // 2026-10: `event:error data:{"code":4026,"message":"..."}`). Without it
492
+ // the frame fell to the 502 `platform_unavailable` fallback and the client
493
+ // never saw the 413 it compacts on.
494
+ "4026",
462
495
  ]);
463
496
 
464
497
  /**
@@ -35,7 +35,19 @@ import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../prov
35
35
 
36
36
  const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
37
37
 
38
+ /** Codex's built-in (non-routeable) model spellings worth matching. */
39
+ const CODEX_REVIEW_MODELS = new Set(["codex-auto-review"]);
40
+
38
41
  export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
42
+ if (toolId === "codex") {
43
+ const normalized = sourceModel.trim().toLowerCase();
44
+ if (CODEX_REVIEW_MODELS.has(normalized)) {
45
+ // Guardian's auto-review model is one fixed spelling; surface the full
46
+ // spelling plus the bare family so a slot mapped to either one hits.
47
+ return [...new Set([sourceModel, "codex-auto-review"])];
48
+ }
49
+ return [sourceModel];
50
+ }
39
51
  if (toolId !== "claude") return [sourceModel];
40
52
  const normalized = sourceModel.trim().toLowerCase();
41
53
  const family =
@@ -199,23 +211,33 @@ export function capabilityProfileCacheSize(): number {
199
211
  return capabilityProfileCache.size;
200
212
  }
201
213
 
202
- /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
214
+ /** Resolves route identity: bundled adapters without a native User-Agent builder, plus custom providers. */
203
215
  export function resolveRouteUserAgent(
204
216
  providerId: string,
205
217
  tenantUserAgent: string | undefined,
206
218
  globalUserAgent: string | undefined,
207
219
  ): string | undefined {
208
- if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
209
- // A provider whose operator set no identity of their own gets the Claude Code
210
- // CLI identity, resolved live so the stamped version tracks discovery. This
211
- // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
212
- // client on requests that mostly reached neither.
213
- const selected = resolveTenantOverride(
214
- tenantUserAgent,
215
- globalUserAgent,
216
- buildClaudeCliUserAgent(),
217
- );
218
- return selected.trim() === "" ? undefined : selected;
220
+ if (isBundledProviderId(providerId)) {
221
+ // A bundled adapter that frames its own identity headers never consumes a
222
+ // route User-Agent (Command Code, Qoder, buddy, ...), so it never gets one
223
+ // — the control is hidden for it too.
224
+ if (providerHasAdapterUserAgent(providerId)) return undefined;
225
+ // Bundled API-key providers: an operator who set no identity of their own
226
+ // gets the Claude Code CLI identity, resolved live so the stamped version
227
+ // tracks discovery. This used to be a frozen `codex_cli_rs` literal, which
228
+ // named a different vendor's client on requests that mostly reached neither.
229
+ const selected = resolveTenantOverride(
230
+ tenantUserAgent,
231
+ globalUserAgent,
232
+ buildClaudeCliUserAgent(),
233
+ );
234
+ return selected.trim() === "" ? undefined : selected;
235
+ }
236
+ // Custom (BYOK) providers: only an explicit operator choice rides on the
237
+ // route; the adapter owns the default otherwise (see `registerByokProviders`).
238
+ // An explicit empty choice ("None") is preserved so the adapter can honor it.
239
+ const selected = resolveTenantOverride(tenantUserAgent, globalUserAgent, undefined);
240
+ return selected === undefined ? undefined : selected.trim();
219
241
  }
220
242
 
221
243
  /** One persisted model row ready for candidate construction. */