@iislee/opencodex 2.31.0 → 2.35.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/README.md +3 -3
- package/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +104 -15
- package/src/adapters/base.ts +29 -1
- package/src/adapters/command-code.ts +42 -6
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +69 -18
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +2 -2
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -2
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +68 -14
- package/src/adapters/cursor/request-builder.ts +40 -10
- package/src/adapters/cursor/tool-definitions.ts +14 -4
- package/src/adapters/cursor/tool-result-normalize.ts +28 -5
- package/src/adapters/cursor/types.ts +20 -0
- package/src/adapters/cursor.ts +209 -38
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +173 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google.ts +136 -24
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-tools.ts +55 -16
- package/src/adapters/kiro.ts +182 -23
- package/src/adapters/openai-chat.ts +300 -242
- package/src/adapters/openai-responses.ts +353 -27
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +4 -3
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +17 -16
- package/src/bridge.ts +90 -37
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/context-windows.ts +16 -9
- package/src/claude/outbound.ts +24 -11
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +116 -19
- package/src/cli/account-extended.ts +262 -29
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +69 -15
- package/src/cli/agent.ts +8 -1
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +256 -28
- package/src/cli/doctor.ts +126 -14
- package/src/cli/help.ts +17 -6
- package/src/cli/index.ts +39 -9
- package/src/cli/init.ts +1 -0
- package/src/cli/inspect.ts +230 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +26 -10
- package/src/cli/observe.ts +31 -8
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +49 -10
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +41 -1
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +34 -10
- package/src/cli/version-skew.ts +46 -0
- package/src/clients/config-export.ts +85 -3
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +83 -7
- package/src/codex/auth-api.ts +50 -10
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +18 -0
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +72 -27
- package/src/codex/catalog/provider-fetch.ts +187 -31
- package/src/codex/catalog/sync.ts +131 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +27 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +63 -22
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +91 -10
- package/src/codex/routing.ts +9 -0
- package/src/codex/shim.ts +56 -3
- package/src/codex/subagent-model-fallback.ts +104 -25
- package/src/codex/transition-state.ts +116 -8
- package/src/combos/failover.ts +27 -0
- package/src/combos/types.ts +10 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +206 -824
- package/src/generated/compatibility-version.json +373 -213
- package/src/images/loop.ts +5 -4
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +46 -2
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/state.ts +79 -7
- package/src/integrations/writer.ts +26 -2
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +36 -3
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/process-control.ts +2 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +65 -6
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/local-token-detect.ts +2 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +14 -5
- package/src/oauth/types.ts +15 -0
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/label.ts +34 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +65 -7
- package/src/providers/registry.ts +184 -69
- package/src/providers/slug-codec.ts +52 -0
- package/src/providers/xai-transport.ts +21 -0
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +157 -39
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/responses/schema.ts +5 -1
- package/src/responses/state.ts +94 -4
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +27 -7
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/index.ts +179 -32
- package/src/server/lifecycle.ts +52 -1
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +45 -23
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +59 -12
- package/src/server/management/context.ts +10 -1
- package/src/server/management/logs-usage-routes.ts +30 -24
- package/src/server/management/model-routes.ts +274 -4
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +10 -4
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/provider-routes.ts +81 -11
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/management-api.ts +18 -16
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +29 -5
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +70 -16
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +37 -3
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +810 -208
- package/src/server/responses/empty-completion-guard.ts +63 -6
- package/src/server/responses/fetch-helpers.ts +17 -102
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-custom-tool-repair.ts +87 -6
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/responses-undeclared-tool-guard.ts +241 -18
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +14 -2
- package/src/service.ts +42 -4
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/types/config.ts +66 -0
- package/src/types/provider.ts +69 -0
- package/src/types/request.ts +17 -4
- package/src/types/tools.ts +116 -11
- package/src/types.ts +3 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +63 -12
- package/src/usage/summary.ts +211 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-BF38heuV.js +0 -104
- package/gui/dist/assets/index-DMiI18Kv.css +0 -1
|
@@ -35,6 +35,41 @@ export function emptyCompletionRetryEnabled(
|
|
|
35
35
|
/** Surfaced when the single retry was also empty or failed upstream. */
|
|
36
36
|
export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Observe an event stream for the empty-completion shape WITHOUT changing it (#2472).
|
|
40
|
+
*
|
|
41
|
+
* The guard above is opt-in, so with the default configuration a turn that completes with no
|
|
42
|
+
* output text and no tool call passes through untouched and the client records a silent
|
|
43
|
+
* success. That is the reported symptom: an empty result nobody can explain, with no trace
|
|
44
|
+
* that the proxy saw anything unusual.
|
|
45
|
+
*
|
|
46
|
+
* This is deliberately a passthrough observer, not a second guard. Retrying by default would
|
|
47
|
+
* re-send a turn that may have already had billable side effects; the honest default is to
|
|
48
|
+
* leave the stream alone and make the occurrence visible, so a user can correlate it and
|
|
49
|
+
* decide whether to enable the retry.
|
|
50
|
+
*/
|
|
51
|
+
export async function* observeEmptyCompletion(
|
|
52
|
+
events: AsyncIterable<AdapterEvent>,
|
|
53
|
+
onEmptyTurn: () => void,
|
|
54
|
+
): AsyncGenerator<AdapterEvent> {
|
|
55
|
+
let sawContent = false;
|
|
56
|
+
let sawTerminal = false;
|
|
57
|
+
for await (const event of events) {
|
|
58
|
+
// Reasoning is deliberately NOT content, matching the guard: a reasoning-only stream that
|
|
59
|
+
// ends with nothing is the canonical shape of this failure.
|
|
60
|
+
if (isContentEvent(event)) sawContent = true;
|
|
61
|
+
if (isTerminalEvent(event)) {
|
|
62
|
+
sawTerminal = true;
|
|
63
|
+
// Only a successful terminal is the silent failure. `error` and `incomplete` are already
|
|
64
|
+
// a stated outcome the client can render, so flagging them would be noise.
|
|
65
|
+
if (!sawContent && event.type === "done") onEmptyTurn();
|
|
66
|
+
}
|
|
67
|
+
yield event;
|
|
68
|
+
}
|
|
69
|
+
// A stream that ends before any terminal is the pre-output EOF variant of the same failure.
|
|
70
|
+
if (!sawContent && !sawTerminal) onEmptyTurn();
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
/**
|
|
39
74
|
* Terminal stop reasons the bridge renders as a visible `response.incomplete`
|
|
40
75
|
* (max_tokens / content_filter). Those are already a stated failure, not the
|
|
@@ -153,9 +188,10 @@ export interface EmptyCompletionGuardOptions {
|
|
|
153
188
|
* Watch an adapter event stream for the empty-completion failure mode. Events
|
|
154
189
|
* are held until the turn produces content or ends: reasoning and other
|
|
155
190
|
* pre-content events stay buffered (released in order on first content), the
|
|
156
|
-
* terminal is withheld, and an empty terminal triggers one
|
|
157
|
-
* retry through `continuation`. Usage is merged across attempts
|
|
158
|
-
* and request log meter the whole turn, not just the attempt that
|
|
191
|
+
* terminal is withheld, and an empty terminal or pre-output EOF triggers one
|
|
192
|
+
* identical-turn retry through `continuation`. Usage is merged across attempts
|
|
193
|
+
* so the bridge and request log meter the whole turn, not just the attempt that
|
|
194
|
+
* succeeded.
|
|
159
195
|
*
|
|
160
196
|
* Heartbeats always pass through untouched: they feed the bridge's stall
|
|
161
197
|
* watchdog, so holding them behind the content gate would trip false
|
|
@@ -194,7 +230,11 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
194
230
|
if (sawContent || passthrough) {
|
|
195
231
|
// Buffered content is already flowing; everything downstream passes
|
|
196
232
|
// through. Every terminal carries usage merged across every attempt.
|
|
197
|
-
|
|
233
|
+
if (isTerminalEvent(event)) {
|
|
234
|
+
yield withUsage(event);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
yield event;
|
|
198
238
|
continue;
|
|
199
239
|
}
|
|
200
240
|
if (isContentEvent(event)) {
|
|
@@ -267,8 +307,25 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
267
307
|
if (isReasoningEvent(event)) yield { type: "heartbeat" };
|
|
268
308
|
}
|
|
269
309
|
if (!terminalSeen) {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
310
|
+
// A terminal-less EOF before text or a tool call is replay-safe: nothing
|
|
311
|
+
// actionable reached the client. Retry once, then surface a stated error
|
|
312
|
+
// instead of letting the bridge reduce the turn to adapter_eof.
|
|
313
|
+
if (!sawContent && !passthrough && retries < maxRetries) {
|
|
314
|
+
retries += 1;
|
|
315
|
+
try {
|
|
316
|
+
source = await options.continuation();
|
|
317
|
+
} catch {
|
|
318
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (!sawContent && retries > 0) {
|
|
324
|
+
yield emptyCompletionRetryFailedEvent(usage, true);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Post-output EOF remains incomplete; replaying could duplicate text or
|
|
328
|
+
// executable tool calls.
|
|
272
329
|
yield* releaseHeld();
|
|
273
330
|
return;
|
|
274
331
|
}
|
|
@@ -5,107 +5,12 @@ import {
|
|
|
5
5
|
shouldUseCodexWsUpstream,
|
|
6
6
|
type BunRuntimeGateInput,
|
|
7
7
|
} from "./ws-upstream";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
getConfigPath,
|
|
11
|
-
multiAgentGuidanceEnabled,
|
|
12
|
-
resolveEnvValue,
|
|
13
|
-
} from "../../config";
|
|
14
|
-
import { parseRequest } from "../../responses/parser";
|
|
15
|
-
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
|
|
16
|
-
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
|
|
17
|
-
import { expandPreviousResponseInput, previousResponseProviderState, rememberResponseState } from "../../responses/state";
|
|
18
|
-
import { routeModel } from "../../router";
|
|
19
|
-
import {
|
|
20
|
-
advanceComboAfterFailure,
|
|
21
|
-
comboDefaultEffort,
|
|
22
|
-
comboFailureDecision,
|
|
23
|
-
comboIdFromRawBody,
|
|
24
|
-
concreteComboRequestBody,
|
|
25
|
-
getCombo,
|
|
26
|
-
isComboTargetInCooldown,
|
|
27
|
-
NoAvailableComboTargetsError,
|
|
28
|
-
noteComboSuccess,
|
|
29
|
-
parseRetryAfterMs,
|
|
30
|
-
pickComboTarget,
|
|
31
|
-
targetKey,
|
|
32
|
-
} from "../../combos";
|
|
33
|
-
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
34
|
-
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
35
|
-
import { modelInList, namespacedToolName } from "../../types";
|
|
36
|
-
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
37
|
-
import {
|
|
38
|
-
forceRefreshOAuthAccessSnapshot,
|
|
39
|
-
getOAuthCredentialApiBaseUrl,
|
|
40
|
-
getOAuthCredentialProjectId,
|
|
41
|
-
getValidAccessTokenSnapshot,
|
|
42
|
-
type OAuthAccessSnapshot,
|
|
43
|
-
UnsupportedOAuthProviderError,
|
|
44
|
-
} from "../../oauth";
|
|
45
|
-
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
46
|
-
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
47
|
-
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
48
|
-
import {
|
|
49
|
-
applyCodexAuthContextToProvider,
|
|
50
|
-
CodexAccountCooldownError,
|
|
51
|
-
CodexAuthContextError,
|
|
52
|
-
CodexDirectAuthenticationError,
|
|
53
|
-
CodexPoolAuthenticationError,
|
|
54
|
-
CodexThreadAffinityExpiredError,
|
|
55
|
-
headersForCodexAuthContext,
|
|
56
|
-
isCodexAuthContextUsable,
|
|
57
|
-
resolveCodexAuthContext,
|
|
58
|
-
type CodexAuthContext,
|
|
59
|
-
} from "../../codex/auth-context";
|
|
60
|
-
import {
|
|
61
|
-
formatCodexProviderForLog,
|
|
62
|
-
recordCodexUpstreamOutcome,
|
|
63
|
-
type CodexUpstreamOutcome,
|
|
64
|
-
} from "../../codex/routing";
|
|
65
|
-
import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry";
|
|
66
|
-
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
67
|
-
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
68
|
-
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
69
|
-
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
70
|
-
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
|
|
71
|
-
import { isUsageDebugEnabled } from "../../usage/debug";
|
|
72
|
-
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
|
|
73
|
-
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
74
|
-
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
|
|
75
|
-
import { shouldAttemptImageTierRetry } from "../image-retry";
|
|
76
|
-
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
8
|
+
import type { OcxProviderConfig } from "../../types";
|
|
77
9
|
import type { WsData } from "../ws-bridge";
|
|
78
|
-
import { registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
|
|
79
|
-
import { redactSecretString } from "../../lib/redact";
|
|
80
|
-
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
81
|
-
import { supportedLadderFor } from "../effort-policy";
|
|
82
|
-
import {
|
|
83
|
-
beginRequestAttempt,
|
|
84
|
-
catalogModelSupportsServiceTier,
|
|
85
|
-
finishRequestAttempt,
|
|
86
|
-
inspectResponseLogJson,
|
|
87
|
-
noteAttemptSend,
|
|
88
|
-
readConfiguredCodexServiceTier,
|
|
89
|
-
requestLogSpeedLabel,
|
|
90
|
-
sealRequestAttemptIdentity,
|
|
91
|
-
usageFromResponsesPayload,
|
|
92
|
-
type RequestLogContext,
|
|
93
|
-
} from "../request-log";
|
|
94
|
-
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
95
|
-
import {
|
|
96
|
-
consumeForInspection,
|
|
97
|
-
consumeForResponseLogMetadata,
|
|
98
|
-
markNativePassthroughSseResponse,
|
|
99
|
-
relaySseWithFailedTail,
|
|
100
|
-
relayWithAbort,
|
|
101
|
-
sanitizePassthroughHeaders,
|
|
102
|
-
} from "../relay";
|
|
103
|
-
import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
|
|
104
|
-
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
105
10
|
import { waitForProviderRequestSlot } from "../../providers/request-pacing";
|
|
106
11
|
import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
|
|
107
12
|
|
|
108
|
-
export { withUpstreamHttpVersion }
|
|
13
|
+
export { withUpstreamHttpVersion };
|
|
109
14
|
|
|
110
15
|
export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
|
|
111
16
|
if (!server) return false;
|
|
@@ -159,14 +64,26 @@ export function providerFetch(
|
|
|
159
64
|
options: ProviderFetchOptions = {},
|
|
160
65
|
): ProviderFetch {
|
|
161
66
|
const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
|
|
67
|
+
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
68
|
+
base.preconnect?.(...args);
|
|
69
|
+
};
|
|
70
|
+
const httpFetch = Object.assign(
|
|
71
|
+
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
72
|
+
base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
|
|
73
|
+
{ preconnect },
|
|
74
|
+
) as typeof globalThis.fetch;
|
|
162
75
|
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
163
76
|
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
164
77
|
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
165
78
|
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
166
79
|
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
|
|
167
|
-
|
|
80
|
+
// The fallback has to be the same HTTP fetch the non-WS branch would have
|
|
81
|
+
// used, protocol pin included: a WS turn that falls back is serving the
|
|
82
|
+
// request over HTTP, and dropping the provider's `upstreamHttpVersion`
|
|
83
|
+
// there would silently negotiate a transport the operator ruled out.
|
|
84
|
+
return codexWsUpstreamFetch(input, init, httpFetch, runtime);
|
|
168
85
|
}
|
|
169
|
-
return
|
|
86
|
+
return httpFetch(input, init);
|
|
170
87
|
};
|
|
171
88
|
let pacingSlotAcquired = options.pacingSlotAcquired === true;
|
|
172
89
|
const waitForPacing = (signal?: AbortSignal) => {
|
|
@@ -182,9 +99,6 @@ export function providerFetch(
|
|
|
182
99
|
await waitForPacing(init?.signal ?? undefined);
|
|
183
100
|
return unpaced(input, init);
|
|
184
101
|
};
|
|
185
|
-
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
186
|
-
base.preconnect?.(...args);
|
|
187
|
-
};
|
|
188
102
|
return Object.assign(wrapped, {
|
|
189
103
|
preconnect,
|
|
190
104
|
waitForPacing,
|
|
@@ -225,6 +139,7 @@ export async function fetchWithHeaderTimeout(
|
|
|
225
139
|
// indistinguishable from a pre-connection failure (#914).
|
|
226
140
|
...(manualRedirect ? { redirect: "manual" as const } : {}),
|
|
227
141
|
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
142
|
+
timeout: 0,
|
|
228
143
|
});
|
|
229
144
|
} finally {
|
|
230
145
|
clearTimeout(timer);
|
|
@@ -68,7 +68,9 @@ function imageTokens(imageUrl: string): number {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
71
|
-
|
|
71
|
+
if (part.type === "image") return imageTokens(part.imageUrl);
|
|
72
|
+
if (part.type === "video") return imageTokens(part.videoUrl);
|
|
73
|
+
return estimateTokens(part.text, modelId);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { formatErrorResponse } from "../../bridge";
|
|
2
|
+
import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
|
|
2
3
|
import {
|
|
3
4
|
resolveClientRetryAfter,
|
|
4
5
|
validateClientRetryAfterHeader,
|
|
5
6
|
} from "../../lib/retry-after";
|
|
6
7
|
|
|
8
|
+
function isCyberPolicyBody(body: string): boolean {
|
|
9
|
+
if (isCyberPolicyMessage(body)) return true;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
12
|
+
const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
|
|
13
|
+
? parsed.response as Record<string, unknown>
|
|
14
|
+
: undefined;
|
|
15
|
+
for (const candidate of [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]) {
|
|
16
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
|
17
|
+
const record = candidate as Record<string, unknown>;
|
|
18
|
+
if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true;
|
|
19
|
+
if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true;
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
/* non-JSON body — message detection above is the only safe fallback */
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/**
|
|
8
28
|
* Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
|
|
9
29
|
* Codex maps an *empty* body to the literal client string "Unknown error"
|
|
@@ -32,18 +52,22 @@ export function formatPassthroughUpstreamError(
|
|
|
32
52
|
const now = options?.now ?? Date.now();
|
|
33
53
|
const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
|
|
34
54
|
const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
const cyberPolicyFailure = isCyberPolicyBody(trimmed);
|
|
56
|
+
const resolved = cyberPolicyFailure
|
|
57
|
+
? undefined
|
|
58
|
+
: resolveClientRetryAfter({
|
|
59
|
+
status,
|
|
60
|
+
message: trimmed || `Provider error ${status}: (empty body)`,
|
|
61
|
+
upstreamRetryAfter,
|
|
62
|
+
now,
|
|
63
|
+
});
|
|
41
64
|
|
|
42
65
|
if (trimmed) {
|
|
43
66
|
const needsSet = resolved !== undefined && upstreamRetryAfter !== resolved;
|
|
44
|
-
const needsDelete =
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
const needsDelete = (cyberPolicyFailure && upstreamRetryAfter !== undefined)
|
|
68
|
+
|| (resolved === undefined
|
|
69
|
+
&& upstreamRetryAfter !== undefined
|
|
70
|
+
&& originalValid === undefined);
|
|
47
71
|
|
|
48
72
|
if (!needsSet && !needsDelete) {
|
|
49
73
|
return new Response(bodyText, {
|
|
@@ -91,6 +91,29 @@ function nextSyntheticItemSlot(): ItemIdSlot {
|
|
|
91
91
|
return { kind: "fallback", ordinal: syntheticItemOrdinal };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Backfill `status` on a message output item if missing.
|
|
96
|
+
*
|
|
97
|
+
* The Responses API spec defines `status` as a required field on
|
|
98
|
+
* `OutputMessage`. Some upstream relays omit it, which causes strict
|
|
99
|
+
* deserializers (e.g. grok-build's serde types) to fail with
|
|
100
|
+
* `missing field 'status'`. Only message items carry this field in the
|
|
101
|
+
* Responses schema; reasoning, function_call, and other item types do not.
|
|
102
|
+
*
|
|
103
|
+
* The value is inferred from the event context: `output_item.added` and
|
|
104
|
+
* `response.created` / `response.in_progress` mean the message is still
|
|
105
|
+
* being generated (`in_progress`); `output_item.done` and
|
|
106
|
+
* `response.completed` / `response.incomplete` mean the message is
|
|
107
|
+
* finalized (`completed` / `incomplete` respectively).
|
|
108
|
+
*
|
|
109
|
+
* Returns the same object reference if no change is needed.
|
|
110
|
+
*/
|
|
111
|
+
function backfillItemStatus(item: Record<string, unknown>, inferredStatus: string): Record<string, unknown> {
|
|
112
|
+
if (item.type !== "message") return item;
|
|
113
|
+
if ("status" in item) return item;
|
|
114
|
+
return { ...item, status: inferredStatus };
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
/**
|
|
95
118
|
* Backfill annotations: [] on an output_text content part if missing.
|
|
96
119
|
* Returns the same object reference if no change is needed.
|
|
@@ -122,10 +145,10 @@ function backfillContentArray(content: unknown): unknown {
|
|
|
122
145
|
|
|
123
146
|
/**
|
|
124
147
|
* Walk an output item and backfill output_text parts in its content.
|
|
125
|
-
* Also backfills a missing required id on the item itself.
|
|
148
|
+
* Also backfills a missing required id and status on the item itself.
|
|
126
149
|
* Returns the same object reference if nothing changed.
|
|
127
150
|
*/
|
|
128
|
-
function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
|
|
151
|
+
function backfillOutputItem(item: unknown, slot: ItemIdSlot, inferredStatus: string): unknown {
|
|
129
152
|
if (!isPlainObject(item)) return item;
|
|
130
153
|
// The compact wire family is the `/v1/responses/compact` format, not a Responses output item.
|
|
131
154
|
// Those items have no `id` in that contract, so synthesizing one changes a response body the
|
|
@@ -136,34 +159,92 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
|
|
|
136
159
|
const content = item.content;
|
|
137
160
|
const repaired = backfillContentArray(content);
|
|
138
161
|
const withId = backfillItemId(item, slot);
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
const withStatus = backfillItemStatus(withId, inferredStatus);
|
|
163
|
+
if (repaired === content && withStatus === item) return item;
|
|
164
|
+
return { ...withStatus, ...(repaired === content ? {} : { content: repaired }) };
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/**
|
|
144
168
|
* Walk a response object's output[] and backfill output_text parts.
|
|
169
|
+
*
|
|
170
|
+
* `inferredItemStatus` is the status to backfill on message items that lack
|
|
171
|
+
* one — derived from the event type so `output_item.added` / `response.created`
|
|
172
|
+
* gets `in_progress` while `output_item.done` / `response.completed` gets
|
|
173
|
+
* `completed`.
|
|
174
|
+
*
|
|
175
|
+
* Deliberately does NOT backfill `created_at`. #2639 proposed it for the same
|
|
176
|
+
* strict-decoder reason as `status`, but the proxy also relays some upstream
|
|
177
|
+
* responses verbatim, and `tests/server-combo-failover-e2e.test.ts` asserts a
|
|
178
|
+
* combo backup response is returned byte-exact. Injecting a field the upstream
|
|
179
|
+
* never sent breaks that contract. Both cannot hold for the same body, so the
|
|
180
|
+
* `created_at` half needs its own decision about which contract yields; it is
|
|
181
|
+
* not a detail to slip in beside `status`.
|
|
182
|
+
*
|
|
145
183
|
* Returns the same object reference if nothing changed.
|
|
146
184
|
*/
|
|
147
|
-
function backfillResponseOutput(response: unknown): unknown {
|
|
185
|
+
function backfillResponseOutput(response: unknown, inferredItemStatus: string): unknown {
|
|
148
186
|
if (!isPlainObject(response)) return response;
|
|
149
187
|
const output = response.output;
|
|
150
188
|
if (!Array.isArray(output)) return response;
|
|
151
189
|
let changed = false;
|
|
152
190
|
const repaired = output.map((item, idx) => {
|
|
153
191
|
if (!isPlainObject(item)) return item;
|
|
154
|
-
const next = backfillOutputItem(item, { kind: "index", index: idx });
|
|
192
|
+
const next = backfillOutputItem(item, { kind: "index", index: idx }, inferredItemStatus);
|
|
155
193
|
if (next !== item) changed = true;
|
|
156
194
|
return next;
|
|
157
195
|
});
|
|
158
196
|
return changed ? { ...response, output: repaired } : response;
|
|
159
197
|
}
|
|
160
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Infer the status to backfill on a message item from the event type.
|
|
201
|
+
*
|
|
202
|
+
* `output_item.added` means the item is still being generated (`in_progress`);
|
|
203
|
+
* `output_item.done` means it is finalized (`completed`). Response-level events
|
|
204
|
+
* infer from the response's own status field — which is authoritative when present.
|
|
205
|
+
* If the response status is also absent, the event type itself determines the phase:
|
|
206
|
+
* `response.created` / `response.in_progress` → `in_progress`,
|
|
207
|
+
* `response.completed` → `completed`, `response.incomplete` → `incomplete`.
|
|
208
|
+
*/
|
|
209
|
+
function inferredStatusForEventType(eventType: string): string {
|
|
210
|
+
if (eventType === "response.output_item.added") return "in_progress";
|
|
211
|
+
if (eventType === "response.output_item.done") return "completed";
|
|
212
|
+
if (eventType === "response.created" || eventType === "response.in_progress") return "in_progress";
|
|
213
|
+
// `queued` is a real Responses lifecycle status: the response exists but has not
|
|
214
|
+
// started generating. Without this row it falls through to the `completed`
|
|
215
|
+
// default below, which would mark an unstarted message as finished.
|
|
216
|
+
if (eventType === "response.queued") return "in_progress";
|
|
217
|
+
if (eventType === "response.incomplete" || eventType === "response.failed") return "incomplete";
|
|
218
|
+
return "completed";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Map a response-level lifecycle status to a valid OutputMessage status.
|
|
223
|
+
*
|
|
224
|
+
* `OutputMessage.status` accepts only `in_progress`, `completed`, or
|
|
225
|
+
* `incomplete`. Response-level statuses like `failed` or `cancelled` have no
|
|
226
|
+
* direct message-level equivalent, but `incomplete` is the correct semantic
|
|
227
|
+
* mapping: the message did not finish generating. Writing `completed` would
|
|
228
|
+
* assert something the upstream never claimed — a client branching on
|
|
229
|
+
* `status === "completed"` would treat a truncated message as whole.
|
|
230
|
+
*/
|
|
231
|
+
function messageStatusFromResponseStatus(status: string): string | null {
|
|
232
|
+
if (status === "in_progress" || status === "completed" || status === "incomplete") return status;
|
|
233
|
+
// A queued response has not begun generating, so its message items are
|
|
234
|
+
// in_progress — never completed. Returning null here would fall back to the
|
|
235
|
+
// event-type inference, whose default is `completed`.
|
|
236
|
+
if (status === "queued") return "in_progress";
|
|
237
|
+
if (status === "failed" || status === "cancelled") return "incomplete";
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
161
241
|
/**
|
|
162
242
|
* Statelessly rewrite one SSE event: backfill annotations
|
|
163
243
|
* on any output_text content part found in the event payload.
|
|
164
244
|
*/
|
|
165
245
|
function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
166
246
|
const type = typeof event.type === "string" ? event.type : "";
|
|
247
|
+
const inferredItemStatus = inferredStatusForEventType(type);
|
|
167
248
|
let next = event;
|
|
168
249
|
let changed = false;
|
|
169
250
|
|
|
@@ -177,8 +258,8 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
177
258
|
// is not recoverable in that case, but a unique id is what strict decoders require, and a
|
|
178
259
|
// well-formed stream still gets the stable index-derived id.
|
|
179
260
|
const item = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
|
|
180
|
-
? backfillOutputItem(event.item, { kind: "index", index: rawIndex })
|
|
181
|
-
: backfillOutputItem(event.item, nextSyntheticItemSlot());
|
|
261
|
+
? backfillOutputItem(event.item, { kind: "index", index: rawIndex }, inferredItemStatus)
|
|
262
|
+
: backfillOutputItem(event.item, nextSyntheticItemSlot(), inferredItemStatus);
|
|
182
263
|
if (item !== event.item) {
|
|
183
264
|
next = { ...next, item };
|
|
184
265
|
changed = true;
|
|
@@ -198,7 +279,13 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
198
279
|
// response.created / in_progress / completed / incomplete / failed:
|
|
199
280
|
// response.output[].content[] -> output_text parts
|
|
200
281
|
if (isPlainObject(event.response)) {
|
|
201
|
-
|
|
282
|
+
// For response-level events, prefer the response's own status when it is a valid
|
|
283
|
+
// OutputMessage status. Response lifecycle statuses like "failed" or "cancelled"
|
|
284
|
+
// have no message-level equivalent — fall back to the event-type inference instead.
|
|
285
|
+
const responseStatus = typeof event.response.status === "string"
|
|
286
|
+
? messageStatusFromResponseStatus(event.response.status) ?? inferredItemStatus
|
|
287
|
+
: inferredItemStatus;
|
|
288
|
+
const response = backfillResponseOutput(event.response, responseStatus);
|
|
202
289
|
if (response !== event.response) {
|
|
203
290
|
next = { ...next, response };
|
|
204
291
|
changed = true;
|
|
@@ -210,9 +297,9 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
210
297
|
|
|
211
298
|
/**
|
|
212
299
|
* Create a stateless SSE block rewrite that backfills annotations and
|
|
213
|
-
* on output_text content parts
|
|
214
|
-
* canonical Responses
|
|
215
|
-
* clients.
|
|
300
|
+
* message status on output_text content parts and message items.
|
|
301
|
+
* Unconditional: both are required canonical Responses fields, so adding
|
|
302
|
+
* them when absent is safe for all clients.
|
|
216
303
|
*/
|
|
217
304
|
export function createResponsesFieldBackfillBlockRewrite(): SseBlockRewrite {
|
|
218
305
|
const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
|
|
@@ -245,7 +332,12 @@ export function backfillResponsesFieldsJson(payload: string): string {
|
|
|
245
332
|
return payload;
|
|
246
333
|
}
|
|
247
334
|
if (!isPlainObject(response)) return payload;
|
|
248
|
-
|
|
335
|
+
// For a non-streaming response, derive the item status from the response's own
|
|
336
|
+
// status field when it is a valid OutputMessage status; fall back to "completed".
|
|
337
|
+
const inferredItemStatus = typeof response.status === "string"
|
|
338
|
+
? messageStatusFromResponseStatus(response.status) ?? "completed"
|
|
339
|
+
: "completed";
|
|
340
|
+
const repaired = backfillResponseOutput(response, inferredItemStatus);
|
|
249
341
|
if (repaired === response) return payload;
|
|
250
342
|
return JSON.stringify(repaired);
|
|
251
343
|
}
|
|
@@ -28,6 +28,25 @@ const UPGRADE_DEADLINE_MS = 10_000;
|
|
|
28
28
|
export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
|
|
29
29
|
export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
30
30
|
export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
|
|
31
|
+
// The backend drops any inbound message of 16 MiB or more: it closes the socket
|
|
32
|
+
// (1009) without a Responses terminal event, which reaches clients as a bare
|
|
33
|
+
// 502 upstream_server_error. Measured against the live endpoint 2026-08-23:
|
|
34
|
+
// 16,777,000 B completed, 16,777,300 B closed in ~1s, every time. The same
|
|
35
|
+
// request body succeeds over HTTP SSE, so the ceiling belongs to this transport
|
|
36
|
+
// alone (see #2426). A full-replay thread reaches it with ~11 pasted
|
|
37
|
+
// screenshots, and then never recovers, because each retry resends the frame.
|
|
38
|
+
export const MAX_CODEX_WS_CREATE_FRAME_BYTES = 16 * 1024 * 1024;
|
|
39
|
+
// Bun frames the payload it is handed, so the send-side budget is the JSON text
|
|
40
|
+
// itself, and nothing is appended between the check and the send. The margin is
|
|
41
|
+
// a conservative cushion, not a computed requirement: it covers RFC 6455 frame
|
|
42
|
+
// overhead in case the backend counts it (14 bytes at this payload size — an
|
|
43
|
+
// 8-byte extended length plus a 4-byte client mask, leaving ~65.5 KiB spare),
|
|
44
|
+
// and it leaves room for a future caller that appends to the frame.
|
|
45
|
+
const CODEX_WS_CREATE_FRAME_MARGIN_BYTES = 64 * 1024;
|
|
46
|
+
export const CODEX_WS_CREATE_FRAME_LIMIT_BYTES =
|
|
47
|
+
MAX_CODEX_WS_CREATE_FRAME_BYTES - CODEX_WS_CREATE_FRAME_MARGIN_BYTES;
|
|
48
|
+
/** Close code the backend uses for an oversized message (RFC 6455 "message too big"). */
|
|
49
|
+
const WS_CLOSE_MESSAGE_TOO_BIG = 1009;
|
|
31
50
|
|
|
32
51
|
export type BunRuntimeIdentity = {
|
|
33
52
|
version: string;
|
|
@@ -102,6 +121,52 @@ export function shouldUseCodexWsUpstream(
|
|
|
102
121
|
}
|
|
103
122
|
}
|
|
104
123
|
|
|
124
|
+
const CLOSED_BEFORE_TERMINAL = "codex websocket closed before a Responses terminal event";
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The close code is the only thing that separates "the backend refused this
|
|
128
|
+
* payload" from "the network dropped", and both used to reach the caller as the
|
|
129
|
+
* same bare 502. Naming the oversized case here puts that distinction in the
|
|
130
|
+
* message the client receives.
|
|
131
|
+
*
|
|
132
|
+
* It does NOT reach the request log as a typed code. The eager relay turns any
|
|
133
|
+
* stream error into a generic `upstream_reset` synthetic terminal
|
|
134
|
+
* (`relay.ts`, `relay-eager.ts`) without feeding that frame back through the
|
|
135
|
+
* inspector, so `/api/logs` keeps neither this message nor a specific code —
|
|
136
|
+
* only `streamAborted`. Machine-readable typing would mean changing the error
|
|
137
|
+
* taxonomy, which is deliberately out of scope for this transport fix.
|
|
138
|
+
*/
|
|
139
|
+
function closedBeforeTerminalMessage(event: unknown): string {
|
|
140
|
+
const detail = event as { code?: unknown; reason?: unknown } | null | undefined;
|
|
141
|
+
const code = typeof detail?.code === "number" ? detail.code : null;
|
|
142
|
+
const reason = typeof detail?.reason === "string" ? detail.reason.trim() : "";
|
|
143
|
+
if (code === null) return CLOSED_BEFORE_TERMINAL;
|
|
144
|
+
const suffix = reason ? ` ${code} ${reason}` : ` ${code}`;
|
|
145
|
+
if (code === WS_CLOSE_MESSAGE_TOO_BIG) {
|
|
146
|
+
return `codex websocket rejected the request frame as too large (close${suffix});`
|
|
147
|
+
+ ` requests at or above ${MAX_CODEX_WS_CREATE_FRAME_BYTES} bytes must use the HTTP SSE transport`;
|
|
148
|
+
}
|
|
149
|
+
return `${CLOSED_BEFORE_TERMINAL} (close${suffix})`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* True when the `response.create` frame is at or above the backend's inbound
|
|
154
|
+
* message ceiling, so this turn must take the HTTP SSE path instead.
|
|
155
|
+
*
|
|
156
|
+
* Sizing a 16 MiB string should not cost a 16 MiB copy. UTF-8 never encodes
|
|
157
|
+
* below one byte per UTF-16 code unit and never above three, so both tails are
|
|
158
|
+
* settled from the string length alone; only the narrow band between them pays
|
|
159
|
+
* for a real byte count, and `Buffer.byteLength` measures without allocating.
|
|
160
|
+
*/
|
|
161
|
+
export function codexWsCreateFrameExceedsLimit(
|
|
162
|
+
frameText: string,
|
|
163
|
+
limitBytes: number = CODEX_WS_CREATE_FRAME_LIMIT_BYTES,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (frameText.length >= limitBytes) return true;
|
|
166
|
+
if (frameText.length * 3 < limitBytes) return false;
|
|
167
|
+
return Buffer.byteLength(frameText, "utf8") >= limitBytes;
|
|
168
|
+
}
|
|
169
|
+
|
|
105
170
|
export function codexWsUpstreamFetch(
|
|
106
171
|
url: string,
|
|
107
172
|
init: RequestInit,
|
|
@@ -127,6 +192,14 @@ export function codexWsUpstreamFetch(
|
|
|
127
192
|
return sseFallback(url, init);
|
|
128
193
|
}
|
|
129
194
|
|
|
195
|
+
// Decide before dialing. Once the socket is open the caller already holds a
|
|
196
|
+
// streaming Response, so the oversized close can only be surfaced as a stream
|
|
197
|
+
// error — and a resend at that point could double-generate. Measuring the
|
|
198
|
+
// frame we are about to send keeps the whole failure mode unreachable.
|
|
199
|
+
if (codexWsCreateFrameExceedsLimit(frameText)) {
|
|
200
|
+
return sseFallback(url, init);
|
|
201
|
+
}
|
|
202
|
+
|
|
130
203
|
const headers: Record<string, string> = {};
|
|
131
204
|
new Headers(init.headers ?? {}).forEach((value, key) => {
|
|
132
205
|
// HTTP-body framing headers do not apply to a WS handshake.
|
|
@@ -279,7 +352,7 @@ export function codexWsUpstreamFetch(
|
|
|
279
352
|
}
|
|
280
353
|
});
|
|
281
354
|
|
|
282
|
-
ws.addEventListener("close", () => {
|
|
355
|
+
ws.addEventListener("close", (event: unknown) => {
|
|
283
356
|
signal?.removeEventListener("abort", onAbort);
|
|
284
357
|
if (!opened) {
|
|
285
358
|
if (settledPreOpen) return;
|
|
@@ -297,7 +370,7 @@ export function codexWsUpstreamFetch(
|
|
|
297
370
|
// here would reach clients with no response.completed/failed at all —
|
|
298
371
|
// relaySseWithFailedTail() only synthesizes a failed terminal when the
|
|
299
372
|
// body read THROWS. Error the stream like a reset TCP socket.
|
|
300
|
-
try { controller.error(new Error(
|
|
373
|
+
try { controller.error(new Error(closedBeforeTerminalMessage(event))); } catch { /* stream already done */ }
|
|
301
374
|
}
|
|
302
375
|
});
|
|
303
376
|
|