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.
- package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-XaUmiBrC.js} +1 -1
- package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-CVe6RaBH.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-Dd8ZSamp.js → CliToolsPage-0XUo4UMn.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-CXGS_Ct-.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-DGnGNYnM.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-CKUFTcW4.js → CustomizationPage-DAG5J9EY.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-CzSLiDzr.js → HealthEventsModal-BKtAPYVF.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-BdmRLnq2.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-DuWzAQFL.js → OverviewPage-CqMxibJB.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Bwotu8UJ.js → ProvidersPage-B9uWu4Nz.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js → ProxyPage-BoWOhpDO.js} +1 -1
- package/dist/dashboard/assets/{ProxyPage-D7hpWbg9.js.gz → ProxyPage-BoWOhpDO.js.gz} +0 -0
- package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-7P2n--Fv.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CxZ1fTkN.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-BdgECjqq.js → SetupPage-Co12Zzaw.js} +1 -1
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-Dqf4zpS-.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-Byj2lAEm.js} +1 -1
- package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-CTkLAuRc.js} +1 -1
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-ClCMCWWQ.js} +3 -3
- package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-CWWZ-uCZ.js} +1 -1
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
- package/dist/dashboard/assets/{index-D6QzEC_n.js → index-DFVFrnWW.js} +2 -2
- package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
- package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-DBNVLdUu.js} +1 -1
- package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +22 -22
- package/package.json +1 -1
- package/src/console/cli-tools/contracts.ts +10 -4
- package/src/network/outbound-fetch.ts +281 -226
- package/src/network/pool/agent.ts +7 -0
- package/src/providers/default-registry.ts +1 -0
- package/src/providers/integrations/ollamacloud-models.ts +50 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder.ts +1373 -1641
- package/src/providers/operations/account-error-classification.ts +1684 -1660
- package/src/providers/operations/account-health-service.ts +621 -621
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +7 -2
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/empty-stream.ts +224 -224
- package/src/transport/failure-policy.ts +33 -0
- package/src/transport/routing/route-catalog.ts +34 -12
- package/src/transport/routing/router.ts +28 -0
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- 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: (
|
|
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
|
-
/**
|
|
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
|
|
59
|
-
*
|
|
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
|
|
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 (
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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. */
|