@bitkyc08/opencodex 2.49.0 → 2.51.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.
- package/AGENTS_INSTALL.md +9 -1
- package/README.md +3 -0
- package/bin/ocx.mjs +222 -71
- package/gui/dist/assets/index-D7BdZpZm.js +115 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/qoder/adapter.ts +69 -1
- package/src/adapters/qoder/scaffold-guard.ts +233 -0
- package/src/claude/agents-inject.ts +29 -5
- package/src/claude/desktop-3p.ts +31 -3
- package/src/claude/gateway-cache.ts +12 -21
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +43 -1
- package/src/cli/claude-agent-startup-sync.ts +26 -1
- package/src/cli/claude.ts +138 -20
- package/src/cli/config-command.ts +67 -1
- package/src/cli/connect.ts +181 -14
- package/src/cli/dispatch.ts +53 -9
- package/src/cli/doctor.ts +9 -2
- package/src/cli/ensure-desired-integrations.ts +10 -0
- package/src/cli/gui-pair-client.ts +1 -12
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +367 -0
- package/src/cli/index.ts +99 -31
- package/src/cli/launcher-context.ts +1 -1
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/registry.ts +43 -3
- package/src/cli/status.ts +325 -5
- package/src/cli/version-skew.ts +4 -1
- package/src/cli.ts +2 -2
- package/src/client/catalog-compatibility.ts +192 -0
- package/src/client/connect.ts +31 -0
- package/src/client/hub-client.ts +52 -0
- package/src/client/hub-state.ts +214 -0
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +50 -13
- package/src/codex/auth-api.ts +156 -28
- package/src/codex/auth-context.ts +21 -0
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +71 -2
- package/src/codex/catalog/sync.ts +99 -0
- package/src/codex/codex-write-lock.ts +11 -2
- package/src/codex/desired-state.ts +47 -1
- package/src/codex/inject-coordination.ts +10 -5
- package/src/codex/inject.ts +29 -12
- package/src/codex/loopback-target.ts +45 -0
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +54 -8
- package/src/codex/routing.ts +48 -1
- package/src/codex/runtime.ts +37 -3
- package/src/codex/sync.ts +29 -9
- package/src/codex/warmup.ts +21 -4
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config/pending-teardown.ts +1 -1
- package/src/config.ts +184 -12
- package/src/generated/compatibility-version.json +188 -116
- package/src/grok/status.ts +9 -1
- package/src/integrations/config-io.ts +54 -1
- package/src/lib/bun-runtime.ts +1 -1
- package/src/lib/errors.ts +8 -0
- package/src/lib/gui-pair-capability.ts +27 -0
- package/src/lib/local-destinations.ts +162 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/quota.ts +20 -1
- package/src/providers/registry.ts +35 -6
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +10 -7
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +12 -6
- package/src/server/hub-state.ts +98 -0
- package/src/server/images.ts +2 -2
- package/src/server/index.ts +149 -8
- package/src/server/management/api-access.ts +14 -3
- package/src/server/management/config-routes.ts +2 -2
- package/src/server/management/cursor-integration-routes.ts +13 -4
- package/src/server/management/logs-usage-routes.ts +4 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +9 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +1 -1
- package/src/server/proxy-liveness.ts +7 -1
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log-conversation.ts +41 -1
- package/src/server/request-log.ts +10 -0
- package/src/server/responses/codex-auth-error.ts +18 -1
- package/src/server/responses/codex-ws-exchange.ts +36 -4
- package/src/server/responses/codex-ws-wire.ts +76 -5
- package/src/server/responses/compact.ts +28 -11
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +201 -48
- package/src/server/responses/policy-fallback.ts +13 -3
- package/src/server/search.ts +2 -2
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +965 -68
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +93 -4
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/update/badge.ts +3 -2
- package/src/update/index.ts +317 -64
- package/src/update/install-detection.d.mts +6 -0
- package/src/update/install-detection.mjs +73 -0
- package/src/update/job.ts +101 -49
- package/src/update/pnpm-global-install.d.mts +144 -0
- package/src/update/pnpm-global-install.mjs +591 -0
- package/src/update/pnpm-invocation.d.mts +43 -0
- package/src/update/pnpm-invocation.mjs +141 -0
- package/src/update/registry-integrity.d.mts +16 -0
- package/src/update/registry-integrity.mjs +37 -0
- package/src/update/transactional-install.d.mts +1 -1
- package/src/update/transactional-install.mjs +101 -7
- package/src/update/tray-update-plan.mjs +1 -1
- package/src/vision/plan.ts +13 -3
- package/src/vision/routed-describe.ts +51 -20
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/gui/dist/assets/index-BtyONQrZ.js +0 -115
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* Lives outside cli.ts (which dispatches argv at module top level) so tests can import it.
|
|
11
11
|
*/
|
|
12
12
|
import { loadConfig } from "../config";
|
|
13
|
+
import { isWildcardHostname } from "../codex/loopback-target";
|
|
13
14
|
import { readAlivePid, readRuntimePort, verifyPidIdentity } from "../config/process-state";
|
|
14
15
|
import { directLocalHttpFetch } from "./direct-local-http";
|
|
15
16
|
|
|
@@ -82,10 +83,15 @@ export interface LiveProxy {
|
|
|
82
83
|
/**
|
|
83
84
|
* Host to probe for a given bind hostname: wildcards answer on IPv4 loopback, and raw
|
|
84
85
|
* IPv6 addresses must be bracketed or the composed URL is invalid.
|
|
86
|
+
*
|
|
87
|
+
* The wildcard test is `isWildcardHostname`, not a list of spellings. This function used to
|
|
88
|
+
* know exactly three (`0.0.0.0`, `::`, `[::]`) while the bind-scope predicate knew every
|
|
89
|
+
* all-zero form, so `ocx` composed `http://0.0.0.0.:10100` or `http://*:10100` — unreachable
|
|
90
|
+
* URLs — for a config the server itself treated as a wildcard bind. One predicate, both sides.
|
|
85
91
|
*/
|
|
86
92
|
export function probeHostname(hostname: string | undefined): string {
|
|
87
93
|
const trimmed = (hostname ?? "").trim();
|
|
88
|
-
if (!trimmed || trimmed
|
|
94
|
+
if (!trimmed || isWildcardHostname(trimmed)) return "127.0.0.1";
|
|
89
95
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed;
|
|
90
96
|
return trimmed.includes(":") ? `[${trimmed}]` : trimmed;
|
|
91
97
|
}
|
|
@@ -21,6 +21,58 @@ import type { TranslatorBudget } from "../lib/translator-budget";
|
|
|
21
21
|
*/
|
|
22
22
|
export const MAX_DECOMPRESSED_BODY_BYTES = 256 * 1024 * 1024;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Hard ceiling on the opt-in `maxInboundBodyBytes` (#3573).
|
|
26
|
+
*
|
|
27
|
+
* The opt-in exists because a 922k-token session serializes past the 256 MiB default, and the
|
|
28
|
+
* request that crosses it is the compaction request itself — so the session can no longer
|
|
29
|
+
* shrink and is stuck. An UNBOUNDED inbound cap is not an acceptable answer: this admission
|
|
30
|
+
* limit is the only thing standing between one request and the process heap, and
|
|
31
|
+
* `readBoundedJsonRequestBody` materializes the body several times over (retained wire bytes,
|
|
32
|
+
* decoded bytes, the decoded string, the re-encoded measurement copies, and the parsed object
|
|
33
|
+
* graph), so peak RSS is a MULTIPLE of whatever is admitted here. 512 MiB is the largest value
|
|
34
|
+
* that keeps that multiple survivable on an ordinary machine, and it is what #3573 asked for.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_CONFIGURABLE_INBOUND_BODY_BYTES = 512 * 1024 * 1024;
|
|
37
|
+
|
|
38
|
+
/** Floor for the opt-in. Below this an ordinary multi-image turn cannot be admitted at all. */
|
|
39
|
+
export const MIN_CONFIGURABLE_INBOUND_BODY_BYTES = 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the configured inbound admission limit, clamped to the supported range.
|
|
43
|
+
*
|
|
44
|
+
* Pure and total on purpose: the schema in `src/config.ts` degrades an invalid hand edit to
|
|
45
|
+
* `undefined` rather than failing the parse, so the schema cannot be the place the ceiling is
|
|
46
|
+
* enforced. Every caller resolves through here, which makes this the single auditable bound
|
|
47
|
+
* regardless of how the config object was produced.
|
|
48
|
+
*
|
|
49
|
+
* Omitted, zero, or non-finite = the 256 MiB default, so an unconfigured proxy admits exactly
|
|
50
|
+
* what it admits today.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveInboundBodyLimitBytes(configured: number | undefined): number {
|
|
53
|
+
if (configured === undefined || !Number.isFinite(configured) || configured <= 0) {
|
|
54
|
+
return MAX_DECOMPRESSED_BODY_BYTES;
|
|
55
|
+
}
|
|
56
|
+
return Math.min(
|
|
57
|
+
Math.max(Math.floor(configured), MIN_CONFIGURABLE_INBOUND_BODY_BYTES),
|
|
58
|
+
MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render a byte count, or nothing at all. `DecompressedBodyTooLargeError` accepts non-finite
|
|
64
|
+
* and untyped values from legacy callers and deliberately keeps them out of its own message;
|
|
65
|
+
* the client-facing message inherits that rule rather than printing `NaN MB`.
|
|
66
|
+
*/
|
|
67
|
+
function megabytes(bytes: number): string | null {
|
|
68
|
+
return Number.isFinite(bytes) && bytes >= 0 && bytes <= Number.MAX_SAFE_INTEGER
|
|
69
|
+
? (bytes / (1024 * 1024)).toFixed(1)
|
|
70
|
+
: null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const INBOUND_CEILING_MB = (MAX_CONFIGURABLE_INBOUND_BODY_BYTES / (1024 * 1024)).toFixed(1);
|
|
74
|
+
|
|
75
|
+
|
|
24
76
|
export class UnsupportedContentEncodingError extends Error {
|
|
25
77
|
constructor(readonly encoding: string) {
|
|
26
78
|
super(`Unsupported content-encoding: ${encoding}`);
|
|
@@ -54,6 +106,33 @@ export class DecompressedBodyTooLargeError extends Error {
|
|
|
54
106
|
}
|
|
55
107
|
}
|
|
56
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Name OpenCodex as the refuser, and name the lever.
|
|
111
|
+
*
|
|
112
|
+
* #4112 gave the UPSTREAM context refusal on `/v1/responses` its own HTTP 413 with
|
|
113
|
+
* `context_length_exceeded`. That makes the two 413s on this surface look alike to a client
|
|
114
|
+
* while having opposite remedies: the upstream one means the provider will not take the turn,
|
|
115
|
+
* this one means the proxy never read it and a config key would have let it through. The
|
|
116
|
+
* wording deliberately avoids "context window"/"context length", which `classifyError` treats
|
|
117
|
+
* as evidence of an upstream context verdict.
|
|
118
|
+
*/
|
|
119
|
+
export function describeInboundBodyRefusal(error: DecompressedBodyTooLargeError): string {
|
|
120
|
+
// A lower-bound measurement stopped counting at the cap; reporting it as exact would be a lie.
|
|
121
|
+
const approximate = error.measurement === "declared_wire" || error.measurement === "decoded_exact"
|
|
122
|
+
? "" : "at least ";
|
|
123
|
+
const observed = megabytes(error.bytes);
|
|
124
|
+
const limit = megabytes(error.limit);
|
|
125
|
+
const sizes = limit === null
|
|
126
|
+
? "the body is above the inbound admission limit"
|
|
127
|
+
: observed === null
|
|
128
|
+
? `the body is above the ${limit} MB inbound admission limit`
|
|
129
|
+
: `the body is ${approximate}${observed} MB, above the ${limit} MB inbound admission limit`;
|
|
130
|
+
return `OpenCodex refused this request before reading it: ${sizes}. `
|
|
131
|
+
+ "This is a local proxy limit, not a provider refusal. Raise \"maxInboundBodyBytes\" in "
|
|
132
|
+
+ `config.json (ceiling ${INBOUND_CEILING_MB} MB) and restart the proxy, or compact the `
|
|
133
|
+
+ "conversation earlier.";
|
|
134
|
+
}
|
|
135
|
+
|
|
57
136
|
function assertBodySizeWithinLimit(
|
|
58
137
|
body: Uint8Array,
|
|
59
138
|
maxBytes: number,
|
|
@@ -259,7 +338,16 @@ export async function readBoundedJsonRequestBody(
|
|
|
259
338
|
}
|
|
260
339
|
}
|
|
261
340
|
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
-
|
|
341
|
+
/**
|
|
342
|
+
* Parse a JSON data-plane body using the shared admission cap.
|
|
343
|
+
*
|
|
344
|
+
* `maxBytes` is the resolved per-deployment limit from `resolveInboundBodyLimitBytes()`;
|
|
345
|
+
* omitting it keeps the 256 MiB default for callers with no config in scope.
|
|
346
|
+
*/
|
|
347
|
+
export function readJsonRequestBody(
|
|
348
|
+
req: Request,
|
|
349
|
+
budget?: TranslatorBudget,
|
|
350
|
+
maxBytes: number = MAX_DECOMPRESSED_BODY_BYTES,
|
|
351
|
+
): Promise<unknown> {
|
|
352
|
+
return readBoundedJsonRequestBody(req, maxBytes, budget);
|
|
265
353
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Best-effort chat/session correlation for Logs / usage.jsonl (#330).
|
|
3
3
|
* Opaque ids only — never persist raw emails or Claude Desktop system-hash fallbacks.
|
|
4
4
|
*/
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
|
|
7
7
|
/** Reject absurdly long client strings before hashing (DoS / JSONL bloat). */
|
|
8
8
|
export const LOG_CONVERSATION_ID_INPUT_MAX = 4096;
|
|
@@ -217,3 +217,43 @@ export function summarizeConversationLogs(entries: readonly TotalsSource[]): Con
|
|
|
217
217
|
unmeteredRequests,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Request-scoped Go affinity for requests that carry no conversation identity.
|
|
223
|
+
*
|
|
224
|
+
* A sessionless request must still reach OpenCode Go with `x-opencode-session`, because the upstream
|
|
225
|
+
* began rejecting requests without it on 2026-09-06. It must not reuse one global value either, which
|
|
226
|
+
* would smear unrelated probes into a single conversation. So the lane is allocated once per admitted
|
|
227
|
+
* `Request` object and retained for that object's lifetime.
|
|
228
|
+
*
|
|
229
|
+
* The identity has to survive every place the proxy rebuilds a `Request`: translation to the internal
|
|
230
|
+
* Responses shape, compaction, and — the boundary that matters most — the policy fallback retry, where
|
|
231
|
+
* a second candidate would otherwise be handed a freshly minted lane after a retryable failure.
|
|
232
|
+
* `linkRequestSessionLane` carries the allocation across those boundaries.
|
|
233
|
+
*/
|
|
234
|
+
const requestAllocatedSessionLanes = new WeakMap<Request, string>();
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the session lane for a request: real conversation identity when the client supplied it,
|
|
238
|
+
* otherwise a per-request value allocated once and reused for retries on the same object.
|
|
239
|
+
*/
|
|
240
|
+
export function getOrAllocateRequestSessionLane(req: Request): string {
|
|
241
|
+
const explicit = sessionLaneIdFromRequest(req.headers)
|
|
242
|
+
?? normalizeLogConversationId(req.headers.get("x-opencode-session"));
|
|
243
|
+
if (explicit) return explicit;
|
|
244
|
+
|
|
245
|
+
const existing = requestAllocatedSessionLanes.get(req);
|
|
246
|
+
if (existing) return existing;
|
|
247
|
+
const allocated = randomUUID();
|
|
248
|
+
requestAllocatedSessionLanes.set(req, allocated);
|
|
249
|
+
return allocated;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Carry a source request's lane onto a request the proxy built from it, so a rebuilt request keeps
|
|
254
|
+
* the conversation it belongs to instead of looking sessionless again.
|
|
255
|
+
*/
|
|
256
|
+
export function linkRequestSessionLane(sourceReq: Request, targetReq: Request): void {
|
|
257
|
+
requestAllocatedSessionLanes.set(targetReq, getOrAllocateRequestSessionLane(sourceReq));
|
|
258
|
+
}
|
|
259
|
+
|
|
@@ -1119,6 +1119,16 @@ export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchPara
|
|
|
1119
1119
|
filtered = filtered.filter(entry => entry.model === model
|
|
1120
1120
|
|| entry.attempts?.some(attempt => attempt.model === model));
|
|
1121
1121
|
}
|
|
1122
|
+
// #4057: "which account served this request" is the first question asked when one provider
|
|
1123
|
+
// holds several accounts, and until now the only way to answer it was to grep usage.jsonl by
|
|
1124
|
+
// hand. Attempts are matched for the same reason `provider` and `model` match them: when a
|
|
1125
|
+
// request failed over between pool accounts, a search for the account that finally served it
|
|
1126
|
+
// has to find that request, not only the account that first refused it.
|
|
1127
|
+
const account = params.get("account")?.trim();
|
|
1128
|
+
if (account) {
|
|
1129
|
+
filtered = filtered.filter(entry => entry.accountLogLabel === account
|
|
1130
|
+
|| entry.attempts?.some(attempt => attempt.accountLogLabel === account));
|
|
1131
|
+
}
|
|
1122
1132
|
const status = params.get("status")?.trim().toLowerCase();
|
|
1123
1133
|
if (status) {
|
|
1124
1134
|
filtered = /^[1-5]xx$/.test(status)
|
|
@@ -29,10 +29,27 @@ export function nativeMainRefreshFailureResponse(error: unknown): Response {
|
|
|
29
29
|
if (error instanceof MainAccountTokenRefreshError
|
|
30
30
|
|| error instanceof MainAuthJsonChangedDuringRefreshError
|
|
31
31
|
|| (error instanceof NativeProfileError && error.retryable)) {
|
|
32
|
+
// A bare "retry this request" reads as a transient server fault, which is how #4212's reporter
|
|
33
|
+
// concluded the proxy had broken while one account was the thing that needed them. The refusal
|
|
34
|
+
// stays a retryable 503 because the refresh genuinely may succeed, but it now names what is
|
|
35
|
+
// failing and what to do when retrying stops helping.
|
|
36
|
+
//
|
|
37
|
+
// It says "sign in to the main Codex account again" and deliberately does NOT say
|
|
38
|
+
// "reauthentication", for the same reason the pool counterpart does not — see
|
|
39
|
+
// `poolCredentialRefreshIncompleteResponse` in ./core.ts. `classifyError` runs
|
|
40
|
+
// `isAuthenticationMessage` before it reaches the `status === 503` arm, and that check is
|
|
41
|
+
// status-blind on the bare substring "authentication", which "reauthentication" contains.
|
|
42
|
+
// A body carrying that word is reclassified to `authentication_error` / `invalid_api_key`
|
|
43
|
+
// even though the HTTP status stays 503, and Codex keys its retry-after backoff on
|
|
44
|
+
// `server_is_overloaded` — so the word alone turns a transient refresh into what reads as a
|
|
45
|
+
// bad API key and the client stops retrying. The pool path documented this trap and this one
|
|
46
|
+
// walked into it anyway, which is why the test below now asserts the classification and not
|
|
47
|
+
// just the sentence.
|
|
32
48
|
const response = formatErrorResponse(
|
|
33
49
|
503,
|
|
34
50
|
"server_busy",
|
|
35
|
-
"Codex main credential refresh did not complete; retry this request"
|
|
51
|
+
"Codex main credential refresh did not complete; retry this request. "
|
|
52
|
+
+ "If it keeps failing, sign in to the main Codex account again.",
|
|
36
53
|
);
|
|
37
54
|
const headers = new Headers(response.headers);
|
|
38
55
|
headers.set("Retry-After", "1");
|
|
@@ -5,7 +5,8 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w
|
|
|
5
5
|
import { CodexWsCorrelation } from "./codex-ws-correlation";
|
|
6
6
|
import type { CodexWsSession } from "./codex-ws-session";
|
|
7
7
|
import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
|
|
8
|
-
MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage
|
|
8
|
+
MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
|
|
9
|
+
codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire";
|
|
9
10
|
|
|
10
11
|
interface ExchangeOptions {
|
|
11
12
|
session: CodexWsSession;
|
|
@@ -94,6 +95,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
94
95
|
let received = false;
|
|
95
96
|
let responseCommitted = false;
|
|
96
97
|
let terminal = false;
|
|
98
|
+
// #4191: the counters behind the failure classification. A user whose long
|
|
99
|
+
// thread died here could not tell an unanswered socket from one that carried
|
|
100
|
+
// only quota frames, because both arrived as the same one-line message.
|
|
101
|
+
let upstreamFrames = 0;
|
|
102
|
+
let controlFrames = 0;
|
|
103
|
+
let relayedEvents = 0;
|
|
104
|
+
let sentAt: number | null = null;
|
|
105
|
+
let firstFrameAt: number | null = null;
|
|
97
106
|
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
98
107
|
const encoder = new TextEncoder();
|
|
99
108
|
const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
|
|
@@ -123,6 +132,21 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
123
132
|
ws.removeEventListener("error", onError);
|
|
124
133
|
};
|
|
125
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Snapshot the stage for a failure message. Measuring the frame is deferred
|
|
137
|
+
* to here so the happy path never pays for it: a full-replay thread's frame
|
|
138
|
+
* runs to megabytes, and this is the only place its size is worth knowing.
|
|
139
|
+
*/
|
|
140
|
+
const failureStage = (): CodexWsFailureStage => ({
|
|
141
|
+
requestBytes: Buffer.byteLength(frameText, "utf8"),
|
|
142
|
+
sent,
|
|
143
|
+
upstreamFrames,
|
|
144
|
+
controlFrames,
|
|
145
|
+
relayedEvents,
|
|
146
|
+
firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
|
|
147
|
+
elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
|
|
148
|
+
});
|
|
149
|
+
|
|
126
150
|
const commitResponse = () => {
|
|
127
151
|
if (responseCommitted) return;
|
|
128
152
|
responseCommitted = true;
|
|
@@ -192,6 +216,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
192
216
|
sent = true;
|
|
193
217
|
try {
|
|
194
218
|
ws.send(frameText);
|
|
219
|
+
sentAt = Date.now();
|
|
195
220
|
} catch {
|
|
196
221
|
if (received || responseCommitted) {
|
|
197
222
|
if (terminal) session.dispose();
|
|
@@ -211,13 +236,18 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
211
236
|
}
|
|
212
237
|
if (!metadata) commitResponse();
|
|
213
238
|
else if (!responseCommitted && !terminal) {
|
|
214
|
-
preludeTimer = setTimeout(
|
|
239
|
+
preludeTimer = setTimeout(
|
|
240
|
+
() => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`),
|
|
241
|
+
CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
|
|
242
|
+
);
|
|
215
243
|
}
|
|
216
244
|
};
|
|
217
245
|
|
|
218
246
|
const onMessage = (event: MessageEvent) => {
|
|
219
247
|
if (!controller || terminal) return;
|
|
220
248
|
received = true;
|
|
249
|
+
upstreamFrames += 1;
|
|
250
|
+
if (firstFrameAt === null) firstFrameAt = Date.now();
|
|
221
251
|
const text = typeof event.data === "string" ? event.data : "";
|
|
222
252
|
if (!text) return;
|
|
223
253
|
// UTF-8 byte length is always at least the JS string length. Reject this
|
|
@@ -243,6 +273,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
243
273
|
if (sanitized !== null) {
|
|
244
274
|
relayText = sanitized;
|
|
245
275
|
controlFrame = true;
|
|
276
|
+
controlFrames += 1;
|
|
246
277
|
}
|
|
247
278
|
} catch (error) {
|
|
248
279
|
failStream(error);
|
|
@@ -296,6 +327,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
296
327
|
failStream("codex websocket response stream closed while enqueueing");
|
|
297
328
|
return;
|
|
298
329
|
}
|
|
330
|
+
if (!controlFrame) relayedEvents += 1;
|
|
299
331
|
if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
|
|
300
332
|
const completedId = correlation?.completed(normalized.payload) ?? null;
|
|
301
333
|
terminal = true;
|
|
@@ -316,7 +348,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
316
348
|
resolve(sseFallback(url, init));
|
|
317
349
|
return;
|
|
318
350
|
}
|
|
319
|
-
if (sent && !terminal) failStream(closedBeforeTerminalMessage(event));
|
|
351
|
+
if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage()));
|
|
320
352
|
};
|
|
321
353
|
|
|
322
354
|
const onError = () => {
|
|
@@ -327,7 +359,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
327
359
|
cleanup();
|
|
328
360
|
session.dispose();
|
|
329
361
|
resolve(sseFallback(url, init));
|
|
330
|
-
} else failStream(
|
|
362
|
+
} else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`);
|
|
331
363
|
};
|
|
332
364
|
detachOwner = session.bindOwner(reason => cancelExchange(reason));
|
|
333
365
|
ws.addEventListener("open", onOpen);
|
|
@@ -2,7 +2,7 @@ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
|
2
2
|
// If the 101 never arrives (network black hole), give SSE a chance well before
|
|
3
3
|
// the caller's connect timeout (default 200s) would fire.
|
|
4
4
|
export const UPGRADE_DEADLINE_MS = 10_000;
|
|
5
|
-
export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS =
|
|
5
|
+
export const CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS = 90_000;
|
|
6
6
|
// Keep the push-based WS transport inside the same memory envelope as the
|
|
7
7
|
// bounded SSE relays that consume this response. Unlike fetch response bodies,
|
|
8
8
|
// a WebSocket cannot be paused when a ReadableStream applies backpressure, so
|
|
@@ -50,6 +50,72 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
|
|
|
50
50
|
|
|
51
51
|
const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Content-free stage record for an exchange that ended without a Responses
|
|
55
|
+
* terminal event (#4191).
|
|
56
|
+
*
|
|
57
|
+
* The field report that drove this could not be told apart from a network
|
|
58
|
+
* outage, because every such failure reached the user as one of two bare
|
|
59
|
+
* sentences. Both are true of a socket that was never answered, a socket that
|
|
60
|
+
* carried only quota control frames, and a socket that died mid-response —
|
|
61
|
+
* three different upstream stories with three different owners. These counters
|
|
62
|
+
* are the smallest set that separates them, and every one of them is a size, a
|
|
63
|
+
* count, or a duration: no request body, no header, no account identifier, and
|
|
64
|
+
* no conversation text can reach a message built from this record.
|
|
65
|
+
*/
|
|
66
|
+
export type CodexWsFailureStage = {
|
|
67
|
+
/** UTF-8 size of the `response.create` frame this exchange dialled with. */
|
|
68
|
+
requestBytes: number;
|
|
69
|
+
/** True once `ws.send()` returned, so the turn may be executing upstream. */
|
|
70
|
+
sent: boolean;
|
|
71
|
+
/** Frames the socket delivered, of any kind, including ones that did not parse. */
|
|
72
|
+
upstreamFrames: number;
|
|
73
|
+
/** Frames the metadata channel claimed (quota, response metadata). */
|
|
74
|
+
controlFrames: number;
|
|
75
|
+
/** Responses events actually written to the downstream SSE body. */
|
|
76
|
+
relayedEvents: number;
|
|
77
|
+
/** Milliseconds from send to the first upstream frame; null when none arrived. */
|
|
78
|
+
firstFrameMs: number | null;
|
|
79
|
+
/** Milliseconds from send to this failure; null when the failure predates the send. */
|
|
80
|
+
elapsedMs: number | null;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Which upstream story the counters tell. Ordered by how much the upstream had
|
|
85
|
+
* committed to, because that is what decides who owns the failure — and, for a
|
|
86
|
+
* future maintainer reading #4191, it is deliberately NOT a fallback-eligibility
|
|
87
|
+
* signal. `no-upstream-frame` does not mean the frame was not accepted; the
|
|
88
|
+
* no-replay-after-send contract in `codex-ws-exchange.ts` stands regardless of
|
|
89
|
+
* what this classifier says.
|
|
90
|
+
*/
|
|
91
|
+
export type CodexWsFailureCause =
|
|
92
|
+
| "before-send"
|
|
93
|
+
| "no-upstream-frame"
|
|
94
|
+
| "no-response-event"
|
|
95
|
+
| "after-response-started";
|
|
96
|
+
|
|
97
|
+
export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailureCause {
|
|
98
|
+
if (!stage.sent) return "before-send";
|
|
99
|
+
if (stage.relayedEvents > 0) return "after-response-started";
|
|
100
|
+
if (stage.upstreamFrames === 0) return "no-upstream-frame";
|
|
101
|
+
return "no-response-event";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Render the stage as a suffix appended to an existing failure message.
|
|
106
|
+
*
|
|
107
|
+
* It is a suffix, not an interpolation, on purpose: the close-code tail these
|
|
108
|
+
* messages already carry is matched as a contiguous substring by the callers
|
|
109
|
+
* and tests that read it, so nothing may be inserted ahead of it.
|
|
110
|
+
*/
|
|
111
|
+
export function codexWsFailureDetail(stage: CodexWsFailureStage): string {
|
|
112
|
+
const duration = (value: number | null): string => (value === null ? "n/a" : `${value}ms`);
|
|
113
|
+
return ` [cause=${classifyCodexWsFailure(stage)} request=${stage.requestBytes}B`
|
|
114
|
+
+ ` sent=${stage.sent ? "yes" : "no"} frames=${stage.upstreamFrames}`
|
|
115
|
+
+ ` control=${stage.controlFrames} relayed=${stage.relayedEvents}`
|
|
116
|
+
+ ` first-frame=${duration(stage.firstFrameMs)} elapsed=${duration(stage.elapsedMs)}]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
53
119
|
export type ResponsesWsRelayEvent = {
|
|
54
120
|
type: string;
|
|
55
121
|
text: string;
|
|
@@ -111,18 +177,23 @@ export function normalizeResponsesWsRelayEvent(text: string): ResponsesWsRelayEv
|
|
|
111
177
|
* inspector, so `/api/logs` keeps neither this message nor a specific code —
|
|
112
178
|
* only `streamAborted`. Machine-readable typing would mean changing the error
|
|
113
179
|
* taxonomy, which is deliberately out of scope for this transport fix.
|
|
180
|
+
*
|
|
181
|
+
* When a stage is supplied its detail is appended last, after the close-code
|
|
182
|
+
* tail, so the code and reason stay one contiguous substring.
|
|
114
183
|
*/
|
|
115
|
-
export function closedBeforeTerminalMessage(event: unknown): string {
|
|
184
|
+
export function closedBeforeTerminalMessage(event: unknown, stage?: CodexWsFailureStage): string {
|
|
116
185
|
const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
|
|
117
186
|
const code = typeof detail?.code === "number" ? detail.code : null;
|
|
118
187
|
const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
|
|
119
|
-
|
|
188
|
+
const stageDetail = stage ? codexWsFailureDetail(stage) : "";
|
|
189
|
+
if (code === null) return `${CLOSED_BEFORE_TERMINAL}${stageDetail}`;
|
|
120
190
|
const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
|
|
121
191
|
if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
|
|
122
192
|
return `codex websocket rejected the request frame as too large (close${suffix});`
|
|
123
|
-
+ ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport
|
|
193
|
+
+ ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`
|
|
194
|
+
+ stageDetail;
|
|
124
195
|
}
|
|
125
|
-
return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
|
|
196
|
+
return `${CLOSED_BEFORE_TERMINAL} (close${suffix})${stageDetail}`;
|
|
126
197
|
}
|
|
127
198
|
|
|
128
199
|
/**
|
|
@@ -104,7 +104,12 @@ import { fastPolicyForModel } from "../../providers/service-tier";
|
|
|
104
104
|
import { parseFastOnlyRowId } from "../fast-row";
|
|
105
105
|
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
106
106
|
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
107
|
-
import {
|
|
107
|
+
import {
|
|
108
|
+
readJsonRequestBody,
|
|
109
|
+
resolveInboundBodyLimitBytes,
|
|
110
|
+
DecompressedBodyTooLargeError,
|
|
111
|
+
UnsupportedContentEncodingError,
|
|
112
|
+
} from "../request-decompress";
|
|
108
113
|
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
109
114
|
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
|
|
110
115
|
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
@@ -147,12 +152,13 @@ import {
|
|
|
147
152
|
decodeRequestErrorResponse,
|
|
148
153
|
handleResponses,
|
|
149
154
|
preAuthUpstreamHostCircuitKey,
|
|
155
|
+
poolCredentialRefreshIncompleteResponse,
|
|
150
156
|
upstreamHostCircuitOpenResponse,
|
|
151
157
|
usesCodexForwardPoolAuth,
|
|
152
158
|
} from "./core";
|
|
153
159
|
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
|
|
154
160
|
import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
|
|
155
|
-
import { sessionLaneIdFromRequest } from "../request-log-conversation";
|
|
161
|
+
import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
|
|
156
162
|
import { recallComboForLane } from "./combo-session-recall";
|
|
157
163
|
|
|
158
164
|
export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
|
|
@@ -312,6 +318,13 @@ async function refreshPoolCompactContext(args: {
|
|
|
312
318
|
authCtx: CodexAuthContext & { kind: "pool" };
|
|
313
319
|
provider: OcxProviderConfig;
|
|
314
320
|
codexAccountMode?: CodexAccountMode;
|
|
321
|
+
/**
|
|
322
|
+
* Public selector for the account this refresh is for, when the request carried one. The
|
|
323
|
+
* caller has it and this function does not, because compact takes no `RouteResult` — which
|
|
324
|
+
* is the whole reason the refusal here used to be less specific than the one core returns
|
|
325
|
+
* for the identical failure.
|
|
326
|
+
*/
|
|
327
|
+
codexAccountNamespace?: string;
|
|
315
328
|
substituteMainCredential: boolean;
|
|
316
329
|
options: HandleResponsesCompactOptions;
|
|
317
330
|
}): Promise<
|
|
@@ -372,14 +385,15 @@ async function refreshPoolCompactContext(args: {
|
|
|
372
385
|
if (isTerminalCompactPoolRefreshFailure(error)) {
|
|
373
386
|
return { ok: false, quarantine: true, response: reauthResponse() };
|
|
374
387
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
quarantine: false,
|
|
391
|
+
response: poolCredentialRefreshIncompleteResponse({
|
|
392
|
+
authCtx,
|
|
393
|
+
config,
|
|
394
|
+
accountSelector: args.codexAccountNamespace,
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
383
397
|
}
|
|
384
398
|
}
|
|
385
399
|
|
|
@@ -522,7 +536,7 @@ export async function handleResponsesCompact(
|
|
|
522
536
|
): Promise<Response> {
|
|
523
537
|
let body: unknown;
|
|
524
538
|
try {
|
|
525
|
-
body = await readJsonRequestBody(req);
|
|
539
|
+
body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
|
|
526
540
|
} catch (err) {
|
|
527
541
|
return decodeRequestErrorResponse(err, "responses-compact");
|
|
528
542
|
}
|
|
@@ -912,6 +926,7 @@ export async function handleResponsesCompact(
|
|
|
912
926
|
authCtx: poolAuthCtx,
|
|
913
927
|
provider: compactProvider,
|
|
914
928
|
codexAccountMode: route.codexAccountMode,
|
|
929
|
+
codexAccountNamespace: route.codexAccountNamespace,
|
|
915
930
|
substituteMainCredential,
|
|
916
931
|
options,
|
|
917
932
|
})
|
|
@@ -1015,6 +1030,7 @@ export async function handleResponsesCompact(
|
|
|
1015
1030
|
upstream.headers,
|
|
1016
1031
|
authCtx.writerGeneration,
|
|
1017
1032
|
authCtx.kind === "main-pool" ? authCtx.mainQuotaWriter : undefined,
|
|
1033
|
+
{ modelId: route.modelId },
|
|
1018
1034
|
);
|
|
1019
1035
|
}
|
|
1020
1036
|
recordCompactPoolOutcome(authCtx, upstream.status, {
|
|
@@ -1143,6 +1159,7 @@ export async function handleResponsesCompact(
|
|
|
1143
1159
|
headers: internalHeaders,
|
|
1144
1160
|
body: JSON.stringify(internalBody),
|
|
1145
1161
|
});
|
|
1162
|
+
linkRequestSessionLane(req, internalReq);
|
|
1146
1163
|
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
|
|
1147
1164
|
if (!response.ok) return response;
|
|
1148
1165
|
let json: { output?: unknown[]; status?: unknown; error?: unknown };
|
|
@@ -5,6 +5,17 @@ import type { AdapterEvent } from "../../types";
|
|
|
5
5
|
export const PROVIDER_INPUT_TOO_LARGE_MESSAGE =
|
|
6
6
|
"The provider rejected this turn because its input exceeds the provider size or context limit. Reduce the current input or compact the conversation before retrying.";
|
|
7
7
|
|
|
8
|
+
/** Preserve non-streaming HTTP failure semantics without exposing an upstream body. */
|
|
9
|
+
export function jsonContextOverflowResponse(): Response {
|
|
10
|
+
return Response.json({
|
|
11
|
+
error: {
|
|
12
|
+
message: PROVIDER_INPUT_TOO_LARGE_MESSAGE,
|
|
13
|
+
type: "invalid_request_error",
|
|
14
|
+
code: "context_length_exceeded",
|
|
15
|
+
},
|
|
16
|
+
}, { status: 413, headers: { "Cache-Control": "no-store" } });
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
async function* contextOverflowEvents(): AsyncGenerator<AdapterEvent> {
|
|
9
20
|
yield {
|
|
10
21
|
type: "error",
|