@bitkyc08/opencodex 2.55.0 → 2.56.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/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,1476 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ResponsesRequestContext,
|
|
3
|
+
ResponsesAdmissionState,
|
|
4
|
+
PassthroughAdmissionState,
|
|
5
|
+
} from "./core-options";
|
|
6
|
+
import type { PreparedResponsesRequest } from "./request-prepare";
|
|
7
|
+
import type { ResponsesTransport } from "./request-transport";
|
|
8
|
+
import type { ResponsesEffects } from "./response-effects";
|
|
9
|
+
import type { ResponsesSendBudget } from "./request-send-budget";
|
|
10
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
11
|
+
import { codexSafetyBufferingFilterOptions, terminalStatusFromParsed } from "../relay";
|
|
12
|
+
import { imageGenToolCallAliases } from "../responses-image-gen-repair";
|
|
13
|
+
import { rememberResponseState } from "../../responses/state";
|
|
14
|
+
import {
|
|
15
|
+
currentTurnWireToolCatalogBody,
|
|
16
|
+
hasExplicitWireToolCatalog,
|
|
17
|
+
collectDeclaredWireToolNames,
|
|
18
|
+
collectDeclaredBareWireToolNames,
|
|
19
|
+
collectDeclaredNamelessClientCallTypes,
|
|
20
|
+
collectProviderExecutedCallTypes,
|
|
21
|
+
undeclaredToolCallName,
|
|
22
|
+
undeclaredToolCallNameInResponse,
|
|
23
|
+
normalizeDefaultNamespaceInResponse,
|
|
24
|
+
} from "../responses-undeclared-tool-guard";
|
|
25
|
+
import { collectSelfNamedNamespaceScrubAuthorization } from "../responses-self-named-namespace-scrub";
|
|
26
|
+
import type { ProviderExecutedCallType } from "../responses-undeclared-tool-guard";
|
|
27
|
+
import {
|
|
28
|
+
releaseCodexAuthContextProbeLease,
|
|
29
|
+
unwrapUpstreamRetryEvidenceError,
|
|
30
|
+
codexProbeLeaseId,
|
|
31
|
+
codexProbeQuotaScope,
|
|
32
|
+
createCodexReserveDispatchGuard,
|
|
33
|
+
} from "../../codex/auth-context";
|
|
34
|
+
import {
|
|
35
|
+
NamespaceToolCollisionError,
|
|
36
|
+
restoreRoutedNamespaceCalls,
|
|
37
|
+
} from "../../responses/namespace-tool-compat";
|
|
38
|
+
import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
|
|
39
|
+
import { formatErrorResponse } from "../../bridge";
|
|
40
|
+
import { redactSecretString } from "../../lib/redact";
|
|
41
|
+
import {
|
|
42
|
+
collectFunctionCallRepairSchemas,
|
|
43
|
+
repairFunctionCallsInJson,
|
|
44
|
+
} from "../../responses/function-call-compat";
|
|
45
|
+
import type { RoutedNamespaceToolAliases } from "../../responses/namespace-tool-compat";
|
|
46
|
+
import { hasResponsesSnapshotRepair, repairResponsesSnapshotJson } from "../responses-snapshot-repair";
|
|
47
|
+
import { backfillResponsesFieldsJson } from "./responses-field-backfill";
|
|
48
|
+
import type { AdapterRequest } from "../../adapters/base";
|
|
49
|
+
import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
|
|
50
|
+
import { CODE_MODE_EXEC_TOOL_NAME } from "../../types";
|
|
51
|
+
import type { ResponsesTerminalStatus } from "../../bridge";
|
|
52
|
+
import { hasPassiveAccountQuota, recordPassiveAccountQuota } from "../../providers/quota";
|
|
53
|
+
import {
|
|
54
|
+
isMuseSubscriptionUsagePayload,
|
|
55
|
+
parseMuseSubscriptionUsage,
|
|
56
|
+
} from "../../providers/muse-subscription-usage";
|
|
57
|
+
import { restoreMuseToolNames } from "../../responses/muse-tool-name-alias";
|
|
58
|
+
import { restoreRoutedCustomCalls } from "../../responses/custom-tool-compat";
|
|
59
|
+
import { restorePlaintextV2AgentMessageCalls } from "../../responses/plaintext-v2-agent-messages";
|
|
60
|
+
import {
|
|
61
|
+
recordAdapterReasoning,
|
|
62
|
+
recordAdapterTier,
|
|
63
|
+
noteAttemptSend,
|
|
64
|
+
sealRequestAttemptIdentity,
|
|
65
|
+
recordAttemptCredentialSource,
|
|
66
|
+
} from "../request-log";
|
|
67
|
+
import {
|
|
68
|
+
upstreamHostHealthKey,
|
|
69
|
+
normalizeUpstreamHostCircuitThreshold,
|
|
70
|
+
disableUpstreamHostCircuitForKey,
|
|
71
|
+
acquireUpstreamHostAdmission,
|
|
72
|
+
resetUpstreamHostHealth,
|
|
73
|
+
releaseUpstreamHostAdmission,
|
|
74
|
+
recordUpstreamHostFailure,
|
|
75
|
+
} from "../../codex/upstream-host-health";
|
|
76
|
+
import {
|
|
77
|
+
safeOriginLabel,
|
|
78
|
+
fetchWithHeaderTimeout,
|
|
79
|
+
providerFetch,
|
|
80
|
+
safeHostLabel,
|
|
81
|
+
storedPoolReplayDispatchNotifier,
|
|
82
|
+
} from "./fetch-helpers";
|
|
83
|
+
import { clientCancelledResponse } from "./core-errors";
|
|
84
|
+
import {
|
|
85
|
+
upstreamHostCircuitOpenResponse,
|
|
86
|
+
usesCodexForwardPoolAuth,
|
|
87
|
+
codexWsQuotaObserver,
|
|
88
|
+
isFixedCodexAccount,
|
|
89
|
+
shouldRetryCodexPoolAccountModel400,
|
|
90
|
+
shouldRetryCodexPoolAccountQuota,
|
|
91
|
+
shouldRetryCodexPoolAccountTransient,
|
|
92
|
+
retryCodexPoolOnAlternateAccount,
|
|
93
|
+
} from "./core-codex-account";
|
|
94
|
+
import { readCodexWsStage } from "./codex-ws-wire";
|
|
95
|
+
import { linkAbortSignal } from "./core-lifetime";
|
|
96
|
+
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
97
|
+
import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
|
|
98
|
+
import { streamingContextOverflowResponse } from "./context-overflow";
|
|
99
|
+
import {
|
|
100
|
+
SendBudgetExhaustedError,
|
|
101
|
+
fetchWithTransientRetry,
|
|
102
|
+
applyUpstreamRecoveryInit,
|
|
103
|
+
TRANSIENT_RETRY_MAX_ATTEMPTS,
|
|
104
|
+
prepareSameTarget429Wait,
|
|
105
|
+
sleepWithAbort,
|
|
106
|
+
} from "../../lib/upstream-retry";
|
|
107
|
+
import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
|
|
108
|
+
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
109
|
+
import { recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
110
|
+
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
111
|
+
import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
112
|
+
import { rateLimitRetryPolicyFor, rateLimitRetryDelayMs } from "../../providers/key-failover";
|
|
113
|
+
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
114
|
+
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
115
|
+
import { refreshPoolForwardAuth, refreshNativeMainForwardAuth, withClaudeNativeSession } from "./core-auth";
|
|
116
|
+
import { bindRouteReasoningReplayScope } from "./core-replay";
|
|
117
|
+
import type { OAuthAccessSnapshot } from "../../oauth";
|
|
118
|
+
import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
|
|
119
|
+
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
|
|
120
|
+
import {
|
|
121
|
+
GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
|
|
122
|
+
isGenericOAuthFailoverEnabled,
|
|
123
|
+
rotateGenericOAuthAccountOn429,
|
|
124
|
+
failoverAccountSnapshot,
|
|
125
|
+
} from "../../oauth/generic-account-failover";
|
|
126
|
+
import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
|
|
127
|
+
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
|
|
128
|
+
import {
|
|
129
|
+
attemptOpaqueBlobRecovery,
|
|
130
|
+
outboundResponsesBodyCarriesEncryptedFunctionOutput,
|
|
131
|
+
resetStreamedOpaqueBlobLogContext,
|
|
132
|
+
consoleGoUploadRejectionBody,
|
|
133
|
+
CONSOLE_GO_UPLOAD_RETRY_DELAY_MS,
|
|
134
|
+
reasoningEffortRejectionText,
|
|
135
|
+
} from "./core-opaque-recovery";
|
|
136
|
+
import type { RequestLogContext } from "../request-log";
|
|
137
|
+
import { preflightComboStreamResponse } from "./combo-stream-preflight";
|
|
138
|
+
import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors";
|
|
139
|
+
import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
|
|
140
|
+
import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
|
|
141
|
+
|
|
142
|
+
/** Prepares and recovers one native Responses exchange before client commitment. */
|
|
143
|
+
export async function preparePassthroughExchange(
|
|
144
|
+
requestContext: Pick<ResponsesRequestContext, "config" | "logCtx" | "options" | "req">,
|
|
145
|
+
admissionState: ResponsesAdmissionState,
|
|
146
|
+
nativeHostState: PassthroughAdmissionState,
|
|
147
|
+
requestState: Pick<
|
|
148
|
+
PreparedResponsesRequest,
|
|
149
|
+
| "route"
|
|
150
|
+
| "toolBridgeMaps"
|
|
151
|
+
| "parsed"
|
|
152
|
+
| "translatorBudget"
|
|
153
|
+
| "responseStateOptions"
|
|
154
|
+
| "selectedForwardHeaders"
|
|
155
|
+
| "clientRequestedStream"
|
|
156
|
+
| "inboundWire"
|
|
157
|
+
| "substituteMainCredential"
|
|
158
|
+
| "callerAuthHeaders"
|
|
159
|
+
| "subagentFallbackAccountId"
|
|
160
|
+
>,
|
|
161
|
+
transportState: Pick<
|
|
162
|
+
ResponsesTransport,
|
|
163
|
+
| "adapter"
|
|
164
|
+
| "genericFailoverAccountId"
|
|
165
|
+
| "passiveQuotaWriterGeneration"
|
|
166
|
+
| "oauthDispatch"
|
|
167
|
+
| "resolveSelectionAdapter"
|
|
168
|
+
| "isOAuth401ReplayProvider"
|
|
169
|
+
| "sentOAuthSnapshot"
|
|
170
|
+
| "refreshResolvedOAuthSelection"
|
|
171
|
+
| "replayOAuthCredentialSnapshot"
|
|
172
|
+
| "genericFailovers"
|
|
173
|
+
| "applyFailoverSnapshot"
|
|
174
|
+
>,
|
|
175
|
+
responseEffects: Pick<
|
|
176
|
+
ResponsesEffects,
|
|
177
|
+
| "refreshRequestToolAliases"
|
|
178
|
+
| "routedMuseToolNameAliases"
|
|
179
|
+
| "plaintextV2AgentMessageToolNames"
|
|
180
|
+
| "routedNamespaceToolAliases"
|
|
181
|
+
| "plaintextV2AgentMessageAliasedToolNames"
|
|
182
|
+
| "notifyResponseComplete"
|
|
183
|
+
>,
|
|
184
|
+
sendBudgetState: Pick<
|
|
185
|
+
ResponsesSendBudget,
|
|
186
|
+
| "remainingTransientSendBudget"
|
|
187
|
+
| "noteTransientSends"
|
|
188
|
+
| "recoverySendAllowance"
|
|
189
|
+
| "recoveryClassFor"
|
|
190
|
+
| "sendBudgetExhausted"
|
|
191
|
+
| "reserveCredentialHop"
|
|
192
|
+
| "pendingHopPermit"
|
|
193
|
+
| "workflowRootId"
|
|
194
|
+
>,
|
|
195
|
+
) {
|
|
196
|
+
const { config, logCtx, options, req } = requestContext;
|
|
197
|
+
const {
|
|
198
|
+
route,
|
|
199
|
+
toolBridgeMaps,
|
|
200
|
+
parsed,
|
|
201
|
+
translatorBudget,
|
|
202
|
+
responseStateOptions,
|
|
203
|
+
clientRequestedStream,
|
|
204
|
+
inboundWire,
|
|
205
|
+
substituteMainCredential,
|
|
206
|
+
callerAuthHeaders,
|
|
207
|
+
} = requestState;
|
|
208
|
+
const {
|
|
209
|
+
passiveQuotaWriterGeneration,
|
|
210
|
+
oauthDispatch,
|
|
211
|
+
resolveSelectionAdapter,
|
|
212
|
+
isOAuth401ReplayProvider,
|
|
213
|
+
refreshResolvedOAuthSelection,
|
|
214
|
+
applyFailoverSnapshot,
|
|
215
|
+
} = transportState;
|
|
216
|
+
const { refreshRequestToolAliases, notifyResponseComplete } = responseEffects;
|
|
217
|
+
const {
|
|
218
|
+
remainingTransientSendBudget,
|
|
219
|
+
noteTransientSends,
|
|
220
|
+
recoverySendAllowance,
|
|
221
|
+
recoveryClassFor,
|
|
222
|
+
sendBudgetExhausted,
|
|
223
|
+
reserveCredentialHop,
|
|
224
|
+
workflowRootId,
|
|
225
|
+
} = sendBudgetState;
|
|
226
|
+
|
|
227
|
+
const codexSafetyBufferingOptions = isCanonicalOpenAiForwardProvider(route.provider)
|
|
228
|
+
? codexSafetyBufferingFilterOptions(config)
|
|
229
|
+
: undefined;
|
|
230
|
+
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
231
|
+
? new Map<string, { namespace: string; name: string }>()
|
|
232
|
+
: imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
|
|
233
|
+
const routedCustomToolNames = new Set<string>();
|
|
234
|
+
const routedCustomToolRepairNames = new Set<string>();
|
|
235
|
+
const routedToolSearchNames = new Set<string>();
|
|
236
|
+
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
237
|
+
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
238
|
+
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
239
|
+
// way a chained turn keeps its earlier context is the local replay expansion. Record
|
|
240
|
+
// completed passthrough responses (force bypasses Codex's blanket store:false) so the next
|
|
241
|
+
// turn's expansion hits. Never record a body whose own previous_response_id failed to
|
|
242
|
+
// expand: its input is a delta, and storing it would replay a truncated conversation.
|
|
243
|
+
// Compaction turns are excluded: _rawBody still carries the full pre-compaction history and
|
|
244
|
+
// recording it would let a later expansion rehydrate the chain Codex just replaced.
|
|
245
|
+
const passthroughRecordEligible = parsed._compactionRequest !== true
|
|
246
|
+
&& (!parsed.previousResponseId || parsed._previousResponseInputExpanded === true);
|
|
247
|
+
const rememberPassthroughResponse = passthroughRecordEligible
|
|
248
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) =>
|
|
249
|
+
rememberResponseState(parsed._rawBody, response, undefined, responseStateOptions(true))
|
|
250
|
+
: undefined;
|
|
251
|
+
if (parsed.previousResponseId && !parsed._previousResponseInputExpanded) {
|
|
252
|
+
console.warn(
|
|
253
|
+
`[responses] previous_response_id ${parsed.previousResponseId} not found in local replay state `
|
|
254
|
+
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
// Preserve the caller's readable catalog boundary before provider-specific normalization can
|
|
258
|
+
// remove an unsupported final entry (for example xAI cached-only web search).
|
|
259
|
+
const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0;
|
|
260
|
+
const clientToolAuthorizationBody = currentTurnWireToolCatalogBody(
|
|
261
|
+
parsed._rawBody,
|
|
262
|
+
replayedInputPrefixLength,
|
|
263
|
+
);
|
|
264
|
+
const selfNamedNamespaceScrubAuthorization = collectSelfNamedNamespaceScrubAuthorization(
|
|
265
|
+
clientToolAuthorizationBody,
|
|
266
|
+
toolBridgeMaps.bareCustomToolNames,
|
|
267
|
+
toolBridgeMaps.bareFunctionToolNames,
|
|
268
|
+
);
|
|
269
|
+
const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
|
|
270
|
+
const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
|
|
271
|
+
const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody);
|
|
272
|
+
const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
|
|
273
|
+
clientToolAuthorizationBody,
|
|
274
|
+
);
|
|
275
|
+
// Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a
|
|
276
|
+
// declaration alone cannot buy the exemption on some other upstream that never serves it.
|
|
277
|
+
// Provider-executed declarations are authorized from the actual outbound body, after the
|
|
278
|
+
// adapter has applied destination-specific injection and normalization. Client-executed tool
|
|
279
|
+
// authority remains bounded to the caller-owned catalog above.
|
|
280
|
+
const providerExecutedCallTypes = new Set<ProviderExecutedCallType>();
|
|
281
|
+
let request: Awaited<ReturnType<typeof transportState.adapter.buildRequest>>;
|
|
282
|
+
try {
|
|
283
|
+
request = await transportState.adapter.buildRequest(parsed, { headers: requestState.selectedForwardHeaders, translatorBudget });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
286
|
+
// A tool catalog this proxy cannot lower onto one wire namespace is a client input error, and
|
|
287
|
+
// the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing
|
|
288
|
+
// it here escaped every catch up to the Bun handler, so the same request produced an
|
|
289
|
+
// unstructured 500 — and no request log — depending only on whether a rotation ran first.
|
|
290
|
+
// Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the
|
|
291
|
+
// catalog had to drop, so the selector naming it is a client input error, not a 500.
|
|
292
|
+
if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) {
|
|
293
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
|
|
294
|
+
}
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const functionRepairSchemas = isCanonicalOpenAiForwardProvider(route.provider)
|
|
298
|
+
? new Map()
|
|
299
|
+
: collectFunctionCallRepairSchemas(clientToolAuthorizationBody);
|
|
300
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
301
|
+
for (const name of request.convertedRoutedCustomToolNames ?? []) {
|
|
302
|
+
if (
|
|
303
|
+
toolBridgeMaps.freeformToolNames.has(name)
|
|
304
|
+
|| toolBridgeMaps.toolNsMap.get(name)?.freeform === true
|
|
305
|
+
) routedCustomToolNames.add(name);
|
|
306
|
+
}
|
|
307
|
+
for (const name of request.routedCustomToolRepairNames ?? []) {
|
|
308
|
+
if (
|
|
309
|
+
toolBridgeMaps.freeformToolNames.has(name)
|
|
310
|
+
|| toolBridgeMaps.toolNsMap.get(name)?.freeform === true
|
|
311
|
+
) routedCustomToolRepairNames.add(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const name of request.convertedRoutedToolSearchNames ?? []) {
|
|
315
|
+
// The adapter already keeps this set empty when tool_choice forbids the private search.
|
|
316
|
+
// Its wire name may be collision-aliased, so comparing it to the caller-facing name here
|
|
317
|
+
// would incorrectly disable restoration for the exact ambiguous-name case the alias fixes.
|
|
318
|
+
routedToolSearchNames.add(name);
|
|
319
|
+
}
|
|
320
|
+
refreshRequestToolAliases(request);
|
|
321
|
+
// #1700: the bridged paths refuse a call to a tool the request never declared
|
|
322
|
+
// (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed
|
|
323
|
+
// provider's top-level `apply_patch` — which under Codex code mode exists only as a nested
|
|
324
|
+
// `tools.apply_patch(...)` helper inside `exec`, never as a wire tool — reached Codex as a
|
|
325
|
+
// call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
|
|
326
|
+
// Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
|
|
327
|
+
// routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
|
|
328
|
+
// The guard needs a catalog to compare against, so it stands down when the request omits one.
|
|
329
|
+
// An explicit empty catalog is still authoritative: it declares that no client tools may be
|
|
330
|
+
// called. A passthrough request can legitimately omit `tools` entirely and still receive a call
|
|
331
|
+
// the client understands — `tests/providers/github-copilot/github-copilot-stream-contract.test.ts` sends
|
|
332
|
+
// `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
|
|
333
|
+
// `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
|
|
334
|
+
// too because the proxy cannot establish the caller's declared authorization boundary.
|
|
335
|
+
const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
|
|
336
|
+
try {
|
|
337
|
+
const body = JSON.parse(bodyText) as unknown;
|
|
338
|
+
return body && typeof body === "object" && !Array.isArray(body)
|
|
339
|
+
? body as Record<string, unknown>
|
|
340
|
+
: undefined;
|
|
341
|
+
} catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
let outboundRequestBody: Record<string, unknown> | undefined;
|
|
346
|
+
const declaredWireToolNames = new Set<string>();
|
|
347
|
+
const declaredBareWireToolNames = new Set<string>();
|
|
348
|
+
const declaredNamelessClientCallTypes = new Set<string>();
|
|
349
|
+
// `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one
|
|
350
|
+
// namespaced tool through a bare tool_choice. Restore that request-bounded identity before
|
|
351
|
+
// authorization checks instead of admitting the bare name into the declared set: for `exec`,
|
|
352
|
+
// the latter would also authorize the unrelated code-mode helper names.
|
|
353
|
+
const authorizedBareNamespaceToolAliases: RoutedNamespaceToolAliases = new Map(
|
|
354
|
+
[...toolBridgeMaps.toolNsMap].flatMap(([alias, identity]) =>
|
|
355
|
+
alias === identity.name
|
|
356
|
+
? [[alias, {
|
|
357
|
+
namespace: identity.namespace,
|
|
358
|
+
name: identity.name,
|
|
359
|
+
kind: identity.freeform ? "custom" as const : "function" as const,
|
|
360
|
+
}] as const]
|
|
361
|
+
: []
|
|
362
|
+
),
|
|
363
|
+
);
|
|
364
|
+
const restoreAuthorizedBareNamespaceToolCalls = (value: unknown): unknown =>
|
|
365
|
+
restoreRoutedNamespaceCalls(value, authorizedBareNamespaceToolAliases).value;
|
|
366
|
+
const normalizeFunctionCompletionJson = (text: string): string => {
|
|
367
|
+
const snapshot = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)
|
|
368
|
+
? repairResponsesSnapshotJson(text, outboundRequestBody)
|
|
369
|
+
: text;
|
|
370
|
+
// Sparse gateways need completion status inferred before schema repair can
|
|
371
|
+
// distinguish completed arguments from in-progress placeholders.
|
|
372
|
+
return repairFunctionCallsInJson(backfillResponsesFieldsJson(snapshot), functionRepairSchemas);
|
|
373
|
+
};
|
|
374
|
+
let undeclaredToolGuardActive = false;
|
|
375
|
+
const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => {
|
|
376
|
+
outboundRequestBody = parseOutboundRequestBody(builtRequest.body);
|
|
377
|
+
providerExecutedCallTypes.clear();
|
|
378
|
+
if (isXaiResponsesDestination(route.provider)) {
|
|
379
|
+
// Preserve the caller-declared authorization recognized by the original classifier, then
|
|
380
|
+
// add adapter-injected declarations from the actual current-turn outbound catalog.
|
|
381
|
+
for (const callType of collectProviderExecutedCallTypes(clientToolAuthorizationBody)) {
|
|
382
|
+
providerExecutedCallTypes.add(callType);
|
|
383
|
+
}
|
|
384
|
+
const currentOutboundCatalog = currentTurnWireToolCatalogBody(
|
|
385
|
+
outboundRequestBody,
|
|
386
|
+
replayedInputPrefixLength,
|
|
387
|
+
);
|
|
388
|
+
for (const callType of collectProviderExecutedCallTypes(currentOutboundCatalog)) {
|
|
389
|
+
providerExecutedCallTypes.add(callType);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
declaredWireToolNames.clear();
|
|
393
|
+
// With no replay prefix the full outbound body belongs to this turn and its normalized
|
|
394
|
+
// aliases are authoritative. A continuation's outbound body still contains historical
|
|
395
|
+
// catalogs (and may promote historical tool-search definitions), so it can never widen the
|
|
396
|
+
// current caller snapshot captured above.
|
|
397
|
+
declaredBareWireToolNames.clear();
|
|
398
|
+
if (replayedInputPrefixLength === 0) {
|
|
399
|
+
for (const name of collectDeclaredWireToolNames(outboundRequestBody)) {
|
|
400
|
+
declaredWireToolNames.add(name);
|
|
401
|
+
}
|
|
402
|
+
for (const name of collectDeclaredBareWireToolNames(outboundRequestBody)) {
|
|
403
|
+
declaredBareWireToolNames.add(name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name);
|
|
407
|
+
for (const name of clientDeclaredBareWireToolNames) declaredBareWireToolNames.add(name);
|
|
408
|
+
declaredNamelessClientCallTypes.clear();
|
|
409
|
+
if (replayedInputPrefixLength === 0) {
|
|
410
|
+
for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) {
|
|
411
|
+
declaredNamelessClientCallTypes.add(callType);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const callType of clientDeclaredNamelessCallTypes) {
|
|
415
|
+
declaredNamelessClientCallTypes.add(callType);
|
|
416
|
+
}
|
|
417
|
+
// On an ordinary request these maps capture caller-catalog identities that normalization may
|
|
418
|
+
// replace on the outbound wire (for example a client image tool becoming hosted). On replay,
|
|
419
|
+
// however, the parsed maps also contain historical catalog entries, so only the bounded
|
|
420
|
+
// current-turn wire snapshot above may authorize a call.
|
|
421
|
+
if (replayedInputPrefixLength === 0) {
|
|
422
|
+
for (const name of toolBridgeMaps.declaredToolNames) {
|
|
423
|
+
// `buildToolBridgeMaps` also aliases a namespaced tool under its bare name when the
|
|
424
|
+
// caller's `tool_choice` selected it unambiguously, which the bridge needs to route the
|
|
425
|
+
// call back. For `exec` alone that alias would also switch on nested-helper
|
|
426
|
+
// normalization and re-authorize `exec_command`/`shell_command`/`apply_patch`/`view_image`, so it is
|
|
427
|
+
// admitted here only when the caller's own catalog declared a bare `exec`. Selecting an
|
|
428
|
+
// MCP `exec` is not a declaration of the code-mode shell tool.
|
|
429
|
+
if (
|
|
430
|
+
name === CODE_MODE_EXEC_TOOL_NAME
|
|
431
|
+
&& !clientDeclaredWireToolNames.has(CODE_MODE_EXEC_TOOL_NAME)
|
|
432
|
+
) continue;
|
|
433
|
+
declaredWireToolNames.add(name);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
undeclaredToolGuardActive = (
|
|
437
|
+
declaredWireToolNames.size > 0
|
|
438
|
+
|| clientDeclaredNamelessCallTypes.size > 0
|
|
439
|
+
|| clientExplicitWireToolCatalog
|
|
440
|
+
) && route.provider.authMode !== "forward";
|
|
441
|
+
};
|
|
442
|
+
refreshUndeclaredToolGuard(request);
|
|
443
|
+
// A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
|
|
444
|
+
// untouched upstream stream, so it can still observe a `response.completed` the client never
|
|
445
|
+
// received; checking the payload itself rather than a flag shared with the client relay keeps
|
|
446
|
+
// this free of tee ordering races.
|
|
447
|
+
//
|
|
448
|
+
// Checking only the terminal snapshot is not enough. An upstream can announce the undeclared
|
|
449
|
+
// call in `response.output_item.added`, which trips the client guard, and then close with a
|
|
450
|
+
// `response.completed` whose `output` is empty. The client gets `response.failed`, the terminal
|
|
451
|
+
// check sees nothing undeclared, and the refused turn enters continuation state anyway. So the
|
|
452
|
+
// rejection is sticky for the whole turn, set from every parsed payload on the inspection side.
|
|
453
|
+
let inspectionSawUndeclaredTool = false;
|
|
454
|
+
let inspectedTerminal: ResponsesTerminalStatus | null = null;
|
|
455
|
+
let inspectedCompletionSeen = false;
|
|
456
|
+
let firstTerminalAllowsRecall = false;
|
|
457
|
+
const passiveQuotaObserved = hasPassiveAccountQuota(route.providerName)
|
|
458
|
+
&& route.provider.authMode === "oauth";
|
|
459
|
+
const noteInspectedPayload = (payload: unknown) => {
|
|
460
|
+
// First terminal stays authoritative even in metadata-only inspection, which
|
|
461
|
+
// intentionally continues parsing after a failed/incomplete terminal.
|
|
462
|
+
const terminal = terminalStatusFromParsed(payload);
|
|
463
|
+
if (inspectedTerminal === null && terminal !== null) {
|
|
464
|
+
inspectedTerminal = terminal;
|
|
465
|
+
// The client boundary accepts a terminal by event type, even without a
|
|
466
|
+
// response object. Such a terminal must permanently decline recall.
|
|
467
|
+
if (terminal === "completed" && payload && typeof payload === "object"
|
|
468
|
+
&& "response" in payload && payload.response && typeof payload.response === "object"
|
|
469
|
+
&& !Array.isArray(payload.response) && "model" in payload.response) {
|
|
470
|
+
firstTerminalAllowsRecall = typeof payload.response.model === "string"
|
|
471
|
+
&& payload.response.model.trim().length > 0;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Meta reports subscription usage ONLY as an in-stream event; there is no endpoint
|
|
475
|
+
// to poll (003 §E probed 17 paths, all 404). Observed here rather than behind a
|
|
476
|
+
// dedicated inspector handler because onParsedPayload already reaches every
|
|
477
|
+
// passthrough shape -- eager relay and both tee consumers -- through this one
|
|
478
|
+
// function.
|
|
479
|
+
//
|
|
480
|
+
// Placed BEFORE the undeclared-tool early return below, which is load-bearing: that
|
|
481
|
+
// guard latches for the rest of the turn once it fires, and a turn that tripped it
|
|
482
|
+
// still legitimately reports usage.
|
|
483
|
+
if (passiveQuotaObserved && isMuseSubscriptionUsagePayload(payload)) {
|
|
484
|
+
const quota = parseMuseSubscriptionUsage(payload);
|
|
485
|
+
// Read at EVENT time, not at handler construction: failover rebinds this, and the
|
|
486
|
+
// quota belongs to the account that actually served the turn.
|
|
487
|
+
const servingAccountId = transportState.genericFailoverAccountId;
|
|
488
|
+
if (quota && servingAccountId) {
|
|
489
|
+
recordPassiveAccountQuota(route.providerName, servingAccountId, quota, passiveQuotaWriterGeneration);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth
|
|
493
|
+
// provider) every name looks undeclared, and flipping this would stop recording continuation
|
|
494
|
+
// state for exactly the passthrough traffic the guard deliberately stands down for.
|
|
495
|
+
if (undeclaredToolGuardActive && !inspectionSawUndeclaredTool && undeclaredToolCallName(
|
|
496
|
+
restoreAuthorizedBareNamespaceToolCalls(
|
|
497
|
+
restoreMuseToolNames(payload, responseEffects.routedMuseToolNameAliases).value,
|
|
498
|
+
),
|
|
499
|
+
declaredWireToolNames,
|
|
500
|
+
declaredNamelessClientCallTypes,
|
|
501
|
+
providerExecutedCallTypes,
|
|
502
|
+
declaredBareWireToolNames,
|
|
503
|
+
) !== undefined) {
|
|
504
|
+
inspectionSawUndeclaredTool = true;
|
|
505
|
+
}
|
|
506
|
+
// The snapshot callback opts the inspector into output reconstruction. Compaction
|
|
507
|
+
// has no continuation cache, so use the parsed terminal here without adding retention.
|
|
508
|
+
if (responseEffects.plaintextV2AgentMessageToolNames.size === 0 && !rememberPassthroughResponse && payload && typeof payload === "object"
|
|
509
|
+
&& "type" in payload && payload.type === "response.completed"
|
|
510
|
+
&& "response" in payload && payload.response && typeof payload.response === "object"
|
|
511
|
+
&& !Array.isArray(payload.response)) {
|
|
512
|
+
rememberPassthroughResponseChecked(payload.response as Record<string, unknown>);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
const rememberPassthroughResponseChecked = (
|
|
516
|
+
response: { id?: unknown; output?: unknown; status?: unknown; model?: unknown },
|
|
517
|
+
) => {
|
|
518
|
+
if (inspectionSawUndeclaredTool) return;
|
|
519
|
+
const restored = restoreRoutedCustomCalls(
|
|
520
|
+
restoreAuthorizedBareNamespaceToolCalls(
|
|
521
|
+
restoreRoutedNamespaceCalls(
|
|
522
|
+
restoreMuseToolNames(response, responseEffects.routedMuseToolNameAliases).value,
|
|
523
|
+
responseEffects.routedNamespaceToolAliases,
|
|
524
|
+
).value,
|
|
525
|
+
),
|
|
526
|
+
routedCustomToolNames,
|
|
527
|
+
routedCustomToolRepairNames,
|
|
528
|
+
declaredWireToolNames,
|
|
529
|
+
).value;
|
|
530
|
+
const normalizedResponse = (functionRepairSchemas.size > 0
|
|
531
|
+
? JSON.parse(normalizeFunctionCompletionJson(JSON.stringify(restored)))
|
|
532
|
+
: restored) as { id?: unknown; output?: unknown; status?: unknown };
|
|
533
|
+
const plaintextRestore = restorePlaintextV2AgentMessageCalls(
|
|
534
|
+
normalizedResponse, responseEffects.plaintextV2AgentMessageToolNames, responseEffects.plaintextV2AgentMessageAliasedToolNames,
|
|
535
|
+
);
|
|
536
|
+
if (plaintextRestore.overflowed) return;
|
|
537
|
+
const restoredResponse = plaintextRestore.value as typeof normalizedResponse;
|
|
538
|
+
// Replay overlap compares the items the client echoes, including visible reasoning shape.
|
|
539
|
+
const replayResponse = restoredResponse;
|
|
540
|
+
if (
|
|
541
|
+
undeclaredToolGuardActive
|
|
542
|
+
&& undeclaredToolCallNameInResponse(
|
|
543
|
+
restoredResponse,
|
|
544
|
+
declaredWireToolNames,
|
|
545
|
+
declaredNamelessClientCallTypes,
|
|
546
|
+
providerExecutedCallTypes,
|
|
547
|
+
declaredBareWireToolNames,
|
|
548
|
+
) !== undefined
|
|
549
|
+
) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const normalizedReplayResponse = (undeclaredToolGuardActive
|
|
553
|
+
? normalizeDefaultNamespaceInResponse(
|
|
554
|
+
replayResponse,
|
|
555
|
+
declaredWireToolNames,
|
|
556
|
+
declaredBareWireToolNames,
|
|
557
|
+
).value
|
|
558
|
+
: replayResponse) as typeof replayResponse;
|
|
559
|
+
rememberPassthroughResponse?.(normalizedReplayResponse);
|
|
560
|
+
const firstCompletion = !inspectedCompletionSeen;
|
|
561
|
+
inspectedCompletionSeen = true;
|
|
562
|
+
if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) {
|
|
563
|
+
// A model-less first completion permanently declines recall; later terminal
|
|
564
|
+
// frames are hidden by the client boundary and cannot supply its identity.
|
|
565
|
+
// Native inspection sees the pre-rewrite model. Only an actual terminal
|
|
566
|
+
// model can seed recall; an absent model never falls back to the pick.
|
|
567
|
+
if (typeof response.model === "string" && response.model.trim()) {
|
|
568
|
+
notifyResponseComplete({
|
|
569
|
+
status: response.status,
|
|
570
|
+
model: parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
|
|
571
|
+
? parsed._responseModelId : response.model,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
recordAdapterReasoning(logCtx, request);
|
|
577
|
+
recordAdapterTier(logCtx, request);
|
|
578
|
+
const actualHostKey = upstreamHostHealthKey(
|
|
579
|
+
route.providerName,
|
|
580
|
+
safeOriginLabel(request.url),
|
|
581
|
+
);
|
|
582
|
+
const hostKey = route.provider.authMode === "forward"
|
|
583
|
+
? actualHostKey
|
|
584
|
+
: null;
|
|
585
|
+
const hostCircuitEnabled = hostKey !== null
|
|
586
|
+
&& normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0;
|
|
587
|
+
if (hostKey !== null && !hostCircuitEnabled) {
|
|
588
|
+
disableUpstreamHostCircuitForKey(actualHostKey);
|
|
589
|
+
}
|
|
590
|
+
if (nativeHostState.lease && nativeHostState.lease.key !== hostKey) {
|
|
591
|
+
return formatErrorResponse(502, "upstream_error", "Provider host changed after circuit admission");
|
|
592
|
+
}
|
|
593
|
+
if (options.abortSignal?.aborted) {
|
|
594
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
595
|
+
return clientCancelledResponse();
|
|
596
|
+
}
|
|
597
|
+
if (!nativeHostState.lease && hostCircuitEnabled) {
|
|
598
|
+
const admission = acquireUpstreamHostAdmission(
|
|
599
|
+
hostKey!,
|
|
600
|
+
config.upstreamHostCircuitThreshold,
|
|
601
|
+
);
|
|
602
|
+
if (admission.kind === "blocked") {
|
|
603
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
604
|
+
return upstreamHostCircuitOpenResponse(admission.retryAfterSeconds);
|
|
605
|
+
}
|
|
606
|
+
nativeHostState.lease = admission.lease;
|
|
607
|
+
}
|
|
608
|
+
const settleObservedHostResponse = (): void => {
|
|
609
|
+
if (hostCircuitEnabled) {
|
|
610
|
+
resetUpstreamHostHealth(actualHostKey, nativeHostState.lease);
|
|
611
|
+
} else {
|
|
612
|
+
resetUpstreamHostHealth(actualHostKey);
|
|
613
|
+
}
|
|
614
|
+
nativeHostState.lease = null;
|
|
615
|
+
};
|
|
616
|
+
/**
|
|
617
|
+
* #4191: a Codex WS exchange pins its content-free stage record on the
|
|
618
|
+
* Response it resolves (markCodexWsStage). Adopting the record here, at
|
|
619
|
+
* the single funnel every physical upstream response passes through,
|
|
620
|
+
* binds it to the attempt that actually served it — including the 502/504
|
|
621
|
+
* pre-response JSON settles that never reach the SSE relay.
|
|
622
|
+
*/
|
|
623
|
+
const adoptCodexWsStage = (response: Response): void => {
|
|
624
|
+
const stage = readCodexWsStage(response);
|
|
625
|
+
if (stage && logCtx.activeAttempt) logCtx.activeAttempt.codexWsStage = stage;
|
|
626
|
+
};
|
|
627
|
+
const adoptObservedResponse = <T extends Response>(response: T): T => {
|
|
628
|
+
settleObservedHostResponse();
|
|
629
|
+
adoptCodexWsStage(response);
|
|
630
|
+
return response;
|
|
631
|
+
};
|
|
632
|
+
let passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
633
|
+
? request.usageLog.inputTokens
|
|
634
|
+
: undefined;
|
|
635
|
+
if (passthroughEstimate !== undefined) {
|
|
636
|
+
logCtx.usageLogInputTokens = passthroughEstimate;
|
|
637
|
+
}
|
|
638
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
639
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
640
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
641
|
+
const upstream = new AbortController();
|
|
642
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
643
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
644
|
+
let upstreamResponse: Response;
|
|
645
|
+
/**
|
|
646
|
+
* Refuse a built body that exceeds the operator's configured ceiling, before it is sent.
|
|
647
|
+
*
|
|
648
|
+
* Unconfigured this measures nothing and returns undefined, so an unset proxy behaves
|
|
649
|
+
* exactly as it does today. Runs at every point a body is built or rebuilt, because a
|
|
650
|
+
* rebuild can produce a payload the initial check never saw.
|
|
651
|
+
*/
|
|
652
|
+
const refuseOversizedOutboundBody = (
|
|
653
|
+
builtRequest: AdapterRequest,
|
|
654
|
+
refusalAuthCtx: CodexAuthContext = admissionState.authCtx,
|
|
655
|
+
): Response | undefined => {
|
|
656
|
+
const result = checkOutboundBodySize(builtRequest.body, config.maxUpstreamBodyBytes);
|
|
657
|
+
if (result.admitted) return undefined;
|
|
658
|
+
|
|
659
|
+
// This returns before the surrounding fetch/finally owns the observation, so release
|
|
660
|
+
// it here or one refused body holds translator budget for the process lifetime.
|
|
661
|
+
builtRequest.releaseBodyObservation?.();
|
|
662
|
+
upstream.abort();
|
|
663
|
+
releaseUpstreamHostAdmission(nativeHostState.lease);
|
|
664
|
+
nativeHostState.lease = null;
|
|
665
|
+
releaseCodexAuthContextProbeLease(refusalAuthCtx);
|
|
666
|
+
logCtx.errorCode = "outbound_body_too_large";
|
|
667
|
+
console.warn(
|
|
668
|
+
`[responses] refused an oversized outbound body: bytes=${result.bytes} limit=${result.limit} `
|
|
669
|
+
+ `input_images=${result.imageCount} image_bytes=${result.imageBytes} `
|
|
670
|
+
+ `model=${JSON.stringify(parsed.modelId)}`,
|
|
671
|
+
);
|
|
672
|
+
// A streaming client treats HTTP 413 as a retryable transport error and resends the same
|
|
673
|
+
// oversized body — the reconnect loop #3177 exists to stop. Terminal overflow is the
|
|
674
|
+
// honest shape, and it is what the upstream-413 path already returns.
|
|
675
|
+
if (clientRequestedStream) {
|
|
676
|
+
return streamingContextOverflowResponse(
|
|
677
|
+
parsed._responseModelId ?? parsed.modelId,
|
|
678
|
+
translatorBudget,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return formatErrorResponse(
|
|
682
|
+
413,
|
|
683
|
+
"outbound_body_too_large",
|
|
684
|
+
describeOutboundBodyRefusal(result),
|
|
685
|
+
);
|
|
686
|
+
};
|
|
687
|
+
const transportFailureResponse = (err: unknown): Response => {
|
|
688
|
+
upstream.abort();
|
|
689
|
+
if (options.abortSignal?.aborted) {
|
|
690
|
+
releaseUpstreamHostAdmission(nativeHostState.lease);
|
|
691
|
+
nativeHostState.lease = null;
|
|
692
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
693
|
+
return clientCancelledResponse();
|
|
694
|
+
}
|
|
695
|
+
// A budget refusal is a proxy decision, not an upstream fault. Reporting it as
|
|
696
|
+
// 502 upstream_error would blame the provider for a limit this process applied, and
|
|
697
|
+
// would record a fake reachability failure against the account's health.
|
|
698
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
699
|
+
releaseUpstreamHostAdmission(nativeHostState.lease);
|
|
700
|
+
nativeHostState.lease = null;
|
|
701
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
702
|
+
return formatErrorResponse(429, "request_send_budget_exhausted", err.message);
|
|
703
|
+
}
|
|
704
|
+
const localRefusal = mapCodexAuthContextErrorToResponse(unwrapUpstreamRetryEvidenceError(err), {
|
|
705
|
+
now: Date.now(), accountSelector: route.codexAccountNamespace,
|
|
706
|
+
});
|
|
707
|
+
if (localRefusal) {
|
|
708
|
+
releaseUpstreamHostAdmission(nativeHostState.lease);
|
|
709
|
+
nativeHostState.lease = null;
|
|
710
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
711
|
+
return localRefusal;
|
|
712
|
+
}
|
|
713
|
+
const outcome = classifyTransportFailureKind(err);
|
|
714
|
+
// Host-level evidence stands regardless of pool membership: a direct
|
|
715
|
+
// forward send has no pool accounting, but the reachability failure is
|
|
716
|
+
// still host-wide, not account evidence (#914 review).
|
|
717
|
+
if (outcome === "connect_neutral") {
|
|
718
|
+
if (hostCircuitEnabled) {
|
|
719
|
+
recordUpstreamHostFailure(actualHostKey, {
|
|
720
|
+
code: transportErrorCode(err),
|
|
721
|
+
threshold: config.upstreamHostCircuitThreshold,
|
|
722
|
+
lease: nativeHostState.lease,
|
|
723
|
+
});
|
|
724
|
+
} else {
|
|
725
|
+
recordUpstreamHostFailure(actualHostKey, { code: transportErrorCode(err) });
|
|
726
|
+
}
|
|
727
|
+
nativeHostState.lease = null;
|
|
728
|
+
} else {
|
|
729
|
+
releaseUpstreamHostAdmission(nativeHostState.lease);
|
|
730
|
+
nativeHostState.lease = null;
|
|
731
|
+
}
|
|
732
|
+
if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
|
|
733
|
+
recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, outcome, {
|
|
734
|
+
threadId: admissionState.authCtx.affinityKey,
|
|
735
|
+
fixedAccount: admissionState.authCtx.fixedAccount,
|
|
736
|
+
modelId: route.modelId,
|
|
737
|
+
probeLeaseId: codexProbeLeaseId(admissionState.authCtx),
|
|
738
|
+
probeQuotaScope: codexProbeQuotaScope(admissionState.authCtx),
|
|
739
|
+
writerGeneration: admissionState.authCtx.writerGeneration,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
const msg = outcome === "timeout"
|
|
743
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
744
|
+
: describeUpstreamConnectFailure(err, connectMs);
|
|
745
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
746
|
+
};
|
|
747
|
+
const initialBodyRefusal = refuseOversizedOutboundBody(request);
|
|
748
|
+
if (initialBodyRefusal) return initialBodyRefusal;
|
|
749
|
+
try {
|
|
750
|
+
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
751
|
+
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
752
|
+
// Body is a replayable string; nothing has streamed to the client yet.
|
|
753
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
754
|
+
recovery => {
|
|
755
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
|
|
756
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
757
|
+
method: request.method,
|
|
758
|
+
headers: request.headers,
|
|
759
|
+
body: request.body,
|
|
760
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
761
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
762
|
+
dispatchOverride: oauthDispatch(request),
|
|
763
|
+
providerName: route.providerName,
|
|
764
|
+
modelId: route.modelId,
|
|
765
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
766
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
767
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
768
|
+
}),
|
|
769
|
+
route.provider.authMode === "forward")
|
|
770
|
+
// Every real attempt response — including an intermediate 5xx the
|
|
771
|
+
// retry wrapper replaces — proves the host was reached (#914 review).
|
|
772
|
+
.then(adoptObservedResponse);
|
|
773
|
+
},
|
|
774
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
|
|
775
|
+
);
|
|
776
|
+
} catch (err) {
|
|
777
|
+
return transportFailureResponse(err);
|
|
778
|
+
} finally {
|
|
779
|
+
request.releaseBodyObservation?.();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const opaqueBlobRecoveryGuard: OpaqueBlobRecoveryGuard = { attempted: false };
|
|
783
|
+
// At most one reasoning-effort downgrade per request.
|
|
784
|
+
const reasoningEffortDowngradeGuard: { attempted: boolean } = { attempted: false };
|
|
785
|
+
let oauth401ReplayAttempted = false;
|
|
786
|
+
let codex401ReplayKind: "main" | "stored" | null = null;
|
|
787
|
+
// Console Go answers a transient 400 "Invalid upload request." for bodies it accepts
|
|
788
|
+
// moments later; at most one byte-identical replay is allowed per request.
|
|
789
|
+
const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
|
|
790
|
+
const rateLimitPolicy = rateLimitRetryPolicyFor(route.provider);
|
|
791
|
+
let rateLimitRetries = 0;
|
|
792
|
+
const rebuildAndRefetch = async (
|
|
793
|
+
recovery: AttemptRecoveryKind,
|
|
794
|
+
): Promise<Response | { failed: Response }> => {
|
|
795
|
+
const retryAdapter = resolveSelectionAdapter(
|
|
796
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
797
|
+
config.cacheRetention,
|
|
798
|
+
);
|
|
799
|
+
if (!("passthrough" in retryAdapter) || !retryAdapter.passthrough) {
|
|
800
|
+
upstream.abort();
|
|
801
|
+
return { failed: formatErrorResponse(502, "upstream_error", "Recovery changed the provider wire unexpectedly") };
|
|
802
|
+
}
|
|
803
|
+
try {
|
|
804
|
+
if (recovery !== "console-go-upload-retry") {
|
|
805
|
+
request = await retryAdapter.buildRequest(parsed, {
|
|
806
|
+
headers: requestState.selectedForwardHeaders,
|
|
807
|
+
translatorBudget,
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
refreshRequestToolAliases(request);
|
|
811
|
+
recordAdapterReasoning(logCtx, request);
|
|
812
|
+
recordAdapterTier(logCtx, request);
|
|
813
|
+
} catch (err) {
|
|
814
|
+
upstream.abort();
|
|
815
|
+
if (options.abortSignal?.aborted) return { failed: clientCancelledResponse() };
|
|
816
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
817
|
+
return { failed: formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)) };
|
|
818
|
+
}
|
|
819
|
+
passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
820
|
+
? request.usageLog.inputTokens
|
|
821
|
+
: undefined;
|
|
822
|
+
if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate;
|
|
823
|
+
refreshUndeclaredToolGuard(request);
|
|
824
|
+
logCtx.providerAdapter = retryAdapter.name;
|
|
825
|
+
sealRequestAttemptIdentity(
|
|
826
|
+
logCtx.activeAttempt,
|
|
827
|
+
logCtx.provider,
|
|
828
|
+
retryAdapter.name,
|
|
829
|
+
logCtx.accountLogLabel,
|
|
830
|
+
);
|
|
831
|
+
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, retryAdapter.name);
|
|
832
|
+
const rebuiltBodyRefusal = refuseOversizedOutboundBody(request);
|
|
833
|
+
if (rebuiltBodyRefusal) return { failed: rebuiltBodyRefusal };
|
|
834
|
+
// The base allowance is spent first; once it is gone this leg may still draw the one
|
|
835
|
+
// shared final-recovery reserve, which is what keeps a validated sanitized rebuild
|
|
836
|
+
// after a 5xx streak alive at four total sends instead of dying at three. Reserved
|
|
837
|
+
// outside the try so the finally can hand it back if the leg never reached its send.
|
|
838
|
+
const allowance = recoverySendAllowance(
|
|
839
|
+
TRANSIENT_RETRY_MAX_ATTEMPTS,
|
|
840
|
+
recoveryClassFor(recovery),
|
|
841
|
+
`${route.providerName}|${route.modelId}|${recovery}`,
|
|
842
|
+
);
|
|
843
|
+
try {
|
|
844
|
+
return await fetchWithTransientRetry(
|
|
845
|
+
innerRecovery => {
|
|
846
|
+
// Gated on the return, not fire-and-forget: a consumed permit means this leg
|
|
847
|
+
// already sent once, and letting the second call through would be a free send.
|
|
848
|
+
if (allowance.permit && !allowance.permit.use()) {
|
|
849
|
+
throw new SendBudgetExhaustedError(safeHostLabel(request.url));
|
|
850
|
+
}
|
|
851
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, innerRecovery ?? recovery);
|
|
852
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
853
|
+
method: request.method,
|
|
854
|
+
headers: request.headers,
|
|
855
|
+
body: request.body,
|
|
856
|
+
}, innerRecovery), upstream.signal, connectMs, parsed.stream,
|
|
857
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
858
|
+
dispatchOverride: oauthDispatch(request),
|
|
859
|
+
providerName: route.providerName,
|
|
860
|
+
modelId: route.modelId,
|
|
861
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
862
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
863
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
864
|
+
}),
|
|
865
|
+
route.provider.authMode === "forward")
|
|
866
|
+
.then(adoptObservedResponse);
|
|
867
|
+
},
|
|
868
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts, onSendsConsumed: noteTransientSends },
|
|
869
|
+
);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
return { failed: transportFailureResponse(err) };
|
|
872
|
+
} finally {
|
|
873
|
+
// A no-op once the permit was used or once onSendsConsumed settled it; it only refunds
|
|
874
|
+
// a reservation whose send never happened.
|
|
875
|
+
allowance.permit?.release();
|
|
876
|
+
request.releaseBodyObservation?.();
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// Keep recovery kinds in sync with the generic `recovery:` loop below.
|
|
881
|
+
passthroughRecovery: for (;;) {
|
|
882
|
+
|
|
883
|
+
if (
|
|
884
|
+
upstreamResponse.status === 401
|
|
885
|
+
&& (admissionState.authCtx.kind === "main-pool" || admissionState.authCtx.kind === "pool")
|
|
886
|
+
&& usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)
|
|
887
|
+
&& codex401ReplayKind === null
|
|
888
|
+
) {
|
|
889
|
+
codex401ReplayKind = admissionState.authCtx.kind === "pool" ? "stored" : "main";
|
|
890
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed */ }
|
|
891
|
+
const poolAuthCtx = admissionState.authCtx.kind === "pool" ? admissionState.authCtx : undefined;
|
|
892
|
+
const poolReplay = poolAuthCtx
|
|
893
|
+
? await refreshPoolForwardAuth({ req, config, route, authCtx: poolAuthCtx, substituteMainCredential, options, logCtx })
|
|
894
|
+
: undefined;
|
|
895
|
+
const replay = poolReplay
|
|
896
|
+
?? await refreshNativeMainForwardAuth({ req, config, route, authCtx: admissionState.authCtx, substituteMainCredential, options });
|
|
897
|
+
if (!replay.ok) {
|
|
898
|
+
// Compact already records this; core historically returned without recording,
|
|
899
|
+
// so a dead grant stayed selectable and every request repeated the same doomed
|
|
900
|
+
// refresh. Fenced by the generation the 401 belongs to (#2887).
|
|
901
|
+
if (poolAuthCtx && poolReplay && !poolReplay.ok && poolReplay.quarantine) {
|
|
902
|
+
recordCodexUpstreamOutcome(config, poolAuthCtx.accountId, 401, {
|
|
903
|
+
threadId: poolAuthCtx.affinityKey,
|
|
904
|
+
fixedAccount: poolAuthCtx.fixedAccount,
|
|
905
|
+
modelId: route.modelId,
|
|
906
|
+
writerGeneration: poolAuthCtx.writerGeneration,
|
|
907
|
+
credentialGeneration: poolReplay.quarantineGeneration ?? poolAuthCtx.generation,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
upstream.abort();
|
|
911
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
912
|
+
return replay.response;
|
|
913
|
+
}
|
|
914
|
+
admissionState.authCtx = replay.authCtx;
|
|
915
|
+
route.provider = replay.provider;
|
|
916
|
+
requestState.selectedForwardHeaders = withClaudeNativeSession(replay.headers, replay.provider, options.claudeNativeSessionId);
|
|
917
|
+
const replayAdapter = resolveSelectionAdapter(
|
|
918
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, replay.provider, inboundWire),
|
|
919
|
+
config.cacheRetention,
|
|
920
|
+
);
|
|
921
|
+
if (!("passthrough" in replayAdapter) || !replayAdapter.passthrough) {
|
|
922
|
+
upstream.abort();
|
|
923
|
+
return formatErrorResponse(502, "upstream_error", "Native main refresh changed the provider wire unexpectedly");
|
|
924
|
+
}
|
|
925
|
+
bindRouteReasoningReplayScope({
|
|
926
|
+
parsed,
|
|
927
|
+
providerName: route.providerName,
|
|
928
|
+
provider: replay.provider,
|
|
929
|
+
adapterName: replayAdapter.name,
|
|
930
|
+
codexAuthContext: admissionState.authCtx,
|
|
931
|
+
forwardHeaders: requestState.selectedForwardHeaders,
|
|
932
|
+
});
|
|
933
|
+
logCtx.providerAdapter = replayAdapter.name;
|
|
934
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, replayAdapter.name, logCtx.accountLogLabel);
|
|
935
|
+
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, replayAdapter.name);
|
|
936
|
+
try {
|
|
937
|
+
request = await replayAdapter.buildRequest(parsed, {
|
|
938
|
+
headers: requestState.selectedForwardHeaders,
|
|
939
|
+
translatorBudget,
|
|
940
|
+
});
|
|
941
|
+
refreshRequestToolAliases(request);
|
|
942
|
+
recordAdapterReasoning(logCtx, request);
|
|
943
|
+
recordAdapterTier(logCtx, request);
|
|
944
|
+
refreshUndeclaredToolGuard(request);
|
|
945
|
+
// The 401 replay rebuilds the body before sending, so it needs the same ceiling as
|
|
946
|
+
// every other build site; a replay is exactly when a grown payload reappears.
|
|
947
|
+
const replayBodyRefusal = refuseOversizedOutboundBody(request);
|
|
948
|
+
if (replayBodyRefusal) return replayBodyRefusal;
|
|
949
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, "oauth-401");
|
|
950
|
+
upstreamResponse = await fetchWithHeaderTimeout(
|
|
951
|
+
request.url,
|
|
952
|
+
{ method: request.method, headers: request.headers, body: request.body },
|
|
953
|
+
upstream.signal,
|
|
954
|
+
connectMs,
|
|
955
|
+
parsed.stream,
|
|
956
|
+
// The replay-dispatched signal is what bounds the rest of this logical request, so it
|
|
957
|
+
// has to describe a send that actually happened. fetchWithHeaderTimeout awaits pacing
|
|
958
|
+
// admission BEFORE calling the executor, so signalling at the call site would spend the
|
|
959
|
+
// budget even when a rejected pacing wait means nothing reaches the network. Wrapping
|
|
960
|
+
// the executor moves the signal to the last moment before the send, where a throw from
|
|
961
|
+
// here on is a genuine transport attempt.
|
|
962
|
+
storedPoolReplayDispatchNotifier(
|
|
963
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
964
|
+
dispatchOverride: oauthDispatch(request),
|
|
965
|
+
providerName: route.providerName,
|
|
966
|
+
modelId: route.modelId,
|
|
967
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
968
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
969
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
970
|
+
}),
|
|
971
|
+
codex401ReplayKind === "stored" ? options.onStoredPool401ReplayDispatched : undefined,
|
|
972
|
+
),
|
|
973
|
+
route.provider.authMode === "forward",
|
|
974
|
+
).then(adoptObservedResponse);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
return transportFailureResponse(err);
|
|
977
|
+
} finally {
|
|
978
|
+
request.releaseBodyObservation?.();
|
|
979
|
+
}
|
|
980
|
+
continue passthroughRecovery;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
if (codex401ReplayKind !== null && upstreamResponse.status === 401) break;
|
|
984
|
+
|
|
985
|
+
// Native Responses providers return before the generic adapter recovery loop below. Keep
|
|
986
|
+
// their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
|
|
987
|
+
// rebuilt replay. xAI's current subscription models use this branch now that their official
|
|
988
|
+
// Grok CLI catalog declares the Responses backend.
|
|
989
|
+
if (
|
|
990
|
+
upstreamResponse.status === 401
|
|
991
|
+
&& isOAuth401ReplayProvider
|
|
992
|
+
&& transportState.sentOAuthSnapshot
|
|
993
|
+
&& !oauth401ReplayAttempted
|
|
994
|
+
// Refused here, before the 401 body is cancelled: once it is gone the request can only
|
|
995
|
+
// answer with a synthetic 502, which would report a proxy budget decision as an upstream
|
|
996
|
+
// fault and throw away the credential evidence the client needs.
|
|
997
|
+
&& !sendBudgetExhausted()
|
|
998
|
+
) {
|
|
999
|
+
oauth401ReplayAttempted = true;
|
|
1000
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1001
|
+
let refreshed: OAuthAccessSnapshot;
|
|
1002
|
+
try {
|
|
1003
|
+
refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
upstream.abort();
|
|
1006
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
1007
|
+
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
|
|
1008
|
+
}
|
|
1009
|
+
if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) {
|
|
1010
|
+
upstream.abort();
|
|
1011
|
+
releaseCodexAuthContextProbeLease(admissionState.authCtx);
|
|
1012
|
+
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(new Error("Cloud Code Assist project is required")));
|
|
1013
|
+
}
|
|
1014
|
+
transportState.sentOAuthSnapshot = refreshed;
|
|
1015
|
+
transportState.replayOAuthCredentialSnapshot = {
|
|
1016
|
+
accountId: refreshed.accountId,
|
|
1017
|
+
generation: refreshed.generation,
|
|
1018
|
+
};
|
|
1019
|
+
if (route.providerName === "kiro") {
|
|
1020
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
1021
|
+
}
|
|
1022
|
+
const refreshedProvider = resolveProviderTransport(
|
|
1023
|
+
route.providerName,
|
|
1024
|
+
{
|
|
1025
|
+
...route.provider,
|
|
1026
|
+
apiKey: refreshed.accessToken,
|
|
1027
|
+
...(refreshed.projectId ? { project: refreshed.projectId } : {}),
|
|
1028
|
+
},
|
|
1029
|
+
parsed.options.promptCacheKey,
|
|
1030
|
+
route.providerName === "github-copilot"
|
|
1031
|
+
? resolveCopilotApiBaseUrl(refreshed.apiBaseUrl)
|
|
1032
|
+
: undefined,
|
|
1033
|
+
);
|
|
1034
|
+
route.provider = refreshedProvider;
|
|
1035
|
+
const refreshedAdapter = resolveSelectionAdapter(
|
|
1036
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider, inboundWire),
|
|
1037
|
+
config.cacheRetention,
|
|
1038
|
+
);
|
|
1039
|
+
if (!("passthrough" in refreshedAdapter) || !refreshedAdapter.passthrough) {
|
|
1040
|
+
upstream.abort();
|
|
1041
|
+
return formatErrorResponse(502, "upstream_error", "OAuth refresh changed the provider wire unexpectedly");
|
|
1042
|
+
}
|
|
1043
|
+
bindRouteReasoningReplayScope({
|
|
1044
|
+
parsed,
|
|
1045
|
+
providerName: route.providerName,
|
|
1046
|
+
provider: refreshedProvider,
|
|
1047
|
+
adapterName: refreshedAdapter.name,
|
|
1048
|
+
oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
|
|
1049
|
+
});
|
|
1050
|
+
logCtx.providerAdapter = refreshedAdapter.name;
|
|
1051
|
+
sealRequestAttemptIdentity(
|
|
1052
|
+
logCtx.activeAttempt,
|
|
1053
|
+
logCtx.provider,
|
|
1054
|
+
refreshedAdapter.name,
|
|
1055
|
+
logCtx.accountLogLabel,
|
|
1056
|
+
);
|
|
1057
|
+
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, refreshedAdapter.name);
|
|
1058
|
+
try {
|
|
1059
|
+
request = await refreshedAdapter.buildRequest(parsed, {
|
|
1060
|
+
headers: requestState.selectedForwardHeaders,
|
|
1061
|
+
translatorBudget,
|
|
1062
|
+
});
|
|
1063
|
+
refreshRequestToolAliases(request);
|
|
1064
|
+
recordAdapterReasoning(logCtx, request);
|
|
1065
|
+
recordAdapterTier(logCtx, request);
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
upstream.abort();
|
|
1068
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1069
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1070
|
+
return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
|
|
1071
|
+
}
|
|
1072
|
+
refreshUndeclaredToolGuard(request);
|
|
1073
|
+
const refreshedBodyRefusal = refuseOversizedOutboundBody(request);
|
|
1074
|
+
if (refreshedBodyRefusal) return refreshedBodyRefusal;
|
|
1075
|
+
try {
|
|
1076
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
1077
|
+
recovery => {
|
|
1078
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "oauth-401");
|
|
1079
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1080
|
+
method: request.method,
|
|
1081
|
+
headers: request.headers,
|
|
1082
|
+
body: request.body,
|
|
1083
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
1084
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
1085
|
+
dispatchOverride: oauthDispatch(request),
|
|
1086
|
+
providerName: route.providerName,
|
|
1087
|
+
modelId: route.modelId,
|
|
1088
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1089
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1090
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
1091
|
+
}),
|
|
1092
|
+
route.provider.authMode === "forward")
|
|
1093
|
+
.then(adoptObservedResponse);
|
|
1094
|
+
},
|
|
1095
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
|
|
1096
|
+
);
|
|
1097
|
+
} catch (err) {
|
|
1098
|
+
return transportFailureResponse(err);
|
|
1099
|
+
} finally {
|
|
1100
|
+
request.releaseBodyObservation?.();
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Native Responses returns before the generic adapter's OAuth rotation loop. Keep
|
|
1105
|
+
// the same quorum, cooldown and request budget here, before any client bytes flow.
|
|
1106
|
+
if (
|
|
1107
|
+
upstreamResponse.status === 429
|
|
1108
|
+
&& transportState.genericFailoverAccountId
|
|
1109
|
+
&& transportState.genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
|
|
1110
|
+
&& isGenericOAuthFailoverEnabled(config, route.providerName)
|
|
1111
|
+
) {
|
|
1112
|
+
// The roster cap above is one half of the bound; the request's shared budget is the
|
|
1113
|
+
// other. A refused hop leaves the real 429 -- body, Retry-After and any quota evidence
|
|
1114
|
+
// -- exactly as upstream sent it.
|
|
1115
|
+
const hop = reserveCredentialHop(
|
|
1116
|
+
"auth-recovery",
|
|
1117
|
+
`${route.providerName}|${route.modelId}|oauth-account-429`,
|
|
1118
|
+
true,
|
|
1119
|
+
);
|
|
1120
|
+
if (hop.allowed) {
|
|
1121
|
+
const nextAccountId = rotateGenericOAuthAccountOn429(
|
|
1122
|
+
config, route.providerName, transportState.genericFailoverAccountId,
|
|
1123
|
+
upstreamResponse.headers.get("retry-after"),
|
|
1124
|
+
);
|
|
1125
|
+
let snapshot: OAuthAccessSnapshot | undefined;
|
|
1126
|
+
if (nextAccountId) {
|
|
1127
|
+
try { snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId); }
|
|
1128
|
+
catch { /* Keep the original 429 body readable when the next credential is unavailable. */ }
|
|
1129
|
+
}
|
|
1130
|
+
if (snapshot && await applyFailoverSnapshot(snapshot)) {
|
|
1131
|
+
transportState.genericFailovers += 1;
|
|
1132
|
+
route.provider = resolveProviderTransport(
|
|
1133
|
+
route.providerName, route.provider, parsed.options.promptCacheKey, transportState.sentOAuthSnapshot?.apiBaseUrl,
|
|
1134
|
+
);
|
|
1135
|
+
bindRouteReasoningReplayScope({
|
|
1136
|
+
parsed, providerName: route.providerName, provider: route.provider,
|
|
1137
|
+
adapterName: "openai-responses", oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
|
|
1138
|
+
});
|
|
1139
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
1140
|
+
// The replay IS this hop's send, so the rebuild spends the reservation instead of
|
|
1141
|
+
// asking for one of its own.
|
|
1142
|
+
sendBudgetState.pendingHopPermit = hop.permit;
|
|
1143
|
+
const result = await rebuildAndRefetch("oauth-account-429");
|
|
1144
|
+
sendBudgetState.pendingHopPermit = undefined;
|
|
1145
|
+
if ("failed" in result) return result.failed;
|
|
1146
|
+
upstreamResponse = result;
|
|
1147
|
+
continue passthroughRecovery;
|
|
1148
|
+
}
|
|
1149
|
+
// No credential moved, so the reservation costs nothing.
|
|
1150
|
+
hop.permit?.release();
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the
|
|
1155
|
+
// passthrough wire. This branch returns before the recovery loop below, so Responses-shaped
|
|
1156
|
+
// key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429
|
|
1157
|
+
// immediately with no same-key replay. Pre-stream only — nothing has been relayed yet, so
|
|
1158
|
+
// the replay is lossless (same invariant as the recovery loop). Forward/OAuth providers
|
|
1159
|
+
// keep their pool logic below (rateLimitRetryPolicyFor returns null for them).
|
|
1160
|
+
while (
|
|
1161
|
+
upstreamResponse.status === 429
|
|
1162
|
+
&& rateLimitPolicy !== null
|
|
1163
|
+
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
1164
|
+
// Checked here rather than inside the helper: prepareSameTarget429Wait releases the 429
|
|
1165
|
+
// body, so a refusal discovered after the wait can no longer return the real rate-limit
|
|
1166
|
+
// answer and would surface a synthetic 502 instead.
|
|
1167
|
+
&& !sendBudgetExhausted()
|
|
1168
|
+
) {
|
|
1169
|
+
rateLimitRetries += 1;
|
|
1170
|
+
// Release unread body + deliberate wait via the shared same-target helper.
|
|
1171
|
+
const retryAfterHeader = upstreamResponse.headers.get("retry-after");
|
|
1172
|
+
try {
|
|
1173
|
+
for await (const _ of prepareSameTarget429Wait({
|
|
1174
|
+
body: upstreamResponse.body,
|
|
1175
|
+
signal: options.abortSignal,
|
|
1176
|
+
delayMs: rateLimitRetryDelayMs(rateLimitPolicy, retryAfterHeader, Date.now()),
|
|
1177
|
+
})) {
|
|
1178
|
+
// pre-stream: no stall watchdog to feed
|
|
1179
|
+
}
|
|
1180
|
+
} catch {
|
|
1181
|
+
upstream.abort();
|
|
1182
|
+
return clientCancelledResponse();
|
|
1183
|
+
}
|
|
1184
|
+
// Client cancellation wins over any stale timer edge: re-check before dispatching the
|
|
1185
|
+
// replay so the wire never starts work for a request the client already abandoned.
|
|
1186
|
+
if (options.abortSignal?.aborted || upstream.signal.aborted) {
|
|
1187
|
+
upstream.abort();
|
|
1188
|
+
return clientCancelledResponse();
|
|
1189
|
+
}
|
|
1190
|
+
try {
|
|
1191
|
+
upstreamResponse = await fetchWithTransientRetry(
|
|
1192
|
+
recovery => {
|
|
1193
|
+
// The first send of every replay is itself a rate-limit retry; inner transient-5xx
|
|
1194
|
+
// recoveries keep their own label (recovery is provided for those).
|
|
1195
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery ?? "rate-limit-429");
|
|
1196
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1197
|
+
method: request.method,
|
|
1198
|
+
headers: request.headers,
|
|
1199
|
+
body: request.body,
|
|
1200
|
+
}, recovery), upstream.signal, connectMs, parsed.stream,
|
|
1201
|
+
providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
1202
|
+
dispatchOverride: oauthDispatch(request),
|
|
1203
|
+
providerName: route.providerName,
|
|
1204
|
+
modelId: route.modelId,
|
|
1205
|
+
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1206
|
+
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1207
|
+
? createCodexReserveDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
1208
|
+
}),
|
|
1209
|
+
route.provider.authMode === "forward")
|
|
1210
|
+
.then(adoptObservedResponse);
|
|
1211
|
+
},
|
|
1212
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS), onSendsConsumed: noteTransientSends },
|
|
1213
|
+
);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
return transportFailureResponse(err);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
const captureAffinityResponse = (
|
|
1220
|
+
response: Response,
|
|
1221
|
+
captureAuthCtx: CodexAuthContext = admissionState.authCtx,
|
|
1222
|
+
captureRequest: Awaited<ReturnType<typeof transportState.adapter.buildRequest>> = request,
|
|
1223
|
+
credentialSubstituted = substituteMainCredential
|
|
1224
|
+
|| captureAuthCtx.kind === "pool"
|
|
1225
|
+
|| captureAuthCtx.kind === "main-pool",
|
|
1226
|
+
): void => {
|
|
1227
|
+
if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
|
|
1228
|
+
captureCodexAffinityDiagnostic({
|
|
1229
|
+
inboundHeaders: req.headers,
|
|
1230
|
+
outboundHeaders: captureRequest.headers,
|
|
1231
|
+
authKind: captureAuthCtx.kind,
|
|
1232
|
+
accountMode: route.codexAccountMode,
|
|
1233
|
+
fixedAccount: isFixedCodexAccount(captureAuthCtx),
|
|
1234
|
+
credentialSubstituted,
|
|
1235
|
+
accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
|
|
1236
|
+
wireModelNormalized: parsed.modelId !== route.modelId,
|
|
1237
|
+
status: response.status,
|
|
1238
|
+
});
|
|
1239
|
+
};
|
|
1240
|
+
captureAffinityResponse(upstreamResponse);
|
|
1241
|
+
|
|
1242
|
+
if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
|
|
1243
|
+
let poolRetryOutcome: number | undefined;
|
|
1244
|
+
if (await shouldRetryCodexPoolAccountModel400(
|
|
1245
|
+
upstreamResponse,
|
|
1246
|
+
route.modelId,
|
|
1247
|
+
options.abortSignal,
|
|
1248
|
+
)) {
|
|
1249
|
+
poolRetryOutcome = 400;
|
|
1250
|
+
} else if (!admissionState.authCtx.fixedAccount && await shouldRetryCodexPoolAccountQuota(
|
|
1251
|
+
upstreamResponse,
|
|
1252
|
+
options.abortSignal,
|
|
1253
|
+
)) {
|
|
1254
|
+
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
1255
|
+
// ChatGPT sometimes wraps quota exhaustion in a generic 5xx. Normalize only
|
|
1256
|
+
// body-confirmed cases to quota evidence so cooldown and rotation both apply.
|
|
1257
|
+
poolRetryOutcome = upstreamResponse.status >= 500 ? 429 : upstreamResponse.status;
|
|
1258
|
+
} else if (!admissionState.authCtx.fixedAccount && shouldRetryCodexPoolAccountTransient(upstreamResponse)) {
|
|
1259
|
+
// A plain transient 5xx the same-account retry layer could not absorb. Keep the real
|
|
1260
|
+
// status so it records as transient rather than quota.
|
|
1261
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (poolRetryOutcome !== undefined) {
|
|
1265
|
+
// A stored Pool 401 spent this request's account budget on its own refresh and replay, so
|
|
1266
|
+
// nothing afterwards may be paid for out of a DIFFERENT account. One flag carries that,
|
|
1267
|
+
// rather than a status check here as well: a quota failure has no same-account move, so
|
|
1268
|
+
// `sameAccountOnly` makes it terminal by refusing the alternate; the gated-model 400
|
|
1269
|
+
// ladder does have one — retrying the account the refreshed roster still grants — and
|
|
1270
|
+
// keeps it. An earlier revision also broke here on a non-400 outcome, which no test could
|
|
1271
|
+
// justify because this flag already produced the identical result.
|
|
1272
|
+
const storedReplaySpent = codex401ReplayKind === "stored";
|
|
1273
|
+
const retry = await retryCodexPoolOnAlternateAccount({
|
|
1274
|
+
callerAuthHeaders,
|
|
1275
|
+
config,
|
|
1276
|
+
route,
|
|
1277
|
+
parsed,
|
|
1278
|
+
logCtx,
|
|
1279
|
+
options: { ...options, workflowRootId },
|
|
1280
|
+
firstAuthCtx: admissionState.authCtx,
|
|
1281
|
+
firstResponse: upstreamResponse,
|
|
1282
|
+
outcomeStatus: poolRetryOutcome,
|
|
1283
|
+
sameAccountOnly: storedReplaySpent,
|
|
1284
|
+
upstream,
|
|
1285
|
+
connectMs,
|
|
1286
|
+
passthroughEstimate,
|
|
1287
|
+
stream: parsed.stream,
|
|
1288
|
+
onResponse: (response, retryAuthCtx, retryRequest) => {
|
|
1289
|
+
adoptCodexWsStage(response);
|
|
1290
|
+
captureAffinityResponse(
|
|
1291
|
+
response,
|
|
1292
|
+
retryAuthCtx,
|
|
1293
|
+
retryRequest,
|
|
1294
|
+
retryAuthCtx.kind !== "main",
|
|
1295
|
+
);
|
|
1296
|
+
},
|
|
1297
|
+
});
|
|
1298
|
+
if (retry.kind === "transport") {
|
|
1299
|
+
admissionState.authCtx = retry.authCtx;
|
|
1300
|
+
return transportFailureResponse(retry.error);
|
|
1301
|
+
}
|
|
1302
|
+
if (retry.kind === "retried") {
|
|
1303
|
+
admissionState.authCtx = retry.authCtx;
|
|
1304
|
+
request = retry.request;
|
|
1305
|
+
refreshRequestToolAliases(request);
|
|
1306
|
+
refreshUndeclaredToolGuard(request);
|
|
1307
|
+
upstreamResponse = retry.upstreamResponse;
|
|
1308
|
+
requestState.selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
1309
|
+
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
1310
|
+
requestState.subagentFallbackAccountId = retry.authCtx.accountId;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
// The deterministic route record cannot classify history it never observed (restart, expiry,
|
|
1315
|
+
// eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound
|
|
1316
|
+
// Responses body still carries opaque state, then rebuild once through the ordinary adapter
|
|
1317
|
+
// sanitation path. A second rejection falls through unchanged because the guard stays armed.
|
|
1318
|
+
const opaqueBlobRecovery = await attemptOpaqueBlobRecovery({
|
|
1319
|
+
response: upstreamResponse,
|
|
1320
|
+
outboundBody: request.body,
|
|
1321
|
+
adapterName: transportState.adapter.name,
|
|
1322
|
+
parsed,
|
|
1323
|
+
guard: opaqueBlobRecoveryGuard,
|
|
1324
|
+
signal: upstream.signal,
|
|
1325
|
+
}, rebuildAndRefetch);
|
|
1326
|
+
if (opaqueBlobRecovery.kind === "failed") return opaqueBlobRecovery.response;
|
|
1327
|
+
if (opaqueBlobRecovery.kind === "recovered") {
|
|
1328
|
+
upstreamResponse = opaqueBlobRecovery.response;
|
|
1329
|
+
continue passthroughRecovery;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const recoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1333
|
+
const streamedFunctionOutputCandidate = upstreamResponse.ok
|
|
1334
|
+
&& !!upstreamResponse.body
|
|
1335
|
+
&& (recoveryContentType.includes("text/event-stream") || (!recoveryContentType && parsed.stream))
|
|
1336
|
+
&& !opaqueBlobRecoveryGuard.attempted
|
|
1337
|
+
&& outboundResponsesBodyCarriesEncryptedFunctionOutput(request.body);
|
|
1338
|
+
if (streamedFunctionOutputCandidate) {
|
|
1339
|
+
const preflightLog: RequestLogContext = { model: logCtx.model, provider: logCtx.provider };
|
|
1340
|
+
const preflight = await preflightComboStreamResponse(upstreamResponse, preflightLog,
|
|
1341
|
+
payload => {
|
|
1342
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
1343
|
+
const type = (payload as { type?: unknown }).type;
|
|
1344
|
+
return (type === "error" || type === "response.failed" || type === "response.incomplete")
|
|
1345
|
+
&& upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION;
|
|
1346
|
+
}, {
|
|
1347
|
+
allowMissingContentType: !recoveryContentType && parsed.stream,
|
|
1348
|
+
replayReadErrors: true,
|
|
1349
|
+
});
|
|
1350
|
+
if (options.abortSignal?.aborted) return transportFailureResponse(options.abortSignal.reason);
|
|
1351
|
+
upstreamResponse = preflight.response;
|
|
1352
|
+
if (preflight.kind === "failed") {
|
|
1353
|
+
const streamedOpaqueRecovery = await attemptOpaqueBlobRecovery({
|
|
1354
|
+
response: upstreamResponse,
|
|
1355
|
+
outboundBody: request.body,
|
|
1356
|
+
adapterName: transportState.adapter.name,
|
|
1357
|
+
parsed,
|
|
1358
|
+
guard: opaqueBlobRecoveryGuard,
|
|
1359
|
+
signal: upstream.signal,
|
|
1360
|
+
}, rebuildAndRefetch);
|
|
1361
|
+
if (streamedOpaqueRecovery.kind === "failed") return streamedOpaqueRecovery.response;
|
|
1362
|
+
if (streamedOpaqueRecovery.kind === "recovered") {
|
|
1363
|
+
resetStreamedOpaqueBlobLogContext(logCtx);
|
|
1364
|
+
upstreamResponse = streamedOpaqueRecovery.response;
|
|
1365
|
+
continue passthroughRecovery;
|
|
1366
|
+
}
|
|
1367
|
+
logCtx.upstreamError = preflightLog.upstreamError;
|
|
1368
|
+
logCtx.terminalHttpStatus = preflightLog.terminalHttpStatus;
|
|
1369
|
+
logCtx.terminalErrorCode = preflightLog.terminalErrorCode;
|
|
1370
|
+
logCtx.terminalIncompleteReason = preflightLog.terminalIncompleteReason;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// Console Go (opencode-zen / opencode-go) intermittently rejects a body it accepts seconds
|
|
1374
|
+
// later with 400 invalid_request_error / "Invalid upload request." Replay the byte-identical
|
|
1375
|
+
// request once after the exact gateway rejection. Single-shot guard.
|
|
1376
|
+
// This recovery reuses the captured request; other recovery kinds still rebuild.
|
|
1377
|
+
if (!consoleGoUploadRetryGuard.attempted) {
|
|
1378
|
+
const uploadRejectionBody = await consoleGoUploadRejectionBody(
|
|
1379
|
+
upstreamResponse,
|
|
1380
|
+
consoleGoUploadRetryGuard.attempted,
|
|
1381
|
+
upstream.signal,
|
|
1382
|
+
);
|
|
1383
|
+
if (uploadRejectionBody !== undefined
|
|
1384
|
+
&& isTransientConsoleGoUploadRejection({
|
|
1385
|
+
status: upstreamResponse.status,
|
|
1386
|
+
errorBody: uploadRejectionBody,
|
|
1387
|
+
outboundUrl: request.url,
|
|
1388
|
+
})) {
|
|
1389
|
+
consoleGoUploadRetryGuard.attempted = true;
|
|
1390
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1391
|
+
if (!upstream.signal.aborted) {
|
|
1392
|
+
try {
|
|
1393
|
+
await sleepWithAbort(CONSOLE_GO_UPLOAD_RETRY_DELAY_MS, upstream.signal);
|
|
1394
|
+
} catch { return clientCancelledResponse(); }
|
|
1395
|
+
}
|
|
1396
|
+
if (upstream.signal.aborted) return clientCancelledResponse();
|
|
1397
|
+
const result = await rebuildAndRefetch("console-go-upload-retry");
|
|
1398
|
+
if ("failed" in result) return result.failed;
|
|
1399
|
+
upstreamResponse = result;
|
|
1400
|
+
continue passthroughRecovery;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
// Reasoning-effort downgrade: a rung the catalog still advertises can be refused upstream --
|
|
1404
|
+
// the metadata records the model's ladder, not this account's entitlement (a Muse Code
|
|
1405
|
+
// subscription gates max on muse-spark-1.3-contributor, for example). Learn the refusal so
|
|
1406
|
+
// later turns clamp before dispatch, then replay once at the next lower published rung
|
|
1407
|
+
// instead of failing the turn; requestedEffort/effectiveEffort keep both values in usage.
|
|
1408
|
+
if (!reasoningEffortDowngradeGuard.attempted) {
|
|
1409
|
+
const rejectionText = await reasoningEffortRejectionText(
|
|
1410
|
+
upstreamResponse,
|
|
1411
|
+
reasoningEffortDowngradeGuard.attempted,
|
|
1412
|
+
upstream.signal,
|
|
1413
|
+
);
|
|
1414
|
+
const downgrade = rejectionText === undefined
|
|
1415
|
+
? undefined
|
|
1416
|
+
: planReasoningEffortDowngrade({
|
|
1417
|
+
provider: route.provider,
|
|
1418
|
+
modelId: parsed.modelId,
|
|
1419
|
+
requested: parsed.options.reasoning,
|
|
1420
|
+
rejectionText,
|
|
1421
|
+
});
|
|
1422
|
+
if (downgrade) {
|
|
1423
|
+
reasoningEffortDowngradeGuard.attempted = true;
|
|
1424
|
+
parsed.options.reasoning = downgrade.effort;
|
|
1425
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1426
|
+
const result = await rebuildAndRefetch("reasoning-effort-downgrade");
|
|
1427
|
+
if ("failed" in result) return result.failed;
|
|
1428
|
+
upstreamResponse = result;
|
|
1429
|
+
continue passthroughRecovery;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
break;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
return {
|
|
1436
|
+
codexSafetyBufferingOptions,
|
|
1437
|
+
imageGenCallAliases,
|
|
1438
|
+
routedCustomToolNames,
|
|
1439
|
+
routedCustomToolRepairNames,
|
|
1440
|
+
routedToolSearchNames,
|
|
1441
|
+
rememberPassthroughResponse,
|
|
1442
|
+
selfNamedNamespaceScrubAuthorization,
|
|
1443
|
+
providerExecutedCallTypes,
|
|
1444
|
+
get request(): Awaited<ReturnType<typeof transportState.adapter.buildRequest>> {
|
|
1445
|
+
return request;
|
|
1446
|
+
},
|
|
1447
|
+
set request(value: Awaited<ReturnType<typeof transportState.adapter.buildRequest>>) {
|
|
1448
|
+
request = value;
|
|
1449
|
+
},
|
|
1450
|
+
functionRepairSchemas,
|
|
1451
|
+
get outboundRequestBody(): Record<string, unknown> | undefined {
|
|
1452
|
+
return outboundRequestBody;
|
|
1453
|
+
},
|
|
1454
|
+
set outboundRequestBody(value: Record<string, unknown> | undefined) {
|
|
1455
|
+
outboundRequestBody = value;
|
|
1456
|
+
},
|
|
1457
|
+
declaredWireToolNames,
|
|
1458
|
+
declaredBareWireToolNames,
|
|
1459
|
+
declaredNamelessClientCallTypes,
|
|
1460
|
+
authorizedBareNamespaceToolAliases,
|
|
1461
|
+
normalizeFunctionCompletionJson,
|
|
1462
|
+
get undeclaredToolGuardActive(): typeof undeclaredToolGuardActive {
|
|
1463
|
+
return undeclaredToolGuardActive;
|
|
1464
|
+
},
|
|
1465
|
+
set undeclaredToolGuardActive(value: typeof undeclaredToolGuardActive) {
|
|
1466
|
+
undeclaredToolGuardActive = value;
|
|
1467
|
+
},
|
|
1468
|
+
noteInspectedPayload,
|
|
1469
|
+
rememberPassthroughResponseChecked,
|
|
1470
|
+
upstream,
|
|
1471
|
+
connectMs,
|
|
1472
|
+
upstreamResponse,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
export type PassthroughExchange = Exclude<Awaited<ReturnType<typeof preparePassthroughExchange>>, Response>;
|