@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922
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 +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -12,9 +12,57 @@ import { waitForProviderRequestSlot } from "../../providers/request-pacing";
|
|
|
12
12
|
import { withUpstreamHttpVersion } from "../../lib/upstream-http-version";
|
|
13
13
|
import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
14
14
|
import { configuredOutboundFetch } from "../../lib/proxy-env";
|
|
15
|
+
import {
|
|
16
|
+
describeProviderEgressForLog,
|
|
17
|
+
markEgressTransparentExecutor,
|
|
18
|
+
providerEgressSendInit,
|
|
19
|
+
providerEgressIsExplicit,
|
|
20
|
+
resolveProviderEgress,
|
|
21
|
+
type ProviderEgressBinding,
|
|
22
|
+
} from "../../lib/provider-egress";
|
|
23
|
+
import { redactSecretString } from "../../lib/redact";
|
|
15
24
|
|
|
16
25
|
export { withUpstreamHttpVersion };
|
|
17
26
|
|
|
27
|
+
const egressWebsocketDowngradeWarned = new Set<string>();
|
|
28
|
+
/** A provider name is configuration-controlled, so the notice set is bounded like any cache. */
|
|
29
|
+
const EGRESS_DOWNGRADE_NOTICE_LIMIT = 64;
|
|
30
|
+
/**
|
|
31
|
+
* Marks an init whose provider egress route an outer physical-send boundary already decided.
|
|
32
|
+
*
|
|
33
|
+
* Own symbol keys survive object spread, so the mark travels through the rebuild a
|
|
34
|
+
* `dispatchOverride` performs, and an unknown symbol on a `RequestInit` is inert at the wire.
|
|
35
|
+
*/
|
|
36
|
+
const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Announce once, per provider, that an explicit egress route moved this provider off the
|
|
40
|
+
* WebSocket fast lane.
|
|
41
|
+
*
|
|
42
|
+
* The WebSocket upstream selects its proxy from the process environment when it dials, so it
|
|
43
|
+
* cannot carry a per-provider route. Serving the turn over HTTP/SSE honours the operator's
|
|
44
|
+
* egress choice, which is the one that has to win — but a transport change the operator did
|
|
45
|
+
* not ask for is exactly the kind of substitution this batch refuses to make silently, so it
|
|
46
|
+
* is stated rather than merely done.
|
|
47
|
+
*/
|
|
48
|
+
function warnEgressWebsocketDowngradeOnce(providerName: string, egress: string): void {
|
|
49
|
+
if (egressWebsocketDowngradeWarned.has(providerName)) return;
|
|
50
|
+
if (egressWebsocketDowngradeWarned.size >= EGRESS_DOWNGRADE_NOTICE_LIMIT) return;
|
|
51
|
+
egressWebsocketDowngradeWarned.add(providerName);
|
|
52
|
+
console.warn(
|
|
53
|
+
// The name is caller-controlled and can be token-shaped, so it is redacted and JSON-escaped
|
|
54
|
+
// before it reaches a log, exactly as at the management error boundary.
|
|
55
|
+
`[opencodex] provider ${JSON.stringify(redactSecretString(providerName))} declares egress ${egress}; the WebSocket upstream `
|
|
56
|
+
+ "selects its proxy from the process environment and cannot carry a per-provider route, "
|
|
57
|
+
+ "so these turns are served over HTTP/SSE.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Test seam: the downgrade notice is once per provider per process, not once per request. */
|
|
62
|
+
export function __resetEgressWebsocketDowngradeNotices(): void {
|
|
63
|
+
egressWebsocketDowngradeWarned.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
18
66
|
export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
|
|
19
67
|
if (!server) return false;
|
|
20
68
|
try {
|
|
@@ -97,21 +145,43 @@ export function sendWithConnectionPolicy(
|
|
|
97
145
|
physicalFetch: typeof globalThis.fetch,
|
|
98
146
|
input: Parameters<typeof globalThis.fetch>[0],
|
|
99
147
|
init?: RequestInit,
|
|
148
|
+
egress?: ProviderEgressBinding,
|
|
100
149
|
): Promise<Response> {
|
|
101
150
|
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
102
151
|
const fresh = wantsFreshConnection(input);
|
|
103
152
|
if (fresh) {
|
|
104
153
|
headers.set("Connection", "close");
|
|
105
154
|
}
|
|
155
|
+
// Decided here, against the destination this send is actually going to, and around whichever
|
|
156
|
+
// executor was just selected. A `dispatchOverride` that rebuilds a queued request can change
|
|
157
|
+
// both the upstream host and the provider transport after the wrapper was constructed, so a
|
|
158
|
+
// route resolved at construction could be applied to a different host than it was decided for.
|
|
159
|
+
// These calls nest: an override decides with its own binding and then hands the send to the
|
|
160
|
+
// executor `providerFetch` supplied, which is another one of these. The outermost caller holds
|
|
161
|
+
// the reselected provider and the rebuilt destination, so it decides and marks the init; the
|
|
162
|
+
// inner pass honours that mark rather than recomputing from a stale closure.
|
|
163
|
+
const alreadyDecided = (init as Record<symbol, unknown> | undefined)?.[EGRESS_DECIDED] === true;
|
|
164
|
+
const decide = egress !== undefined && !alreadyDecided;
|
|
165
|
+
const egressInit = decide ? providerEgressSendInit(egress, physicalFetch, input) : {};
|
|
106
166
|
return physicalFetch(input, {
|
|
107
167
|
...init,
|
|
108
168
|
headers,
|
|
109
169
|
redirect: "manual",
|
|
110
170
|
...(fresh ? { keepalive: false } : {}),
|
|
171
|
+
...egressInit,
|
|
172
|
+
...(decide ? { [EGRESS_DECIDED]: true } : {}),
|
|
111
173
|
});
|
|
112
174
|
}
|
|
113
175
|
|
|
114
176
|
export interface ProviderFetchOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Keep this send on HTTP even where the WebSocket upstream would normally be selected.
|
|
179
|
+
*
|
|
180
|
+
* Set by a caller replacing an HTTP stream that already failed: a WS create frame is a
|
|
181
|
+
* different send on a different transport, and the replacement has to be the same kind of
|
|
182
|
+
* exchange the client is already reading.
|
|
183
|
+
*/
|
|
184
|
+
httpOnly?: boolean;
|
|
115
185
|
nativeControl?: NativeResponseControl;
|
|
116
186
|
providerName?: string;
|
|
117
187
|
modelId?: string;
|
|
@@ -130,28 +200,63 @@ export function providerFetch(
|
|
|
130
200
|
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
131
201
|
options: ProviderFetchOptions = {},
|
|
132
202
|
): ProviderFetch {
|
|
133
|
-
const
|
|
203
|
+
const providerName = options.providerName ?? "<unnamed provider>";
|
|
204
|
+
const customExecutor = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
|
|
205
|
+
// The route is applied at the physical send (see `sendWithConnectionPolicy`). This binding is
|
|
206
|
+
// only what that boundary needs to decide it.
|
|
207
|
+
const egressBinding: ProviderEgressBinding = { providerName, provider };
|
|
208
|
+
// Resolved per request, not once per wrapper: `providers.<name>.noProxy` is evaluated against
|
|
209
|
+
// the destination, so two requests through the same executor can legitimately take different
|
|
210
|
+
// routes. A malformed value throws and rejects the request rather than degrading to the
|
|
211
|
+
// global proxy or to direct, either of which would read as success at the call site.
|
|
212
|
+
const egressFor = (input: Parameters<typeof globalThis.fetch>[0]) => resolveProviderEgress({
|
|
213
|
+
providerName,
|
|
214
|
+
provider,
|
|
215
|
+
url: typeof input === "string" ? input : input instanceof URL ? input : input.url,
|
|
216
|
+
});
|
|
217
|
+
// The built-in executor forwards its init to a transport that honours the proxy option.
|
|
218
|
+
const configuredFetch = markEgressTransparentExecutor(Object.assign(
|
|
134
219
|
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => configuredOutboundFetch(input, init),
|
|
135
220
|
{ preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) },
|
|
136
|
-
) as typeof globalThis.fetch;
|
|
137
|
-
const base =
|
|
221
|
+
) as typeof globalThis.fetch);
|
|
222
|
+
const base = customExecutor ?? configuredFetch;
|
|
138
223
|
const preconnect = (...args: Parameters<typeof globalThis.fetch.preconnect>): void => {
|
|
139
224
|
base.preconnect?.(...args);
|
|
140
225
|
};
|
|
141
226
|
// Rebuilt dispatches must use the same physical-send boundary as ordinary HTTP sends.
|
|
142
227
|
// Return the original 3xx so the response owner retains its retry/health/relay contract.
|
|
143
|
-
|
|
228
|
+
//
|
|
229
|
+
// Marked transparent because it forwards its init to a transport that honours the proxy
|
|
230
|
+
// option. Leaving it unmarked would make an ordinary configured provider refuse its own route
|
|
231
|
+
// on every overridden path, after the attempt had already been recorded — an override selects
|
|
232
|
+
// `provider.fetch ?? execute`, and `execute` is this wrapper. It still carries the binding, so
|
|
233
|
+
// an override that simply calls it gets the route decided rather than dropped; an override
|
|
234
|
+
// that decided for itself has already marked the init and this pass defers to that decision.
|
|
235
|
+
const dispatch = markEgressTransparentExecutor(Object.assign(
|
|
144
236
|
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
145
|
-
sendWithConnectionPolicy(base, input, init),
|
|
237
|
+
sendWithConnectionPolicy(base, input, init, egressBinding),
|
|
146
238
|
{ preconnect },
|
|
147
|
-
) as typeof globalThis.fetch;
|
|
239
|
+
) as typeof globalThis.fetch);
|
|
148
240
|
const httpFetch = Object.assign(
|
|
149
241
|
async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
242
|
+
// Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
|
|
243
|
+
// attempt accounting and consumes admission state, so a refusal firing after it would
|
|
244
|
+
// charge an attempt for a send that never happens, and a throwing hook would mask the
|
|
245
|
+
// egress error with an unrelated one.
|
|
246
|
+
//
|
|
247
|
+
// With no override, this input and `base` ARE the final destination and executor, so the
|
|
248
|
+
// full decision can be made now. With an override, only the configured value is checked:
|
|
249
|
+
// the override may rebuild against a different host and select a different transport, and
|
|
250
|
+
// refusing on this destination would reject a request whose real route is fine.
|
|
251
|
+
if (options.dispatchOverride) egressFor(input);
|
|
252
|
+
else providerEgressSendInit(egressBinding, base, input);
|
|
150
253
|
// The hook inspects the outgoing headers and refuses the send by throwing; it is not a
|
|
151
254
|
// mutator, and the copy it receives is deliberately not threaded onward. `Connection`
|
|
152
255
|
// is decided inside `dispatch`, which runs after this, so the fresh-connection policy
|
|
153
256
|
// wins regardless of what any caller or hook put in the header.
|
|
154
257
|
options.beforeDispatch?.(new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
|
|
258
|
+
// No proxy option is attached here: a `dispatchOverride` may rebuild this request against
|
|
259
|
+
// a different destination, so the route is decided at the physical send instead.
|
|
155
260
|
const dispatchInit = { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 };
|
|
156
261
|
return options.dispatchOverride
|
|
157
262
|
? options.dispatchOverride(input, dispatchInit, dispatch)
|
|
@@ -164,7 +269,13 @@ export function providerFetch(
|
|
|
164
269
|
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
165
270
|
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
166
271
|
const upstreamWebsocket = provider.upstreamWebsocket === true;
|
|
167
|
-
if (typeof input === "string" && init
|
|
272
|
+
if (!options.httpOnly && typeof input === "string" && init
|
|
273
|
+
&& shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
|
|
274
|
+
const egress = egressFor(input);
|
|
275
|
+
if (providerEgressIsExplicit(egress)) {
|
|
276
|
+
warnEgressWebsocketDowngradeOnce(providerName, describeProviderEgressForLog(egress));
|
|
277
|
+
return httpFetch(input, init);
|
|
278
|
+
}
|
|
168
279
|
// The fallback has to be the same HTTP fetch the non-WS branch would have
|
|
169
280
|
// used, protocol pin included: a WS turn that falls back is serving the
|
|
170
281
|
// request over HTTP, and dropping the provider's `upstreamHttpVersion`
|
|
@@ -188,11 +299,16 @@ export function providerFetch(
|
|
|
188
299
|
await waitForPacing(init?.signal ?? undefined);
|
|
189
300
|
return unpaced(input, init);
|
|
190
301
|
};
|
|
191
|
-
|
|
302
|
+
// The returned wrapper forwards its init down to `dispatch`, which applies the route at the
|
|
303
|
+
// physical send. Adapters that hand this executor back as `provider.fetch` (Cursor does)
|
|
304
|
+
// therefore still carry a per-provider route instead of being refused as opaque.
|
|
305
|
+
const paceAware = Object.assign(wrapped, {
|
|
192
306
|
preconnect,
|
|
193
307
|
waitForPacing,
|
|
194
308
|
unpacedFetch: Object.assign(unpaced, { preconnect }),
|
|
195
309
|
});
|
|
310
|
+
markEgressTransparentExecutor(paceAware as unknown as typeof globalThis.fetch);
|
|
311
|
+
return paceAware;
|
|
196
312
|
}
|
|
197
313
|
|
|
198
314
|
|
|
@@ -85,9 +85,19 @@ function imageTokens(imageUrl: string): number {
|
|
|
85
85
|
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
86
86
|
if (part.type === "image") return imageTokens(part.imageUrl);
|
|
87
87
|
if (part.type === "video") return imageTokens(part.videoUrl);
|
|
88
|
+
// An inline document is a base64 payload, not a sentence: estimating it from its marker would
|
|
89
|
+
// admit a request whose real input is orders of magnitude larger. Counted arithmetically —
|
|
90
|
+
// rebuilding the data URL here would materialize a second request-sized string just to measure it.
|
|
91
|
+
if (part.type === "document") return base64PayloadTokens(part.data);
|
|
88
92
|
return estimateTokens(part.text, modelId);
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
function base64PayloadTokens(base64: string): number {
|
|
96
|
+
if (base64.length === 0) return 0;
|
|
97
|
+
const decoded = Math.floor((base64.length * 3) / 4);
|
|
98
|
+
return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
92
102
|
if (typeof content === "string") return estimateTokens(content, modelId);
|
|
93
103
|
let total = 0;
|
|
@@ -93,6 +93,7 @@ import { createResponsesFieldBackfillBlockRewrite } from "./responses-field-back
|
|
|
93
93
|
import { createResponsesFunctionToolRepairBlockRewrite } from "../responses-function-tool-repair";
|
|
94
94
|
import {
|
|
95
95
|
createUndeclaredToolCallGuardBlockRewrite,
|
|
96
|
+
currentTurnWireToolCatalogBody,
|
|
96
97
|
undeclaredToolCallNameInResponse,
|
|
97
98
|
undeclaredToolCallMessage,
|
|
98
99
|
normalizeDefaultNamespaceInJson,
|
|
@@ -515,7 +516,19 @@ export async function deliverPassthroughResponse(
|
|
|
515
516
|
? createGrokResponsesTimestampBlockRewrite()
|
|
516
517
|
: undefined,
|
|
517
518
|
grokClientCompatibilityEnabled
|
|
518
|
-
? createGrokResponsesSparseTerminalBlockRewrite(
|
|
519
|
+
? createGrokResponsesSparseTerminalBlockRewrite(
|
|
520
|
+
translatorBudget,
|
|
521
|
+
nativeExchange.outboundRequestBody,
|
|
522
|
+
{
|
|
523
|
+
clientToolAuthorizationBody: currentTurnWireToolCatalogBody(
|
|
524
|
+
parsed._rawBody,
|
|
525
|
+
parsed._replayPrefixLen ?? 0,
|
|
526
|
+
),
|
|
527
|
+
routedNamespaceToolAliases: responseEffects.routedNamespaceToolAliases,
|
|
528
|
+
routedMuseToolNameAliases: responseEffects.routedMuseToolNameAliases,
|
|
529
|
+
convertedRoutedCustomToolNames: routedCustomToolNames,
|
|
530
|
+
},
|
|
531
|
+
)
|
|
519
532
|
: undefined,
|
|
520
533
|
snapshotRepairEnabled
|
|
521
534
|
? createResponsesSnapshotBlockRewrite(nativeExchange.outboundRequestBody, translatorBudget)
|
|
@@ -102,7 +102,7 @@ import {
|
|
|
102
102
|
clearCodexModelDenialEvidence,
|
|
103
103
|
recordCodexModelDenialEvidence,
|
|
104
104
|
} from "../../codex/model-entitlements";
|
|
105
|
-
import { readCodexWsStage } from "./codex-ws-wire";
|
|
105
|
+
import { isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire";
|
|
106
106
|
import { linkAbortSignal } from "./core-lifetime";
|
|
107
107
|
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
108
108
|
import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
|
|
@@ -112,7 +112,8 @@ import {
|
|
|
112
112
|
fetchWithTransientRetry,
|
|
113
113
|
applyUpstreamRecoveryInit,
|
|
114
114
|
isNonReplayableResponse,
|
|
115
|
-
|
|
115
|
+
refetchAfterProtocolSafeReset,
|
|
116
|
+
prepareSameTarget429Wait,
|
|
116
117
|
sleepWithAbort,
|
|
117
118
|
} from "../../lib/upstream-retry";
|
|
118
119
|
import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
|
|
@@ -151,7 +152,9 @@ import {
|
|
|
151
152
|
reasoningEffortRejectionText,
|
|
152
153
|
} from "./core-opaque-recovery";
|
|
153
154
|
import type { RequestLogContext } from "../request-log";
|
|
154
|
-
import { preflightComboStreamResponse } from "./combo-stream-preflight";
|
|
155
|
+
import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight";
|
|
156
|
+
import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
|
|
157
|
+
import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
|
|
155
158
|
import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors";
|
|
156
159
|
import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
|
|
157
160
|
import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
|
|
@@ -189,6 +192,8 @@ export async function preparePassthroughExchange(
|
|
|
189
192
|
| "genericFailovers"
|
|
190
193
|
| "applyFailoverSnapshot"
|
|
191
194
|
| "noteRoutedAttemptSend"
|
|
195
|
+
| "selectionIsCurrent"
|
|
196
|
+
| "requestBindings"
|
|
192
197
|
>,
|
|
193
198
|
responseEffects: Pick<
|
|
194
199
|
ResponsesEffects,
|
|
@@ -206,6 +211,7 @@ export async function preparePassthroughExchange(
|
|
|
206
211
|
| "recoverySendAllowance"
|
|
207
212
|
| "recoveryClassFor"
|
|
208
213
|
| "sendBudgetExhausted"
|
|
214
|
+
| "claimAmbiguousResend"
|
|
209
215
|
| "reserveCredentialHop"
|
|
210
216
|
| "pendingHopPermit"
|
|
211
217
|
| "workflowRootId"
|
|
@@ -239,6 +245,7 @@ export async function preparePassthroughExchange(
|
|
|
239
245
|
recoverySendAllowance,
|
|
240
246
|
recoveryClassFor,
|
|
241
247
|
sendBudgetExhausted,
|
|
248
|
+
claimAmbiguousResend,
|
|
242
249
|
reserveCredentialHop,
|
|
243
250
|
workflowRootId,
|
|
244
251
|
} = sendBudgetState;
|
|
@@ -704,6 +711,35 @@ export async function preparePassthroughExchange(
|
|
|
704
711
|
);
|
|
705
712
|
const configuredTransientSendBudgetExhausted = (): boolean =>
|
|
706
713
|
transientSendPolicy() !== null && transientSendAttempts() === 0;
|
|
714
|
+
/**
|
|
715
|
+
* Judged once. The inbound body does not change between legs, and every rebuild this lane
|
|
716
|
+
* performs only ever REMOVES a hazard -- `previous_response_id` is expanded, hosted tools
|
|
717
|
+
* are lowered into client execution -- so a body that was replaceable stays replaceable.
|
|
718
|
+
* Memoized rather than recomputed because it walks the input array, and a provider that
|
|
719
|
+
* never opted in must not pay for it at all.
|
|
720
|
+
*/
|
|
721
|
+
let selfContainedJudgment: boolean | undefined;
|
|
722
|
+
const requestIsSelfContained = (): boolean =>
|
|
723
|
+
selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody);
|
|
724
|
+
/**
|
|
725
|
+
* The operator's replacement grant for THIS logical request.
|
|
726
|
+
*
|
|
727
|
+
* Read per leg because `route.provider` is reassigned by credential rotation and transport
|
|
728
|
+
* resolution inside the recovery loop, exactly like `transientSendPolicy`. The counter it
|
|
729
|
+
* claims from is not per leg: it lives on the request's execution budget, which a combo
|
|
730
|
+
* child shares, so every ambiguous stage of this request draws on the same grant.
|
|
731
|
+
*/
|
|
732
|
+
const ambiguousResend = () =>
|
|
733
|
+
ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend);
|
|
734
|
+
/**
|
|
735
|
+
* The pre-header row of the stage table, asked through the one gate.
|
|
736
|
+
*
|
|
737
|
+
* `fetchWithResetRetry` takes a plain callback because it is a leaf that must not import
|
|
738
|
+
* the server tree; routing the answer through `authorizeResendForRecovery` here is what
|
|
739
|
+
* keeps the decision derived from the table rather than restated as a boolean.
|
|
740
|
+
*/
|
|
741
|
+
const claimPreHeaderResend = (): boolean =>
|
|
742
|
+
authorizeResendForRecovery("pre-header", "connection-reset", ambiguousResend()).allowed;
|
|
707
743
|
/**
|
|
708
744
|
* Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
|
|
709
745
|
*
|
|
@@ -845,6 +881,7 @@ export async function preparePassthroughExchange(
|
|
|
845
881
|
},
|
|
846
882
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url),
|
|
847
883
|
attempts: remainingTransientSendBudget(transientSendAttempts()), onSendsConsumed: noteTransientSends,
|
|
884
|
+
claimAmbiguousResend: claimPreHeaderResend,
|
|
848
885
|
// The OpenCode Go destination stalls-then-drops inference sends (ambiguous
|
|
849
886
|
// pre-header resets surfacing as refused 429s); its subscription traffic is
|
|
850
887
|
// inference-only, so a bounded reset replay here absorbs the blip instead of
|
|
@@ -949,7 +986,8 @@ export async function preparePassthroughExchange(
|
|
|
949
986
|
route.provider.authMode === "forward")
|
|
950
987
|
.then(adoptObservedResponse);
|
|
951
988
|
},
|
|
952
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
|
|
989
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
|
|
990
|
+
onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
|
|
953
991
|
);
|
|
954
992
|
} catch (err) {
|
|
955
993
|
return { failed: transportFailureResponse(err) };
|
|
@@ -1030,35 +1068,54 @@ export async function preparePassthroughExchange(
|
|
|
1030
1068
|
// every other build site; a replay is exactly when a grown payload reappears.
|
|
1031
1069
|
const replayBodyRefusal = refuseOversizedOutboundBody(request);
|
|
1032
1070
|
if (replayBodyRefusal) return replayBodyRefusal;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1071
|
+
// The replay-dispatched signal is what bounds the rest of this logical request, so it
|
|
1072
|
+
// has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
|
|
1073
|
+
// admission BEFORE calling the executor, so signalling at the call site would spend the
|
|
1074
|
+
// budget even when a rejected pacing wait means nothing reaches the network. Wrapping
|
|
1075
|
+
// the executor moves the signal to the last moment before the send, where a throw from
|
|
1076
|
+
// here on is a genuine transport attempt. The notifier is built once for the whole leg:
|
|
1077
|
+
// it fires on the first dispatch, and a replacement is another send of the same replay
|
|
1078
|
+
// rather than a second one to announce.
|
|
1079
|
+
const oauthReplayExecutor = storedPoolReplayDispatchNotifier(
|
|
1080
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
1081
|
+
nativeControl: nativeResponseControlEligible(route.provider, options.nativeControl) && options.inboundTransport === "websocket" && !options.comboAttempt
|
|
1082
|
+
&& responseEffects.plaintextV2AgentMessageToolNames.size === 0
|
|
1083
|
+
? options.nativeControl : undefined,
|
|
1084
|
+
dispatchOverride: oauthDispatch(request),
|
|
1085
|
+
providerName: route.providerName,
|
|
1086
|
+
modelId: route.modelId,
|
|
1087
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1088
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1089
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
1090
|
+
}),
|
|
1091
|
+
codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
|
|
1092
|
+
);
|
|
1093
|
+
// Routed through the shared helper so an ambiguous reset on THIS leg answers with the
|
|
1094
|
+
// same refusal every other leg gives. A bare fetch here rejected instead, and the
|
|
1095
|
+
// caller's transport-failure path turns a rejection into a client-retryable 502 --
|
|
1096
|
+
// which invites the whole turn to be sent again, on a leg whose first send may already
|
|
1097
|
+
// have run it.
|
|
1098
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
1099
|
+
recovery => {
|
|
1100
|
+
transportState.noteRoutedAttemptSend(passthroughEstimate, recovery ?? "oauth-401");
|
|
1101
|
+
return fetchWithHeaderTimeout(
|
|
1102
|
+
request.url,
|
|
1103
|
+
applyUpstreamRecoveryInit({
|
|
1104
|
+
method: request.method,
|
|
1105
|
+
headers: request.headers,
|
|
1106
|
+
body: request.body,
|
|
1107
|
+
}, recovery),
|
|
1108
|
+
upstream.signal,
|
|
1109
|
+
connectMs,
|
|
1110
|
+
parsed.stream,
|
|
1111
|
+
oauthReplayExecutor,
|
|
1112
|
+
route.provider.authMode === "forward",
|
|
1113
|
+
).then(adoptObservedResponse);
|
|
1114
|
+
},
|
|
1115
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url),
|
|
1116
|
+
attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1117
|
+
onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
|
|
1118
|
+
);
|
|
1062
1119
|
} catch (err) {
|
|
1063
1120
|
return transportFailureResponse(err);
|
|
1064
1121
|
} finally {
|
|
@@ -1182,7 +1239,8 @@ export async function preparePassthroughExchange(
|
|
|
1182
1239
|
route.provider.authMode === "forward")
|
|
1183
1240
|
.then(adoptObservedResponse);
|
|
1184
1241
|
},
|
|
1185
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1242
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1243
|
+
onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
|
|
1186
1244
|
);
|
|
1187
1245
|
} catch (err) {
|
|
1188
1246
|
return transportFailureResponse(err);
|
|
@@ -1314,7 +1372,8 @@ export async function preparePassthroughExchange(
|
|
|
1314
1372
|
route.provider.authMode === "forward")
|
|
1315
1373
|
.then(adoptObservedResponse);
|
|
1316
1374
|
},
|
|
1317
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1375
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1376
|
+
onSendsConsumed: noteTransientSends, claimAmbiguousResend: claimPreHeaderResend },
|
|
1318
1377
|
);
|
|
1319
1378
|
} catch (err) {
|
|
1320
1379
|
return transportFailureResponse(err);
|
|
@@ -1571,6 +1630,91 @@ export async function preparePassthroughExchange(
|
|
|
1571
1630
|
continue passthroughRecovery;
|
|
1572
1631
|
}
|
|
1573
1632
|
}
|
|
1633
|
+
|
|
1634
|
+
// The post-header row of the same table. A native SSE body can die after the head with the
|
|
1635
|
+
// caller having observed nothing, which is the identical question the pre-header helper
|
|
1636
|
+
// answers -- and the identical grant, because both claim from this request's one allowance.
|
|
1637
|
+
const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1638
|
+
const protocolRecoveryCandidate = upstreamResponse.ok
|
|
1639
|
+
&& !!upstreamResponse.body
|
|
1640
|
+
&& !isNonReplayableResponse(upstreamResponse)
|
|
1641
|
+
// The WebSocket transport settles its own ambiguous failures and marks them
|
|
1642
|
+
// non-replayable; re-reading its body here would be a second owner of one exchange.
|
|
1643
|
+
&& !isCodexWsUpstreamResponse(upstreamResponse)
|
|
1644
|
+
// A downstream WebSocket turn that fell back to HTTP must relay response.created
|
|
1645
|
+
// immediately so the client can address the turn and receive explicit control refusal.
|
|
1646
|
+
// This preflight retains that event until output commits, so the two contracts cannot
|
|
1647
|
+
// share one body owner.
|
|
1648
|
+
&& !(options.nativeControl && options.inboundTransport === "websocket")
|
|
1649
|
+
&& ambiguousResend() !== undefined
|
|
1650
|
+
&& remainingTransientSendBudget(transientSendAttempts()) > 0
|
|
1651
|
+
&& (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream));
|
|
1652
|
+
if (protocolRecoveryCandidate) {
|
|
1653
|
+
upstreamResponse = deferProtocolSafeResetRecovery(
|
|
1654
|
+
upstreamResponse,
|
|
1655
|
+
{ model: logCtx.model, provider: logCtx.provider },
|
|
1656
|
+
(error, stage) => refetchAfterProtocolSafeReset(
|
|
1657
|
+
(signal = upstream.signal) => fetchWithHeaderTimeout(
|
|
1658
|
+
request.url,
|
|
1659
|
+
applyUpstreamRecoveryInit({
|
|
1660
|
+
method: request.method,
|
|
1661
|
+
headers: request.headers,
|
|
1662
|
+
body: request.body,
|
|
1663
|
+
}, "connection-reset"),
|
|
1664
|
+
signal,
|
|
1665
|
+
connectMs,
|
|
1666
|
+
true,
|
|
1667
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
1668
|
+
// A replacement HTTP body must not open a fresh WebSocket exchange: the turn it
|
|
1669
|
+
// replaces was an HTTP stream, and a WS create frame is a different send.
|
|
1670
|
+
httpOnly: true,
|
|
1671
|
+
providerName: route.providerName,
|
|
1672
|
+
modelId: route.modelId,
|
|
1673
|
+
dispatchOverride: oauthDispatch(request),
|
|
1674
|
+
beforeDispatch: headers => {
|
|
1675
|
+
if (signal.aborted) throw signal.reason;
|
|
1676
|
+
if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) {
|
|
1677
|
+
throw new Error("Credential selection changed before pre-output stream recovery");
|
|
1678
|
+
}
|
|
1679
|
+
if (isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
1680
|
+
createCodexReserveDispatchGuard(
|
|
1681
|
+
admissionState.authCtx,
|
|
1682
|
+
options.codexAuthPolicy ?? config,
|
|
1683
|
+
route.modelId,
|
|
1684
|
+
options.admission,
|
|
1685
|
+
options.visionDescribeTerminal === true,
|
|
1686
|
+
)?.(headers);
|
|
1687
|
+
}
|
|
1688
|
+
// Recorded with the kind the gate derived its cause from, at the moment the
|
|
1689
|
+
// send actually leaves. One authorisation, one recorded reason, one send.
|
|
1690
|
+
transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset");
|
|
1691
|
+
// Charged to the SAME request counter every other send goes through. The
|
|
1692
|
+
// replacement is bought here rather than by a nested retry helper, so there is
|
|
1693
|
+
// one charge for one send and no per-layer counter to reconcile.
|
|
1694
|
+
noteTransientSends(1);
|
|
1695
|
+
},
|
|
1696
|
+
}),
|
|
1697
|
+
route.provider.authMode === "forward",
|
|
1698
|
+
).then(adoptObservedResponse),
|
|
1699
|
+
error,
|
|
1700
|
+
{
|
|
1701
|
+
abortSignal: upstream.signal,
|
|
1702
|
+
label: safeHostLabel(request.url),
|
|
1703
|
+
attempts: remainingTransientSendBudget(transientSendAttempts()),
|
|
1704
|
+
// The whole decision, including the stage the preflight observed and the grant the
|
|
1705
|
+
// pre-header helper shares. A committed stage refuses here without touching the
|
|
1706
|
+
// allowance, which is what keeps a turn that already emitted output from draining
|
|
1707
|
+
// the replacement a later ambiguous reset would have been entitled to.
|
|
1708
|
+
authorize: () => authorizeResendForRecovery(stage, "connection-reset", ambiguousResend()).allowed,
|
|
1709
|
+
acceptResponse: candidate => {
|
|
1710
|
+
const type = candidate.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1711
|
+
return type.includes("text/event-stream") || (!type && parsed.stream);
|
|
1712
|
+
},
|
|
1713
|
+
},
|
|
1714
|
+
),
|
|
1715
|
+
{ allowMissingContentType: !streamRecoveryContentType && parsed.stream },
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1574
1718
|
break;
|
|
1575
1719
|
}
|
|
1576
1720
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { formatErrorResponse } from "../../bridge";
|
|
2
2
|
import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
applyReplayRefusalClientHeaders,
|
|
5
|
+
isReplayRefusalCode,
|
|
6
|
+
retainReplayRefusal,
|
|
7
|
+
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
8
|
+
} from "../../lib/upstream-retry";
|
|
4
9
|
import {
|
|
5
10
|
resolveClientRetryAfter,
|
|
6
11
|
validateClientRetryAfterHeader,
|
|
@@ -62,6 +67,10 @@ function isReplayRefusalBody(body: string): boolean {
|
|
|
62
67
|
* - a replay refusal this proxy wrote gets none and keeps none: the whole point of the
|
|
63
68
|
* refusal is that the turn may already be running, and the synthetic default for a
|
|
64
69
|
* retryable 429 is a direct instruction to the client to send it a second time
|
|
70
|
+
*
|
|
71
|
+
* A refusal also leaves with the shared no-retry header and the in-process marker, so the
|
|
72
|
+
* verdict survives this re-wrap as a property of the response rather than as a status a later
|
|
73
|
+
* reader would have to guess from.
|
|
65
74
|
*/
|
|
66
75
|
export function formatPassthroughUpstreamError(
|
|
67
76
|
status: number,
|
|
@@ -84,11 +93,10 @@ export function formatPassthroughUpstreamError(
|
|
|
84
93
|
const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
|
|
85
94
|
const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
|
|
86
95
|
const cyberPolicyFailure = isCyberPolicyBody(trimmed);
|
|
96
|
+
const replayRefusal = options?.replayRefusal === true || isReplayRefusalBody(trimmed);
|
|
87
97
|
// Two different reasons to answer with no wait at all, handled the same way: a hard policy
|
|
88
98
|
// block will not become servable, and a refusal we made was never a rate limit.
|
|
89
|
-
const suppressRetryAfter = cyberPolicyFailure
|
|
90
|
-
|| options?.replayRefusal === true
|
|
91
|
-
|| isReplayRefusalBody(trimmed);
|
|
99
|
+
const suppressRetryAfter = cyberPolicyFailure || replayRefusal;
|
|
92
100
|
const resolved = suppressRetryAfter
|
|
93
101
|
? undefined
|
|
94
102
|
: resolveClientRetryAfter({
|
|
@@ -105,7 +113,9 @@ export function formatPassthroughUpstreamError(
|
|
|
105
113
|
&& upstreamRetryAfter !== undefined
|
|
106
114
|
&& originalValid === undefined);
|
|
107
115
|
|
|
108
|
-
|
|
116
|
+
// A refusal always takes the rewriting path: it has a header to add even when the
|
|
117
|
+
// upstream named no wait for it to remove.
|
|
118
|
+
if (!needsSet && !needsDelete && !replayRefusal) {
|
|
109
119
|
return new Response(bodyText, {
|
|
110
120
|
status,
|
|
111
121
|
...(options?.statusText ? { statusText: options.statusText } : {}),
|
|
@@ -118,21 +128,30 @@ export function formatPassthroughUpstreamError(
|
|
|
118
128
|
: new Headers({ "Content-Type": "application/json" });
|
|
119
129
|
if (needsSet) headers.set("Retry-After", resolved!);
|
|
120
130
|
else headers.delete("Retry-After");
|
|
121
|
-
|
|
131
|
+
if (replayRefusal) applyReplayRefusalClientHeaders(headers);
|
|
132
|
+
const rewritten = new Response(bodyText, {
|
|
122
133
|
status,
|
|
123
134
|
...(options?.statusText ? { statusText: options.statusText } : {}),
|
|
124
135
|
headers,
|
|
125
136
|
});
|
|
137
|
+
return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
const response = formatErrorResponse(
|
|
129
141
|
status,
|
|
130
142
|
"upstream_error",
|
|
131
143
|
`Provider error ${status}: (empty body)`,
|
|
132
|
-
|
|
144
|
+
// Provenance is all that is left when the bounded read returned nothing display-safe, and
|
|
145
|
+
// it is enough: the formatter allowlists this code, restates the refusal status and marks
|
|
146
|
+
// the response, so an unreadable refusal reaches the client as the same refusal.
|
|
147
|
+
replayRefusal
|
|
148
|
+
? { code: UPSTREAM_RESET_REPLAY_REFUSED_CODE }
|
|
149
|
+
: resolved !== undefined ? { retryAfter: resolved } : undefined,
|
|
133
150
|
);
|
|
134
151
|
const headers = new Headers(response.headers);
|
|
135
152
|
headers.set("Content-Type", "application/json");
|
|
136
153
|
if (resolved !== undefined) headers.set("Retry-After", resolved);
|
|
137
|
-
|
|
154
|
+
if (replayRefusal) applyReplayRefusalClientHeaders(headers);
|
|
155
|
+
const wrapped = new Response(response.body, { status: response.status, headers });
|
|
156
|
+
return replayRefusal ? retainReplayRefusal(wrapped) : wrapped;
|
|
138
157
|
}
|