@bitkyc08/opencodex 2.50.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/bin/ocx.mjs +222 -71
- package/gui/dist/assets/{index-C39tnjXO.js → index-D7BdZpZm.js} +1 -1
- 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/cli/capabilities.ts +28 -0
- 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 +94 -30
- package/src/cli/launcher-context.ts +1 -1
- 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/codex/account-usability.ts +48 -12
- package/src/codex/auth-api.ts +49 -5
- package/src/codex/catalog/effort.ts +67 -8
- package/src/codex/catalog/sync.ts +85 -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 +26 -10
- package/src/codex/loopback-target.ts +45 -0
- 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/config/pending-teardown.ts +1 -1
- package/src/config.ts +126 -12
- package/src/generated/compatibility-version.json +136 -76
- 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/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/process-control.ts +130 -20
- package/src/lib/service-secrets.ts +28 -0
- package/src/lib/test-home-guard.ts +49 -0
- package/src/providers/opencode-go-transport.ts +9 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry.ts +34 -5
- package/src/remote/hub-state.ts +182 -0
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -3
- package/src/server/claude-messages.ts +7 -1
- package/src/server/hub-state.ts +98 -0
- package/src/server/index.ts +124 -6
- 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/proxy-liveness.ts +7 -1
- package/src/server/request-log-conversation.ts +41 -1
- 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 +75 -4
- package/src/server/responses/compact.ts +20 -9
- package/src/server/responses/core.ts +57 -10
- package/src/server/responses/policy-fallback.ts +7 -1
- package/src/server/system-env-shell.ts +14 -2
- package/src/server/system-env.ts +106 -14
- package/src/service.ts +906 -94
- package/src/types/config.ts +57 -4
- 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
|
@@ -110,7 +110,7 @@ import { applySystemEnvToggle } from "../system-env";
|
|
|
110
110
|
import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
|
|
111
111
|
import { runWindowsTrayAction } from "../windows-tray-control";
|
|
112
112
|
import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control";
|
|
113
|
-
import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
|
|
113
|
+
import { displayCodexRuntimePath, effortClampAppliesToRuntime, liveRemovedEfforts, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
|
|
114
114
|
|
|
115
115
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
116
116
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
@@ -344,7 +344,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
344
344
|
: null,
|
|
345
345
|
catalogClamp: {
|
|
346
346
|
active: clampActive,
|
|
347
|
-
removedEfforts: clampActive ? (lastClamp
|
|
347
|
+
removedEfforts: clampActive ? [...liveRemovedEfforts(lastClamp)] : [],
|
|
348
348
|
runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
|
|
349
349
|
},
|
|
350
350
|
warning: warningParts.length > 0 ? warningParts.join(" ") : null,
|
|
@@ -14,6 +14,7 @@ import { cursorLastSeen, type CursorSeen } from "../../integrations/cursor-seen"
|
|
|
14
14
|
import { detectCursorInstalls, type CursorInstall } from "../../integrations/cursor-detect";
|
|
15
15
|
import { loadCursorEffortTable } from "../../integrations/cursor-effort-table";
|
|
16
16
|
import { configuredApiAuthToken, isApiAuthRequired, jsonResponse } from "../auth-cors";
|
|
17
|
+
import { localInferenceDestination } from "../../lib/local-destinations";
|
|
17
18
|
import { fetchAllModels } from "../management-api";
|
|
18
19
|
import { predictCursorEffort } from "../models-capabilities";
|
|
19
20
|
import { expandCursorEffortRow, knownEffortRowIds } from "../effort-row";
|
|
@@ -54,11 +55,19 @@ export async function buildCursorIntegrationStatus(
|
|
|
54
55
|
// The port the browser reached is the one Cursor on the same machine will reach too; the
|
|
55
56
|
// runtime record and config.port are fallbacks for a request that carries no port.
|
|
56
57
|
const port = runtime?.port ?? (Number(ctx.url?.port) || config.port);
|
|
57
|
-
//
|
|
58
|
-
//
|
|
58
|
+
// Cursor runs on this machine, so the gateway URL it is told to paste is the LOCAL one: the
|
|
59
|
+
// unauthenticated loopback listener when one is enabled, and otherwise the bind address on the
|
|
60
|
+
// public port — 127.0.0.1 for a loopback or wildcard bind exactly as before, and the tailnet
|
|
61
|
+
// or LAN address on a hub, where no loopback socket exists to paste (#4236).
|
|
62
|
+
const gateway = localInferenceDestination(config, port ?? 10100);
|
|
63
|
+
// apiKeyMode describes the admission rule of the destination just resolved, which on the
|
|
64
|
+
// loopback listener is "no key needed" and on every other form is "a key is required".
|
|
65
|
+
// Pasting one into the listener is harmless; omitting one on a bind that demands it is not.
|
|
59
66
|
const credentialConfigured = !!configuredApiAuthToken(config)
|
|
60
67
|
|| (config.apiKeys ?? []).some(entry => !!entry.key.trim());
|
|
61
|
-
const apiKeyMode = isApiAuthRequired(config) || credentialConfigured
|
|
68
|
+
const apiKeyMode = gateway.requiresAdmissionToken || isApiAuthRequired(config) || credentialConfigured
|
|
69
|
+
? "credential"
|
|
70
|
+
: "placeholder";
|
|
62
71
|
|
|
63
72
|
const limits = nativeContextLimits(config);
|
|
64
73
|
// Same visibility rules as the raw /v1/models list Cursor will read: disabled models and
|
|
@@ -107,7 +116,7 @@ export async function buildCursorIntegrationStatus(
|
|
|
107
116
|
},
|
|
108
117
|
regularCursor: { installed: regular !== undefined, path: regular?.path ?? null },
|
|
109
118
|
gateway: {
|
|
110
|
-
baseUrl:
|
|
119
|
+
baseUrl: `${gateway.origin}/v1`,
|
|
111
120
|
apiKeyMode,
|
|
112
121
|
placeholder: CURSOR_GATEWAY_PLACEHOLDER_KEY,
|
|
113
122
|
},
|
|
@@ -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
|
}
|
|
@@ -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
|
+
|
|
@@ -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);
|
|
@@ -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
|
/**
|
|
@@ -152,12 +152,13 @@ import {
|
|
|
152
152
|
decodeRequestErrorResponse,
|
|
153
153
|
handleResponses,
|
|
154
154
|
preAuthUpstreamHostCircuitKey,
|
|
155
|
+
poolCredentialRefreshIncompleteResponse,
|
|
155
156
|
upstreamHostCircuitOpenResponse,
|
|
156
157
|
usesCodexForwardPoolAuth,
|
|
157
158
|
} from "./core";
|
|
158
159
|
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
|
|
159
160
|
import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error";
|
|
160
|
-
import { sessionLaneIdFromRequest } from "../request-log-conversation";
|
|
161
|
+
import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
|
|
161
162
|
import { recallComboForLane } from "./combo-session-recall";
|
|
162
163
|
|
|
163
164
|
export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
|
|
@@ -317,6 +318,13 @@ async function refreshPoolCompactContext(args: {
|
|
|
317
318
|
authCtx: CodexAuthContext & { kind: "pool" };
|
|
318
319
|
provider: OcxProviderConfig;
|
|
319
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;
|
|
320
328
|
substituteMainCredential: boolean;
|
|
321
329
|
options: HandleResponsesCompactOptions;
|
|
322
330
|
}): Promise<
|
|
@@ -377,14 +385,15 @@ async function refreshPoolCompactContext(args: {
|
|
|
377
385
|
if (isTerminalCompactPoolRefreshFailure(error)) {
|
|
378
386
|
return { ok: false, quarantine: true, response: reauthResponse() };
|
|
379
387
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
+
return {
|
|
389
|
+
ok: false,
|
|
390
|
+
quarantine: false,
|
|
391
|
+
response: poolCredentialRefreshIncompleteResponse({
|
|
392
|
+
authCtx,
|
|
393
|
+
config,
|
|
394
|
+
accountSelector: args.codexAccountNamespace,
|
|
395
|
+
}),
|
|
396
|
+
};
|
|
388
397
|
}
|
|
389
398
|
}
|
|
390
399
|
|
|
@@ -917,6 +926,7 @@ export async function handleResponsesCompact(
|
|
|
917
926
|
authCtx: poolAuthCtx,
|
|
918
927
|
provider: compactProvider,
|
|
919
928
|
codexAccountMode: route.codexAccountMode,
|
|
929
|
+
codexAccountNamespace: route.codexAccountNamespace,
|
|
920
930
|
substituteMainCredential,
|
|
921
931
|
options,
|
|
922
932
|
})
|
|
@@ -1149,6 +1159,7 @@ export async function handleResponsesCompact(
|
|
|
1149
1159
|
headers: internalHeaders,
|
|
1150
1160
|
body: JSON.stringify(internalBody),
|
|
1151
1161
|
});
|
|
1162
|
+
linkRequestSessionLane(req, internalReq);
|
|
1152
1163
|
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
|
|
1153
1164
|
if (!response.ok) return response;
|
|
1154
1165
|
let json: { output?: unknown[]; status?: unknown; error?: unknown };
|
|
@@ -320,6 +320,8 @@ import {
|
|
|
320
320
|
} from "../request-log";
|
|
321
321
|
import {
|
|
322
322
|
conversationIdFromResponsesRequest,
|
|
323
|
+
getOrAllocateRequestSessionLane,
|
|
324
|
+
linkRequestSessionLane,
|
|
323
325
|
normalizeLogConversationId,
|
|
324
326
|
reasoningReplayConversationIdFromResponsesRequest,
|
|
325
327
|
sessionLaneIdFromRequest,
|
|
@@ -2260,6 +2262,50 @@ function isTerminalPoolRefreshFailure(error: unknown): boolean {
|
|
|
2260
2262
|
return error instanceof TokenRefreshError && (error.reason === "revoked" || error.reason === "expired");
|
|
2261
2263
|
}
|
|
2262
2264
|
|
|
2265
|
+
/**
|
|
2266
|
+
* The refusal an operator meets when a stored pool credential's forced refresh does not complete.
|
|
2267
|
+
*
|
|
2268
|
+
* A bare "retry this request" reads as a transient fault in the proxy, which is how #4212's
|
|
2269
|
+
* reporter spent an afternoon concluding OpenCodex had broken while one of their own accounts was
|
|
2270
|
+
* the thing that needed them. It stays a retryable 503 and stays non-quarantining, because the
|
|
2271
|
+
* refresh genuinely may succeed and a token-endpoint 5xx must not retire a healthy account
|
|
2272
|
+
* (#2887). What it adds is the account and the exit: when retrying stops helping, that account
|
|
2273
|
+
* has to be signed in again.
|
|
2274
|
+
*
|
|
2275
|
+
* The label is a public account selector when the request carried one, otherwise the durable
|
|
2276
|
+
* `p`-prefixed log label — never the raw pool id and never the email. Those are the identifiers
|
|
2277
|
+
* `responses-compaction-routing.test.ts` and `codex-auth-context.test.ts` already assert must not
|
|
2278
|
+
* reach an operator-facing surface, and an error body travels further than a log line, not less.
|
|
2279
|
+
* When neither is resolvable the sentence degrades to "the selected Codex pool account" rather
|
|
2280
|
+
* than naming something opaque, because a wrong name is worse than no name.
|
|
2281
|
+
*
|
|
2282
|
+
* The wording says "sign in to that account again" and deliberately does NOT say
|
|
2283
|
+
* "reauthentication". `classifyError` runs `isAuthenticationMessage` before it reaches the
|
|
2284
|
+
* `status === 503` arm, and that check is status-blind on the bare substring "authentication",
|
|
2285
|
+
* which "reauthentication" contains. A body carrying that word is reclassified to
|
|
2286
|
+
* `authentication_error` / `invalid_api_key` even though the HTTP status stays 503 — and Codex
|
|
2287
|
+
* applies retry-after backoff only for `server_is_overloaded`, so the friendlier sentence would
|
|
2288
|
+
* have quietly disabled the retry this refusal exists to ask for. `options.code` cannot buy the
|
|
2289
|
+
* classification back; only the wording can.
|
|
2290
|
+
*/
|
|
2291
|
+
export function poolCredentialRefreshIncompleteResponse(args: {
|
|
2292
|
+
authCtx: CodexAuthContext;
|
|
2293
|
+
config: Pick<OcxConfig, "codexAccounts">;
|
|
2294
|
+
accountSelector?: string;
|
|
2295
|
+
}): Response {
|
|
2296
|
+
const label = args.accountSelector ?? codexAuthContextLogLabel(args.authCtx, args.config);
|
|
2297
|
+
const account = label ? `Codex pool account ${label}` : "the selected Codex pool account";
|
|
2298
|
+
const response = formatErrorResponse(
|
|
2299
|
+
503,
|
|
2300
|
+
"server_busy",
|
|
2301
|
+
`Codex credential refresh did not complete for ${account}; retry this request. `
|
|
2302
|
+
+ "If it keeps failing, sign in to that account again.",
|
|
2303
|
+
);
|
|
2304
|
+
const headers = new Headers(response.headers);
|
|
2305
|
+
headers.set("Retry-After", "1");
|
|
2306
|
+
return new Response(response.body, { status: response.status, headers });
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2263
2309
|
/**
|
|
2264
2310
|
* One forced refresh and one same-account rebuild for a stored pool credential that
|
|
2265
2311
|
* upstream rejected with a pre-stream 401. `quarantine` distinguishes a dead grant,
|
|
@@ -2330,14 +2376,15 @@ async function refreshPoolForwardAuth(args: {
|
|
|
2330
2376
|
response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
|
|
2331
2377
|
};
|
|
2332
2378
|
}
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2379
|
+
return {
|
|
2380
|
+
ok: false,
|
|
2381
|
+
quarantine: false,
|
|
2382
|
+
response: poolCredentialRefreshIncompleteResponse({
|
|
2383
|
+
authCtx,
|
|
2384
|
+
config,
|
|
2385
|
+
accountSelector: route.codexAccountNamespace,
|
|
2386
|
+
}),
|
|
2387
|
+
};
|
|
2341
2388
|
}
|
|
2342
2389
|
}
|
|
2343
2390
|
|
|
@@ -2451,8 +2498,7 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
2451
2498
|
|
|
2452
2499
|
// Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter
|
|
2453
2500
|
// this request will actually use (#404).
|
|
2454
|
-
route.provider = resolveOpenCodeGoTransport(route.provider,
|
|
2455
|
-
sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session")));
|
|
2501
|
+
route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
|
|
2456
2502
|
route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
2457
2503
|
if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId;
|
|
2458
2504
|
logCtx.model = route.modelId;
|
|
@@ -2816,6 +2862,7 @@ export async function handleComboResponses(
|
|
|
2816
2862
|
headers: childHeaders,
|
|
2817
2863
|
body: JSON.stringify(childBody),
|
|
2818
2864
|
});
|
|
2865
|
+
linkRequestSessionLane(req, childRequest);
|
|
2819
2866
|
let resolvedAuth: CodexAuthContext | undefined;
|
|
2820
2867
|
let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
|
|
2821
2868
|
const started = Date.now();
|
|
@@ -2,6 +2,7 @@ import { comboFailureDecision } from "../../combos/failover";
|
|
|
2
2
|
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
3
3
|
import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress";
|
|
4
4
|
import { finishRequestAttempt, type RequestLogContext } from "../request-log";
|
|
5
|
+
import { linkRequestSessionLane } from "../request-log-conversation";
|
|
5
6
|
import type { OcxConfig } from "../../types";
|
|
6
7
|
import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
|
|
7
8
|
import { handleResponses as handleResponsesCore } from "./core";
|
|
@@ -56,12 +57,17 @@ function requestWithCandidate(
|
|
|
56
57
|
headers.delete("content-encoding");
|
|
57
58
|
headers.delete("content-length");
|
|
58
59
|
headers.set("content-type", "application/json");
|
|
59
|
-
|
|
60
|
+
const retryRequest = new Request(req.url, {
|
|
60
61
|
method: req.method,
|
|
61
62
|
headers,
|
|
62
63
|
body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }),
|
|
63
64
|
signal: req.signal,
|
|
64
65
|
});
|
|
66
|
+
// A sessionless request keeps the lane it was already allocated. Without this the second
|
|
67
|
+
// candidate reaches OpenCode Go under a different x-opencode-session than the first attempt,
|
|
68
|
+
// which is the same conversation split the header exists to prevent.
|
|
69
|
+
linkRequestSessionLane(req, retryRequest);
|
|
70
|
+
return retryRequest;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
function errorCodeFromText(text: string): string | undefined {
|
|
@@ -7,6 +7,7 @@ import { resolveClaudeAuthMode } from "../claude/auth-mode";
|
|
|
7
7
|
import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
|
|
8
8
|
import type { OcxConfig } from "../types";
|
|
9
9
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
10
|
+
import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Does the opencodex dummy marker belong in the system environment?
|
|
@@ -79,9 +80,14 @@ export function writeShellEnvFile(
|
|
|
79
80
|
auto?: AutoContextMode,
|
|
80
81
|
deps: SystemEnvDeps = {},
|
|
81
82
|
): void {
|
|
83
|
+
// Same local destination the launchd domain gets, resolved through the same resolver rather
|
|
84
|
+
// than re-derived: the unauthenticated loopback listener when one is enabled, otherwise the
|
|
85
|
+
// bind address (#4236). The two files must not disagree, or a new shell and a launchd-started
|
|
86
|
+
// `claude` would dial different sockets.
|
|
87
|
+
const destination = localInferenceDestination(config, port);
|
|
82
88
|
const lines = [
|
|
83
89
|
`# Generated by opencodex — do not edit manually`,
|
|
84
|
-
`export ANTHROPIC_BASE_URL=${shellValue(
|
|
90
|
+
`export ANTHROPIC_BASE_URL=${shellValue(destination.origin)}`,
|
|
85
91
|
`export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
|
|
86
92
|
];
|
|
87
93
|
// New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
|
|
@@ -89,7 +95,13 @@ export function writeShellEnvFile(
|
|
|
89
95
|
const conditional = (name: string, value: string) =>
|
|
90
96
|
`[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
|
|
91
97
|
if (systemEnvMarkerMode(config, deps) === "proxy") {
|
|
92
|
-
|
|
98
|
+
// On a bind that demands data-plane admission the credential may live only in
|
|
99
|
+
// `OPENCODEX_API_AUTH_TOKEN` or the hardened service token file, so the same ladder the
|
|
100
|
+
// launchd injection uses applies here. Never the admin token (reviewer constraint on #4236).
|
|
101
|
+
const hostAdmissionToken = destination.requiresAdmissionToken ? localAdmissionToken(config) : undefined;
|
|
102
|
+
if (hostAdmissionToken) {
|
|
103
|
+
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(hostAdmissionToken)}`);
|
|
104
|
+
} else if (config.apiKeys?.length) {
|
|
93
105
|
lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
|
|
94
106
|
} else {
|
|
95
107
|
lines.push(conditional("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER));
|