@bitkyc08/opencodex 2.83.0 → 2.84.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/App-CZtqiGcw.js +56 -0
- package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
- package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
- package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
- package/gui/dist/assets/{quota-summary-DiJO6sYY.js → quota-summary-BbEeRfPY.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/coding-agent/protocol.ts +15 -8
- package/src/adapters/ollama-native.ts +29 -1
- package/src/adapters/openai-chat/messages.ts +8 -4
- package/src/adapters/openai-responses/empty-assistant.ts +32 -0
- package/src/adapters/openai-responses/passthrough.ts +3 -0
- package/src/adapters/openai-responses/request-strips.ts +10 -2
- package/src/adapters/qoder/adapter.ts +16 -14
- package/src/bridge/errors.ts +19 -0
- package/src/bridge.ts +1 -1
- package/src/claude/context-windows.ts +54 -1
- package/src/claude/intercept/connect-proxy.ts +10 -3
- package/src/claude/intercept/picker-ca-persistence.ts +3 -1
- package/src/claude/intercept/picker-listener.ts +49 -1
- package/src/claude/intercept/settings-no-proxy.ts +146 -0
- package/src/claude/intercept/settings.ts +101 -9
- package/src/cli/account-api.ts +65 -0
- package/src/cli/account-auth.ts +26 -2
- package/src/cli/account.ts +41 -5
- package/src/cli/capabilities-access-remote.ts +8 -0
- package/src/cli/capabilities-base.ts +13 -1
- package/src/cli/catalog-command-result.ts +2 -1
- package/src/cli/claude.ts +15 -6
- package/src/cli/config-command.ts +8 -2
- package/src/cli/debug.ts +8 -0
- package/src/cli/dispatch.ts +16 -3
- package/src/cli/export-command.ts +14 -1
- package/src/cli/hub-disable.ts +99 -0
- package/src/cli/hub.ts +4 -2
- package/src/cli/index.ts +13 -4
- package/src/cli/link-child.ts +104 -0
- package/src/cli/link-enrollment.ts +96 -0
- package/src/cli/link.ts +39 -24
- package/src/cli/observe-stream.ts +6 -1
- package/src/cli/observe.ts +10 -2
- package/src/cli/provider.ts +12 -0
- package/src/cli/registry.ts +5 -4
- package/src/cli/runtime-api.ts +27 -2
- package/src/cli/status.ts +104 -20
- package/src/client/connect.ts +39 -4
- package/src/client/hub-client.ts +14 -7
- package/src/client/hub-state.ts +25 -14
- package/src/client/link-home.ts +248 -0
- package/src/client/link-management-relay.ts +61 -0
- package/src/client/link-relay.ts +6 -1
- package/src/client/link-status.ts +14 -0
- package/src/client/machine-api.ts +14 -7
- package/src/client/machine-listener.ts +29 -4
- package/src/clients/config-export.ts +46 -16
- package/src/clients/omo-role-models.ts +6 -3
- package/src/codex/agent-role-models.ts +26 -8
- package/src/codex/auth-api/account-list.ts +17 -2
- package/src/codex/auth-api/account-selection.ts +17 -7
- package/src/codex/auth-api/routes.ts +5 -1
- package/src/codex/autostart-health.ts +3 -1
- package/src/codex/catalog/bundled.ts +5 -1
- package/src/codex/catalog/effort.ts +2 -2
- package/src/codex/catalog/metadata.ts +4 -2
- package/src/codex/catalog/native-models.ts +16 -2
- package/src/codex/native-profile-startup.ts +4 -1
- package/src/codex/prompt-journal.ts +20 -2
- package/src/codex/prompt-layers/encoding.ts +49 -0
- package/src/codex/prompt-layers/import-source.ts +30 -0
- package/src/codex/prompt-layers/toml-edit.ts +269 -37
- package/src/codex/prompt-layers/toml-read.ts +52 -4
- package/src/codex/prompt-layers.ts +383 -20
- package/src/codex/prompt-lock-claim.ts +13 -3
- package/src/codex/prompt-lock-evidence.ts +3 -3
- package/src/codex/prompt-lock-owner.ts +14 -6
- package/src/codex/prompt-text-probe.ts +34 -5
- package/src/codex/quota.ts +13 -6
- package/src/codex/role-auto-assign.ts +2 -5
- package/src/codex/routing/active-account.ts +21 -0
- package/src/codex/routing.ts +1 -1
- package/src/codex/runtime.ts +196 -8
- package/src/codex/shim-overlay.ts +8 -4
- package/src/codex/shim.ts +1 -1
- package/src/combos/failover.ts +3 -0
- package/src/config/api-key-reconcile.ts +34 -0
- package/src/config/diagnostics.ts +16 -0
- package/src/config/feature-flags.ts +5 -0
- package/src/config/live-reconcile.ts +43 -29
- package/src/config/live-topology.ts +29 -0
- package/src/config/load-degrade.ts +2 -1
- package/src/config/paths.ts +3 -0
- package/src/config/salvage.ts +3 -1
- package/src/config/schema/config-schema.ts +2 -0
- package/src/generated/compatibility-version.json +311 -171
- package/src/grok/grpc-web.ts +23 -1
- package/src/grok/reset-coupon-ledger.ts +75 -5
- package/src/grok/reset-coupons.ts +4 -2
- package/src/integrations/mutation-plan.ts +13 -7
- package/src/integrations/registry.ts +45 -11
- package/src/integrations/state.ts +19 -7
- package/src/integrations/target.ts +42 -5
- package/src/integrations/writer.ts +14 -6
- package/src/lib/app-owned-memory-stores.ts +4 -0
- package/src/lib/app-owned-memory.ts +11 -0
- package/src/lib/json-byte-size.ts +31 -0
- package/src/lib/optional-shutdown-hooks.ts +35 -0
- package/src/lib/outbound-credential-registry.ts +210 -0
- package/src/lib/passive-read.ts +35 -0
- package/src/lib/shadow-call.ts +44 -5
- package/src/link/codex-settings.ts +155 -0
- package/src/link/management-routes.ts +89 -0
- package/src/link/remote-probe.ts +38 -0
- package/src/link/ssh-argv.ts +26 -5
- package/src/link/ssh-runner.ts +58 -27
- package/src/link/status-projection.ts +22 -4
- package/src/link/supervisor.ts +144 -49
- package/src/link/tunnel-state.ts +11 -4
- package/src/oauth/anthropic-continuity.ts +4 -3
- package/src/oauth/anthropic-identity.ts +13 -2
- package/src/oauth/anthropic-model-quota.ts +13 -1
- package/src/oauth/anthropic-routing.ts +230 -23
- package/src/oauth/anthropic.ts +18 -4
- package/src/oauth/google-antigravity.ts +24 -7
- package/src/oauth/index.ts +24 -15
- package/src/oauth/kiro-account-identity.ts +106 -0
- package/src/oauth/kiro.ts +48 -5
- package/src/oauth/plan.ts +29 -0
- package/src/oauth/store.ts +40 -3
- package/src/oauth/types.ts +2 -0
- package/src/plugins/loader.ts +11 -0
- package/src/providers/anthropic-billing-ownership.ts +74 -0
- package/src/providers/anthropic-billing-recovery.ts +31 -0
- package/src/providers/anthropic-billing-refusal.ts +55 -0
- package/src/providers/anthropic-reset-admission.ts +62 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
- package/src/providers/api-key-resolve.ts +17 -2
- package/src/providers/key-failover.ts +52 -5
- package/src/providers/model-rename-startup.ts +5 -3
- package/src/providers/quota/account-cache.ts +11 -0
- package/src/providers/quota/anthropic-account-quota.ts +4 -2
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota.ts +4 -1
- package/src/providers/registry/entries-core.ts +14 -14
- package/src/providers/registry/entries-extended.ts +0 -1
- package/src/providers/registry/model-seeds.ts +17 -0
- package/src/providers/request-pacing.ts +9 -0
- package/src/providers/stale-no-vision-seed-migration.ts +72 -0
- package/src/responses/compaction.ts +14 -0
- package/src/responses/parser.ts +7 -1
- package/src/server/auth-cors.ts +42 -0
- package/src/server/configured-credentials.ts +194 -0
- package/src/server/index/link-management.ts +58 -0
- package/src/server/index/optional-listeners.ts +5 -1
- package/src/server/index/serve-options.ts +13 -0
- package/src/server/lifecycle.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +28 -10
- package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
- package/src/server/management/codex-agent-role-routes.ts +19 -6
- package/src/server/management/codex-prompt-routes.ts +136 -5
- package/src/server/management/codex-role-auto-assign.ts +1 -1
- package/src/server/management/grok-coupon-routes.ts +78 -27
- package/src/server/management/link-enrollment.ts +192 -0
- package/src/server/management/link-route-state.ts +185 -0
- package/src/server/management/link-routes.ts +82 -402
- package/src/server/management/model-rows.ts +3 -1
- package/src/server/management/provider-overwrite-carry.ts +10 -0
- package/src/server/management/provider-routes.ts +1 -0
- package/src/server/management/route-registry.ts +5 -3
- package/src/server/management/shared.ts +3 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native.ts +68 -42
- package/src/server/relay-eager.ts +2 -0
- package/src/server/request-log-filter.ts +6 -1
- package/src/server/request-log-terminal-status.ts +63 -6
- package/src/server/responses/adapter-dispatch.ts +232 -79
- package/src/server/responses/code-mode-collab-surface.ts +205 -0
- package/src/server/responses/codex-ws-exchange.ts +147 -6
- package/src/server/responses/codex-ws-wire.ts +203 -0
- package/src/server/responses/collaboration.ts +36 -7
- package/src/server/responses/compact.ts +126 -48
- package/src/server/responses/context-overflow.ts +13 -1
- package/src/server/responses/core-codex-account.ts +105 -1
- package/src/server/responses/core-combo-failure.ts +14 -0
- package/src/server/responses/core-combo.ts +7 -2
- package/src/server/responses/core-errors.ts +29 -1
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/deferred-collab-surface.ts +158 -0
- package/src/server/responses/deferred-send-reporter.ts +25 -0
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/passthrough-delivery.ts +4 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -4
- package/src/server/responses/request-prepare.ts +6 -4
- package/src/server/responses/request-transport.ts +6 -8
- package/src/server/responses/sidecar-execution.ts +11 -2
- package/src/server/responses/ws-upstream.ts +9 -1
- package/src/server/startup-action-control.ts +22 -2
- package/src/server/startup-health-cache.ts +4 -0
- package/src/server/system-env-shell.ts +7 -2
- package/src/server/system-env.ts +11 -1
- package/src/service/cli.ts +5 -1
- package/src/service/desktop-startup.ts +147 -34
- package/src/service/desktop-supervision.d.mts +27 -1
- package/src/service/desktop-supervision.mjs +111 -5
- package/src/service/health-sentinel-launch.ts +176 -0
- package/src/service/health-sentinel-main.ts +447 -0
- package/src/service/health-sentinel-policy.ts +241 -0
- package/src/service/health-sentinel-probe.ts +54 -0
- package/src/service/health-sentinel-windows.ts +87 -0
- package/src/service/linux-linger.ts +20 -0
- package/src/types/config.ts +5 -0
- package/src/usage/expected-prices.ts +4 -0
- package/src/usage/log.ts +19 -1
- package/gui/dist/assets/App-Ba8uG696.js +0 -54
- package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
- package/gui/dist/assets/index-BHRZdJuv.js +0 -94
|
@@ -22,7 +22,15 @@ import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
|
|
|
22
22
|
import { formatErrorResponse } from "../../bridge";
|
|
23
23
|
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
24
24
|
import { upstreamErrorMessageFromPayload, isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
|
|
25
|
-
import { isNonReplayableResponse, isTransientUpstreamStatus } from "../../lib/upstream-retry";
|
|
25
|
+
import { isNonReplayableResponse, isTransientUpstreamStatus, SendBudgetExhaustedError } from "../../lib/upstream-retry";
|
|
26
|
+
import {
|
|
27
|
+
codexWsPreOutputRefusalHold,
|
|
28
|
+
codexWsPreOutputRefusalOutcomeRecorded,
|
|
29
|
+
isCodexWsPreOutputRefusal,
|
|
30
|
+
noteCodexWsPreOutputRefusalOutcome,
|
|
31
|
+
restoreCodexWsPreOutputRefusal,
|
|
32
|
+
type CodexWsPreOutputRefusalHold,
|
|
33
|
+
} from "./codex-ws-wire";
|
|
26
34
|
import type { RequestLogContext } from "../request-log";
|
|
27
35
|
import type { DataPlaneAdmission } from "../auth-cors";
|
|
28
36
|
import type { InboundWire } from "../../providers/registry";
|
|
@@ -121,6 +129,90 @@ export function usesCodexForwardPoolAuth(
|
|
|
121
129
|
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Write a pre-output refusal projection's account outcome once (#6969), for an exit that no other
|
|
134
|
+
* owner records: a caller abort or a local send-budget refusal during the same-account backoff,
|
|
135
|
+
* where the transport-failure path writes no account health and the restored stream never reaches
|
|
136
|
+
* delivery. A projection already recorded by the alternate-account helper is left alone.
|
|
137
|
+
*/
|
|
138
|
+
export function recordCodexPreOutputRefusalOutcome(
|
|
139
|
+
config: OcxConfig,
|
|
140
|
+
authCtx: CodexAuthContext,
|
|
141
|
+
provider: OcxProviderConfig,
|
|
142
|
+
response: Response,
|
|
143
|
+
modelId: string,
|
|
144
|
+
): void {
|
|
145
|
+
if (!isCodexWsPreOutputRefusal(response) || codexWsPreOutputRefusalOutcomeRecorded(response)) return;
|
|
146
|
+
if (!usesCodexForwardPoolAuth(authCtx, provider)) return;
|
|
147
|
+
noteCodexWsPreOutputRefusalOutcome(response);
|
|
148
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
|
|
149
|
+
threadId: authCtx.affinityKey,
|
|
150
|
+
fixedAccount: authCtx.fixedAccount,
|
|
151
|
+
modelId,
|
|
152
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
153
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
154
|
+
transientProbe: codexTransientProbeGrant(authCtx),
|
|
155
|
+
writerGeneration: authCtx.writerGeneration,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The pre-output refusal hold for one Pool send starting now (#6969), or undefined. Pool forward
|
|
161
|
+
* sends only, and never a combo child, whose own stream preflight already advances on a zero-output
|
|
162
|
+
* terminal. Every projection a send produces must later pass through
|
|
163
|
+
* {@link PoolPreOutputRefusals.restore}.
|
|
164
|
+
*/
|
|
165
|
+
export function poolPreOutputRefusalHold(
|
|
166
|
+
authCtx: CodexAuthContext,
|
|
167
|
+
provider: OcxProviderConfig,
|
|
168
|
+
connectMs: number,
|
|
169
|
+
comboAttempt: boolean | undefined,
|
|
170
|
+
): CodexWsPreOutputRefusalHold | undefined {
|
|
171
|
+
return !comboAttempt && usesCodexForwardPoolAuth(authCtx, provider) ? codexWsPreOutputRefusalHold(connectMs) : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One dispatch's view of its pre-output refusal projections (#6969): the last one the initial
|
|
176
|
+
* transient ladder observed, the single exit where the dispatch itself must record it, and the
|
|
177
|
+
* restore that hands the client the original stream.
|
|
178
|
+
*/
|
|
179
|
+
export class PoolPreOutputRefusals {
|
|
180
|
+
private last: Response | undefined;
|
|
181
|
+
/** True when the response handed to delivery is a restored projection already recorded. */
|
|
182
|
+
outcomeRecorded = false;
|
|
183
|
+
|
|
184
|
+
readonly observe = (response: Response): Response => {
|
|
185
|
+
if (isCodexWsPreOutputRefusal(response)) this.last = response;
|
|
186
|
+
return response;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One owner per exceptional exit of the initial ladder. A caller abort or a local budget refusal
|
|
191
|
+
* during the backoff leaves the transport-failure path writing no account health, so the refusal
|
|
192
|
+
* already observed is recorded here; any other exception keeps that path as its single writer.
|
|
193
|
+
*/
|
|
194
|
+
recordOnLocalExit(
|
|
195
|
+
error: unknown,
|
|
196
|
+
aborted: boolean,
|
|
197
|
+
config: OcxConfig,
|
|
198
|
+
authCtx: CodexAuthContext,
|
|
199
|
+
provider: OcxProviderConfig,
|
|
200
|
+
modelId: string,
|
|
201
|
+
): void {
|
|
202
|
+
if (!this.last || !(aborted || error instanceof SendBudgetExhaustedError)) return;
|
|
203
|
+
recordCodexPreOutputRefusalOutcome(config, authCtx, provider, this.last, modelId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Called right after the Pool block on every recovery pass: a projection that survived the ladder
|
|
208
|
+
* and the account move becomes the original event stream before any later reader sees it.
|
|
209
|
+
*/
|
|
210
|
+
restore(response: Response): Response {
|
|
211
|
+
this.outcomeRecorded = isCodexWsPreOutputRefusal(response) && codexWsPreOutputRefusalOutcomeRecorded(response);
|
|
212
|
+
return restoreCodexWsPreOutputRefusal(response);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
124
216
|
|
|
125
217
|
/** Live proof that this plain-main response used the observed main credential. */
|
|
126
218
|
export function liveMainQuotaDispatch(
|
|
@@ -368,6 +460,10 @@ export async function shouldRetryCodexScopedQuotaOnAlternate(
|
|
|
368
460
|
* could duplicate a turn the origin may still be running. A 5xx whose body confirms quota is not
|
|
369
461
|
* routed here either — {@link shouldRetryCodexPoolAccountQuota} classifies that one first and
|
|
370
462
|
* carries the cooldown with it.
|
|
463
|
+
*
|
|
464
|
+
* A WebSocket refusal reaches this predicate only as the opt-in pre-output projection (#6969): the
|
|
465
|
+
* upstream itself ended the turn with a structured 5xx while nothing but empty lifecycle frames had
|
|
466
|
+
* been relayed. It carries no non-replayable marker because nothing could have been observed.
|
|
371
467
|
*/
|
|
372
468
|
export function shouldRetryCodexPoolAccountTransient(response: Response): boolean {
|
|
373
469
|
return !isNonReplayableResponse(response) && isTransientUpstreamStatus(response.status);
|
|
@@ -420,6 +516,8 @@ export interface CodexPoolAccountRetryArgs {
|
|
|
420
516
|
stream: boolean;
|
|
421
517
|
/** Keep a buffered canonical client on HTTP/SSE after moving to another Pool account. */
|
|
422
518
|
httpOnly?: boolean;
|
|
519
|
+
/** Pre-output refusal hold for the alternate's WebSocket send (#6969); the caller restores. */
|
|
520
|
+
preOutputRefusalHold?: CodexWsPreOutputRefusalHold;
|
|
423
521
|
onResponse?: (
|
|
424
522
|
response: Response,
|
|
425
523
|
authCtx: CodexAuthContext,
|
|
@@ -571,6 +669,10 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
571
669
|
// keep handing it the next request.
|
|
572
670
|
const recordUnmovedTransientOutcome = (): void => {
|
|
573
671
|
if (!isTransientUpstreamStatus(outcomeStatus)) return;
|
|
672
|
+
// #6969: one write per projection. After recordFirstOutcome, a late window or permit denial
|
|
673
|
+
// must not record the same refusal again, and a recorded projection tells delivery to skip it.
|
|
674
|
+
if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
|
|
675
|
+
noteCodexWsPreOutputRefusalOutcome(firstResponse);
|
|
574
676
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
575
677
|
threadId: firstAuthCtx.affinityKey,
|
|
576
678
|
fixedAccount: firstAuthCtx.fixedAccount,
|
|
@@ -763,6 +865,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
763
865
|
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
764
866
|
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
765
867
|
});
|
|
868
|
+
noteCodexWsPreOutputRefusalOutcome(firstResponse);
|
|
766
869
|
};
|
|
767
870
|
// Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
|
|
768
871
|
// ordinary requests must block the first account before the alternate send.
|
|
@@ -918,6 +1021,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
918
1021
|
httpOnly,
|
|
919
1022
|
providerName: route.providerName,
|
|
920
1023
|
modelId: route.modelId,
|
|
1024
|
+
preOutputRefusalHold: args.preOutputRefusalHold,
|
|
921
1025
|
onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
|
|
922
1026
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
923
1027
|
? createCodexAuthDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { normalizeUpstreamErrorText } from "./core-errors";
|
|
15
15
|
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
16
16
|
import { formatErrorResponse } from "../../bridge";
|
|
17
|
+
import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
|
|
17
18
|
import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
|
|
18
19
|
import { usageFromResponsesPayload } from "../request-log";
|
|
19
20
|
import type { ResponsesTerminalStatus } from "../../bridge";
|
|
@@ -121,6 +122,7 @@ export async function consumeComboFailure(
|
|
|
121
122
|
classificationText,
|
|
122
123
|
...(codexModelRefusal !== undefined ? { codexModelRefusal } : {}),
|
|
123
124
|
...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
|
|
125
|
+
...(upstreamMessage !== undefined ? { upstreamMessage } : {}),
|
|
124
126
|
...(upstreamType !== undefined ? { upstreamType } : {}),
|
|
125
127
|
...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
|
|
126
128
|
// The EFFECTIVE classification decides, not the raw status. An upstream that wraps a quota
|
|
@@ -221,3 +223,15 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
|
221
223
|
childHeaders.delete("content-encoding");
|
|
222
224
|
return childHeaders;
|
|
223
225
|
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A member's local Ollama late-attribution refusal: the exact proxy-owned constant on the exact
|
|
229
|
+
* local wire shape, from a replayable attempt. The caller replaces only the client response with
|
|
230
|
+
* the literal constant envelope; classification inputs are never rewritten.
|
|
231
|
+
*/
|
|
232
|
+
export function isLateAttributionRefusal(failure: ConsumedComboFailure): boolean {
|
|
233
|
+
return !failure.nonReplayable
|
|
234
|
+
&& failure.response.status === 413
|
|
235
|
+
&& failure.upstreamCode === "request_too_large"
|
|
236
|
+
&& failure.upstreamMessage === OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE;
|
|
237
|
+
}
|
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
buildComboChildHeaders,
|
|
81
81
|
createChildPassthroughCallbackGate,
|
|
82
82
|
consumeComboFailure,
|
|
83
|
+
isLateAttributionRefusal,
|
|
83
84
|
} from "./core-combo-failure";
|
|
84
85
|
import {
|
|
85
86
|
linkRequestSessionLane,
|
|
@@ -100,7 +101,7 @@ import {
|
|
|
100
101
|
markEagerRelaySseResponse,
|
|
101
102
|
} from "../relay";
|
|
102
103
|
import { preflightComboStreamResponse } from "./combo-stream-preflight";
|
|
103
|
-
import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
|
|
104
|
+
import { streamingContextOverflowResponse, jsonContextOverflowResponse, lateAttributionLimitResponse } from "./context-overflow";
|
|
104
105
|
import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
|
|
105
106
|
import { settleOperatorReplacement } from "../../lib/upstream-retry";
|
|
106
107
|
import { createComboProtocolLanes, dispatchNativeComboChild } from "./core-combo-native";
|
|
@@ -1007,6 +1008,10 @@ export async function executeComboResponses(
|
|
|
1007
1008
|
retainCancelledAttempt();
|
|
1008
1009
|
return clientCancelledResponse();
|
|
1009
1010
|
}
|
|
1011
|
+
// A member's local late-attribution refusal reaches the client only as the literal constant
|
|
1012
|
+
// envelope: swap the response, keep every classification input, and skip the overflow rewrite.
|
|
1013
|
+
const lateAttributionRefusal = isLateAttributionRefusal(failure);
|
|
1014
|
+
if (lateAttributionRefusal) failure = { ...failure, response: lateAttributionLimitResponse() };
|
|
1010
1015
|
sealRequestAttemptIdentity(
|
|
1011
1016
|
attempt,
|
|
1012
1017
|
childLog.provider,
|
|
@@ -1047,7 +1052,7 @@ export async function executeComboResponses(
|
|
|
1047
1052
|
});
|
|
1048
1053
|
const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
|
|
1049
1054
|
// Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
|
|
1050
|
-
const classifyOverflow = failure.response.status === 413
|
|
1055
|
+
const classifyOverflow = failure.response.status === 413 && !lateAttributionRefusal
|
|
1051
1056
|
&& (wantsStream || (failure.upstreamCode !== "outbound_body_too_large"
|
|
1052
1057
|
&& failure.upstreamCode !== "translation_buffer_limit"));
|
|
1053
1058
|
lastFailureClassifiesOverflow = classifyOverflow;
|
|
@@ -2,7 +2,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
|
2
2
|
import { redactSecretString } from "../../lib/redact";
|
|
3
3
|
import { isCyberPolicyMessage, isCyberPolicyCode } from "../../lib/errors";
|
|
4
4
|
import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
|
|
5
|
-
import { formatErrorResponse } from "../../bridge";
|
|
5
|
+
import { formatErrorResponse, formatUsageLimitReachedResponse } from "../../bridge";
|
|
6
6
|
import {
|
|
7
7
|
UnsupportedContentEncodingError,
|
|
8
8
|
DecompressedBodyTooLargeError,
|
|
@@ -132,6 +132,34 @@ export function clientCancelledResponse(): Response {
|
|
|
132
132
|
return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The Responses answer to a local Anthropic pool refusal (`AnthropicAccountCooldownError`).
|
|
137
|
+
*
|
|
138
|
+
* When every held account is out on spent quota the refusal is a usage limit (#6991), which the
|
|
139
|
+
* Codex client stops on and renders with its reset time; anything else stays the retryable
|
|
140
|
+
* `rate_limit_error` it always was. A combo child keeps the retryable form and wording: the combo
|
|
141
|
+
* hops on any 429 and rebuilds the body anyway, and `isAnthropicPoolLocalRefusal` in
|
|
142
|
+
* `core-combo.ts` deliberately cools the target for the short local fallback, which a usage-limit
|
|
143
|
+
* code or usage-limit prose would stretch to the ten-minute exhaustion fallback in
|
|
144
|
+
* `combos/failover.ts`.
|
|
145
|
+
*/
|
|
146
|
+
export function anthropicPoolCooldownResponse(
|
|
147
|
+
refusal: {
|
|
148
|
+
message: string;
|
|
149
|
+
retryableMessage: string;
|
|
150
|
+
retryAfterSeconds: number | null;
|
|
151
|
+
usageLimit: { readonly resetsAt: number | null } | null;
|
|
152
|
+
},
|
|
153
|
+
options: { comboAttempt?: boolean; logCtx?: { errorCode?: string } } = {},
|
|
154
|
+
): Response {
|
|
155
|
+
if (refusal.usageLimit !== null && !options.comboAttempt) {
|
|
156
|
+
if (options.logCtx) options.logCtx.errorCode = "usage_limit_reached";
|
|
157
|
+
return formatUsageLimitReachedResponse(refusal.message, refusal.usageLimit.resetsAt, refusal.retryAfterSeconds);
|
|
158
|
+
}
|
|
159
|
+
return formatErrorResponse(429, "rate_limit_error", refusal.retryableMessage,
|
|
160
|
+
refusal.retryAfterSeconds === null ? undefined : { retryAfter: String(refusal.retryAfterSeconds) });
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
|
|
136
164
|
export const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
137
165
|
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
|
|
@@ -30,6 +30,8 @@ export interface ConsumedComboFailure {
|
|
|
30
30
|
codexModelRefusal?: CodexAccountModelRefusal;
|
|
31
31
|
/** Structured upstream `error.code` when present in the failure body. */
|
|
32
32
|
upstreamCode?: string;
|
|
33
|
+
/** Sanitized structured `error.message`; compared only against proxy-owned constants, never emitted. */
|
|
34
|
+
upstreamMessage?: string;
|
|
33
35
|
/** Complete structured provider type, retained for conservative recovery classification. */
|
|
34
36
|
upstreamType?: string;
|
|
35
37
|
/** Valid numeric/date value used only for cooldown calculation. */
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deferred V1 collaboration evidence from Codex's `<tools>` world-state fragment (#6844).
|
|
3
|
+
*
|
|
4
|
+
* When the model supports tool search (every OpenCodex catalog row), codex-rs registers the V1
|
|
5
|
+
* collaboration tools as DEFERRED (core/src/tools/spec_plan.rs, add_collaboration_tools) under the
|
|
6
|
+
* fixed namespace `multi_agent_v1` (core/src/tools/handlers/multi_agents_spec.rs). Neither the wire
|
|
7
|
+
* tools nor the code-mode exec description then mention them. With the deferred_tool_world_state
|
|
8
|
+
* feature on (default off upstream; Codex Desktop sessions were observed receiving it), codex-rs
|
|
9
|
+
* instead tells the model which namespaces it can load, in a developer fragment
|
|
10
|
+
* (core/src/context/world_state/tools.rs):
|
|
11
|
+
*
|
|
12
|
+
* <tools>
|
|
13
|
+
* Deferred tool namespaces:
|
|
14
|
+
* - multi_agent_v1: Tools for spawning and managing sub-agents.
|
|
15
|
+
* </tools>
|
|
16
|
+
*
|
|
17
|
+
* Later turns carry only diffs ("Added deferred tool namespaces:", "Removed deferred tool
|
|
18
|
+
* namespaces:", "No deferred tool namespaces remain."), so membership is replayed in input order.
|
|
19
|
+
* The fragment is capped at 4 KiB: rows may be name-only, and whole rows may be replaced by
|
|
20
|
+
* "... <n> additional namespaces omitted." (tools_budget.rs).
|
|
21
|
+
*
|
|
22
|
+
* The evidence is positive-only and conservative:
|
|
23
|
+
* - only developer messages are read, each text part on its own, and only a part that is exactly
|
|
24
|
+
* one fragment after trimming; text that merely mentions the tag is ignored;
|
|
25
|
+
* - a fragment that starts like one but breaks the line grammar clears what was carried, because a
|
|
26
|
+
* lost removal could have been multi_agent_v1; omitted removals clear for the same reason, and
|
|
27
|
+
* omitted rows never establish a namespace;
|
|
28
|
+
* - it counts only beside a host that can load deferred tools: a genuine code-mode exec or a
|
|
29
|
+
* structured tool_search tool. A caller's own exec or a function merely named tool_search is not
|
|
30
|
+
* one.
|
|
31
|
+
*
|
|
32
|
+
* Capability boundary: the row proves that some deferred multi_agent_v1 tool is registered, not
|
|
33
|
+
* that spawn_agent is. Upstream can split them only through an embedder-supplied ToolPolicy
|
|
34
|
+
* allowlist; its one production allowlist (the guardian reviewer) admits no V1 tool at all, and
|
|
35
|
+
* the V1 depth limit disables the surface as a unit, so Codex CLI and Desktop never send a
|
|
36
|
+
* management-only V1 namespace. A developer item can also be impersonated by whoever writes the
|
|
37
|
+
* conversation. Both cases receive advisory guidance they cannot act on; nothing is enforced here.
|
|
38
|
+
*/
|
|
39
|
+
import type { OcxMessage, OcxParsedRequest, OcxTool } from "../../types";
|
|
40
|
+
import type { CodeModeExecAnalysis } from "./code-mode-collab-surface";
|
|
41
|
+
|
|
42
|
+
const MULTI_AGENT_V1_NAMESPACE = "multi_agent_v1";
|
|
43
|
+
const OPEN_TAG = "<tools>";
|
|
44
|
+
const CLOSE_TAG = "</tools>";
|
|
45
|
+
const SNAPSHOT_HEADER = "Deferred tool namespaces:";
|
|
46
|
+
const ADDED_HEADER = "Added deferred tool namespaces:";
|
|
47
|
+
const REMOVED_HEADER = "Removed deferred tool namespaces:";
|
|
48
|
+
const EMPTY_STATE = "No deferred tool namespaces remain.";
|
|
49
|
+
// A description is opaque: upstream copies its first line verbatim, which may hold any character
|
|
50
|
+
// except \n, including U+2028/U+2029 that `.` would refuse.
|
|
51
|
+
const ROW = /^- ([^:\s]+)(?:: [\s\S]*)?$/;
|
|
52
|
+
const OMISSION = /^\.\.\. \d+ additional namespaces omitted\.$/;
|
|
53
|
+
|
|
54
|
+
type Fragment =
|
|
55
|
+
| { kind: "malformed" }
|
|
56
|
+
| { kind: "snapshot"; rows: Set<string>; empty: boolean }
|
|
57
|
+
| { kind: "diff"; added: Set<string>; removed: Set<string>; removedOmitted: boolean; empty: boolean };
|
|
58
|
+
|
|
59
|
+
/** One standalone fragment, a malformed one, or nothing (text that is not a fragment). */
|
|
60
|
+
function parseFragment(text: string): Fragment | null {
|
|
61
|
+
const trimmed = text.trim();
|
|
62
|
+
if (!trimmed.startsWith(OPEN_TAG)) return null;
|
|
63
|
+
if (!trimmed.endsWith(CLOSE_TAG) || trimmed.length < OPEN_TAG.length + CLOSE_TAG.length) return { kind: "malformed" };
|
|
64
|
+
const body = trimmed.slice(OPEN_TAG.length, trimmed.length - CLOSE_TAG.length);
|
|
65
|
+
let group: "snapshot" | "added" | "removed" | undefined;
|
|
66
|
+
// Upstream renders each group at most once, Added before Removed, and skips empty groups.
|
|
67
|
+
const seen = new Set<"snapshot" | "added" | "removed">();
|
|
68
|
+
let groupHasEntries = true;
|
|
69
|
+
let empty = false;
|
|
70
|
+
const rows = { snapshot: new Set<string>(), added: new Set<string>(), removed: new Set<string>() };
|
|
71
|
+
let removedOmitted = false;
|
|
72
|
+
const open = (next: "snapshot" | "added" | "removed"): boolean => {
|
|
73
|
+
if (seen.has(next) || !groupHasEntries) return false;
|
|
74
|
+
if (next === "added" && seen.has("removed")) return false;
|
|
75
|
+
seen.add(next);
|
|
76
|
+
group = next;
|
|
77
|
+
groupHasEntries = false;
|
|
78
|
+
return true;
|
|
79
|
+
};
|
|
80
|
+
for (const raw of body.split("\n")) {
|
|
81
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
82
|
+
if (line === "") continue;
|
|
83
|
+
// The empty-state notice closes the fragment; nothing may follow it.
|
|
84
|
+
if (empty) return { kind: "malformed" };
|
|
85
|
+
if (line === SNAPSHOT_HEADER) { if (!open("snapshot")) return { kind: "malformed" }; continue; }
|
|
86
|
+
if (line === ADDED_HEADER) { if (!open("added")) return { kind: "malformed" }; continue; }
|
|
87
|
+
if (line === REMOVED_HEADER) { if (!open("removed")) return { kind: "malformed" }; continue; }
|
|
88
|
+
if (line === EMPTY_STATE) {
|
|
89
|
+
if (!groupHasEntries) return { kind: "malformed" };
|
|
90
|
+
empty = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (group === undefined) return { kind: "malformed" };
|
|
94
|
+
groupHasEntries = true;
|
|
95
|
+
if (OMISSION.test(line)) {
|
|
96
|
+
if (group === "removed") removedOmitted = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const row = ROW.exec(line);
|
|
100
|
+
if (!row) return { kind: "malformed" };
|
|
101
|
+
rows[group].add(row[1]!);
|
|
102
|
+
}
|
|
103
|
+
if (!groupHasEntries) return { kind: "malformed" };
|
|
104
|
+
const sawSnapshot = seen.has("snapshot");
|
|
105
|
+
const sawDiff = seen.has("added") || seen.has("removed");
|
|
106
|
+
if (sawSnapshot && sawDiff) return { kind: "malformed" };
|
|
107
|
+
if (sawSnapshot) return { kind: "snapshot", rows: rows.snapshot, empty };
|
|
108
|
+
if (!sawDiff && !empty) return { kind: "malformed" };
|
|
109
|
+
for (const name of rows.added) if (rows.removed.has(name)) return { kind: "malformed" };
|
|
110
|
+
return { kind: "diff", added: rows.added, removed: rows.removed, removedOmitted, empty };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function developerTexts(message: OcxMessage): string[] {
|
|
114
|
+
if (message.role !== "developer") return [];
|
|
115
|
+
if (typeof message.content === "string") return [message.content];
|
|
116
|
+
return message.content.flatMap(part => (part.type === "text" ? [part.text] : []));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Replays every fragment in input order; true when multi_agent_v1 is currently known deferred. */
|
|
120
|
+
function deferredNamespacesIncludeV1(messages: readonly OcxMessage[]): boolean {
|
|
121
|
+
const current = new Set<string>();
|
|
122
|
+
for (const message of messages) {
|
|
123
|
+
for (const text of developerTexts(message)) {
|
|
124
|
+
const fragment = parseFragment(text);
|
|
125
|
+
if (fragment === null) continue;
|
|
126
|
+
switch (fragment.kind) {
|
|
127
|
+
case "malformed":
|
|
128
|
+
current.clear();
|
|
129
|
+
break;
|
|
130
|
+
case "snapshot":
|
|
131
|
+
current.clear();
|
|
132
|
+
for (const name of fragment.rows) current.add(name);
|
|
133
|
+
break;
|
|
134
|
+
case "diff":
|
|
135
|
+
if (fragment.removedOmitted) current.clear();
|
|
136
|
+
for (const name of fragment.added) current.add(name);
|
|
137
|
+
for (const name of fragment.removed) current.delete(name);
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
if (fragment.kind !== "malformed" && fragment.empty) current.clear();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return current.has(MULTI_AGENT_V1_NAMESPACE);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function declaresStructuredToolSearch(tools: readonly OcxTool[] | undefined): boolean {
|
|
147
|
+
return (tools ?? []).some(tool => !tool.namespace && tool.toolSearch === true);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* True when the request's own developer history says V1 collaboration tools are deferred and the
|
|
152
|
+
* request comes from a host that can load deferred tools.
|
|
153
|
+
*/
|
|
154
|
+
export function deferredV1Evidence(parsed: OcxParsedRequest, codeMode: CodeModeExecAnalysis): boolean {
|
|
155
|
+
if (!codeMode.genuineExec && !declaresStructuredToolSearch(parsed.context.tools)) return false;
|
|
156
|
+
return deferredNamespacesIncludeV1(parsed.context.messages);
|
|
157
|
+
}
|
|
158
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PhysicalSendReporter } from "../../lib/request-execution-budget";
|
|
2
|
+
|
|
3
|
+
/** Retry thunks can fail in pacing; settle only starts admitted at the physical executor. */
|
|
4
|
+
export function deferPhysicalSendReporter(physical: PhysicalSendReporter) {
|
|
5
|
+
let dispatched = 0;
|
|
6
|
+
let reported = 0;
|
|
7
|
+
const report: PhysicalSendReporter = () => {
|
|
8
|
+
const count = dispatched - reported;
|
|
9
|
+
if (count <= 0) return;
|
|
10
|
+
physical(count);
|
|
11
|
+
reported += count;
|
|
12
|
+
};
|
|
13
|
+
// The retry helper owns attempts, while the physical callback owns spend admission.
|
|
14
|
+
report.beforeSend = () => true;
|
|
15
|
+
report.execute = physical.execute;
|
|
16
|
+
report.close = () => { report(0); physical.close?.(); };
|
|
17
|
+
return {
|
|
18
|
+
report,
|
|
19
|
+
beforeDispatch(): boolean {
|
|
20
|
+
if (physical.beforeSend && !physical.beforeSend()) return false;
|
|
21
|
+
dispatched++;
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeResponseControl } from "./native-response-control";
|
|
2
|
+
import type { CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
|
|
2
3
|
import type { Server } from "bun";
|
|
3
4
|
import {
|
|
4
5
|
codexWsUpstreamFetch,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
type ProviderEgressBinding,
|
|
29
30
|
} from "../../lib/provider-egress";
|
|
30
31
|
import { redactSecretString } from "../../lib/redact";
|
|
32
|
+
import { outboundCredentials } from "../../lib/outbound-credential-registry";
|
|
31
33
|
|
|
32
34
|
export { withUpstreamHttpVersion };
|
|
33
35
|
|
|
@@ -205,6 +207,9 @@ export function sendWithConnectionPolicy(
|
|
|
205
207
|
&& /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
|
|
206
208
|
&& Buffer.byteLength(body, "utf8") >= 1024 * 1024;
|
|
207
209
|
(init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
|
|
210
|
+
// Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
|
|
211
|
+
// request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
|
|
212
|
+
outboundCredentials().remember(headers);
|
|
208
213
|
return physicalFetch(input, {
|
|
209
214
|
...init,
|
|
210
215
|
...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
|
|
@@ -240,6 +245,12 @@ export interface ProviderFetchOptions {
|
|
|
240
245
|
beforeDispatch?: (headers: Headers) => void;
|
|
241
246
|
/** One-shot receipt after final local HTTP admission, immediately before executor entry. */
|
|
242
247
|
onPhysicalDispatch?: () => void;
|
|
248
|
+
/**
|
|
249
|
+
* Opt-in (#6969): let a Codex WebSocket send hold empty lifecycle frames so an authoritative
|
|
250
|
+
* upstream refusal before any output settles as a replayable gateway status. Only a caller that
|
|
251
|
+
* restores the projection afterwards (restoreCodexWsPreOutputRefusal) may set it.
|
|
252
|
+
*/
|
|
253
|
+
preOutputRefusalHold?: CodexWsPreOutputRefusalHold;
|
|
243
254
|
/** Revalidate/rebuild a queued request at its physical send boundary, after pacing. */
|
|
244
255
|
dispatchOverride?: (input: Parameters<typeof globalThis.fetch>[0], init: RequestInit, execute: typeof globalThis.fetch) => Promise<Response>;
|
|
245
256
|
}
|
|
@@ -356,7 +367,7 @@ export function providerFetch(
|
|
|
356
367
|
const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
|
|
357
368
|
httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit)) as typeof globalThis.fetch;
|
|
358
369
|
return codexWsUpstreamFetch(input, init, fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
|
|
359
|
-
async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce);
|
|
370
|
+
async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold);
|
|
360
371
|
}
|
|
361
372
|
return httpFetch(input, init);
|
|
362
373
|
};
|
|
@@ -408,6 +408,7 @@ export async function deliverPassthroughResponse(
|
|
|
408
408
|
| "noteInspectedPayload"
|
|
409
409
|
| "normalizeFunctionCompletionJson"
|
|
410
410
|
| "localUpstream"
|
|
411
|
+
| "terminalOutcomeRecorded"
|
|
411
412
|
>,
|
|
412
413
|
): Promise<Response> {
|
|
413
414
|
const { route } = requestState;
|
|
@@ -486,7 +487,9 @@ export async function deliverPassthroughResponse(
|
|
|
486
487
|
? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => {
|
|
487
488
|
if (terminalOutcomeRecorded) return;
|
|
488
489
|
terminalOutcomeRecorded = true;
|
|
489
|
-
|
|
490
|
+
// A restored pre-output refusal whose account outcome dispatch already wrote (#6969) keeps
|
|
491
|
+
// every other terminal callback but must not write the same refusal a second time.
|
|
492
|
+
if (!nativeExchange.terminalOutcomeRecorded) recordTerminalOutcome(status, httpStatusOverride);
|
|
490
493
|
}
|
|
491
494
|
: undefined;
|
|
492
495
|
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
@@ -91,7 +91,7 @@ import {
|
|
|
91
91
|
safeHostLabel,
|
|
92
92
|
} from "./fetch-helpers";
|
|
93
93
|
import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
|
|
94
|
-
import { clientCancelledResponse } from "./core-errors";
|
|
94
|
+
import { anthropicPoolCooldownResponse, clientCancelledResponse } from "./core-errors";
|
|
95
95
|
import {
|
|
96
96
|
upstreamHostCircuitOpenResponse,
|
|
97
97
|
usesCodexForwardPoolAuth,
|
|
@@ -101,6 +101,8 @@ import {
|
|
|
101
101
|
shouldRetryCodexPoolAccountQuota,
|
|
102
102
|
shouldRetryCodexPoolAccountTransient,
|
|
103
103
|
retryCodexPoolOnAlternateAccount,
|
|
104
|
+
PoolPreOutputRefusals,
|
|
105
|
+
poolPreOutputRefusalHold,
|
|
104
106
|
} from "./core-codex-account";
|
|
105
107
|
import {
|
|
106
108
|
clearCodexModelDenialEvidence,
|
|
@@ -949,8 +951,7 @@ export async function preparePassthroughExchange(
|
|
|
949
951
|
return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(refusal));
|
|
950
952
|
}
|
|
951
953
|
if (refusal instanceof AnthropicAccountCooldownError) {
|
|
952
|
-
return
|
|
953
|
-
refusal.retryAfterSeconds === null ? undefined : { retryAfter: String(refusal.retryAfterSeconds) });
|
|
954
|
+
return anthropicPoolCooldownResponse(refusal, { comboAttempt: options.comboAttempt, logCtx });
|
|
954
955
|
}
|
|
955
956
|
return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(refusal));
|
|
956
957
|
}
|
|
@@ -1005,6 +1006,9 @@ export async function preparePassthroughExchange(
|
|
|
1005
1006
|
// Stable per request: the Combo booking (`reserveDispatch`) already started and froze the spend
|
|
1006
1007
|
// policy, so `spendEnforced` cannot change before dispatch even though no reporter starts it here.
|
|
1007
1008
|
const receiptMode = !sendBudgetState.adapterSendBudget?.spendEnforced && Boolean(options.comboInitialSend);
|
|
1009
|
+
// #6969: Pool sends hold empty lifecycle frames so a pre-output refusal reaches the ladder below.
|
|
1010
|
+
const preOutputRefusals = new PoolPreOutputRefusals();
|
|
1011
|
+
const preOutputRefusalHold = () => poolPreOutputRefusalHold(admissionState.authCtx, route.provider, connectMs, options.comboAttempt);
|
|
1008
1012
|
try {
|
|
1009
1013
|
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
1010
1014
|
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
@@ -1035,11 +1039,12 @@ export async function preparePassthroughExchange(
|
|
|
1035
1039
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1036
1040
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1037
1041
|
? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
1042
|
+
preOutputRefusalHold: preOutputRefusalHold(),
|
|
1038
1043
|
}),
|
|
1039
1044
|
route.provider.authMode === "forward")
|
|
1040
1045
|
// Every real attempt response — including an intermediate 5xx the
|
|
1041
1046
|
// retry wrapper replaces — proves the host was reached (#914 review).
|
|
1042
|
-
.then(adoptObservedResponse);
|
|
1047
|
+
.then(adoptObservedResponse).then(preOutputRefusals.observe);
|
|
1043
1048
|
},
|
|
1044
1049
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url),
|
|
1045
1050
|
attempts: sendBudgetState.initialSendAllowance(transientSendAttempts()),
|
|
@@ -1050,6 +1055,8 @@ export async function preparePassthroughExchange(
|
|
|
1050
1055
|
},
|
|
1051
1056
|
);
|
|
1052
1057
|
} catch (err) {
|
|
1058
|
+
preOutputRefusals.recordOnLocalExit(err, options.abortSignal?.aborted === true || req.signal.aborted,
|
|
1059
|
+
config, admissionState.authCtx, route.provider, route.modelId);
|
|
1053
1060
|
return transportFailureResponse(err);
|
|
1054
1061
|
} finally {
|
|
1055
1062
|
request.releaseBodyObservation?.();
|
|
@@ -1676,6 +1683,7 @@ export async function preparePassthroughExchange(
|
|
|
1676
1683
|
passthroughEstimate,
|
|
1677
1684
|
stream: upstreamRequestsStream,
|
|
1678
1685
|
httpOnly: canonicalBufferedJson,
|
|
1686
|
+
preOutputRefusalHold: preOutputRefusalHold(),
|
|
1679
1687
|
onResponse: (response, retryAuthCtx, retryRequest) => {
|
|
1680
1688
|
adoptCodexWsStage(response);
|
|
1681
1689
|
captureAffinityResponse(
|
|
@@ -1702,6 +1710,8 @@ export async function preparePassthroughExchange(
|
|
|
1702
1710
|
}
|
|
1703
1711
|
}
|
|
1704
1712
|
}
|
|
1713
|
+
// #6969: a projection nothing above recovered goes on as the original stream, before any reader.
|
|
1714
|
+
upstreamResponse = preOutputRefusals.restore(upstreamResponse);
|
|
1705
1715
|
// The deterministic route record cannot classify history it never observed (restart, expiry,
|
|
1706
1716
|
// eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound
|
|
1707
1717
|
// Responses body still carries opaque state, then rebuild once through the ordinary adapter
|
|
@@ -1969,6 +1979,7 @@ export async function preparePassthroughExchange(
|
|
|
1969
1979
|
connectMs,
|
|
1970
1980
|
localUpstream,
|
|
1971
1981
|
upstreamResponse,
|
|
1982
|
+
terminalOutcomeRecorded: preOutputRefusals.outcomeRecorded,
|
|
1972
1983
|
};
|
|
1973
1984
|
}
|
|
1974
1985
|
|
|
@@ -33,6 +33,7 @@ import { resolveContextPrincipal } from "../auth-cors";
|
|
|
33
33
|
import { resolveSkillsSnapshotScopeKey, snapshotSkillsCatalogInBody } from "./skills-snapshot";
|
|
34
34
|
import {
|
|
35
35
|
isShadowSourceModel,
|
|
36
|
+
shadowCallPreservesSelectedModel,
|
|
36
37
|
shadowSourceModelPrefix,
|
|
37
38
|
shouldInterceptShadowCall,
|
|
38
39
|
} from "../../lib/shadow-call";
|
|
@@ -275,10 +276,11 @@ export async function prepareResponsesRequest(
|
|
|
275
276
|
}
|
|
276
277
|
}
|
|
277
278
|
let shadowCallIntercepted = false;
|
|
278
|
-
//
|
|
279
|
-
//
|
|
279
|
+
// Luna is also selectable by users/children. Both intercept sites use the same
|
|
280
|
+
// per-turn provenance; WS frames must not inherit the handshake's classification.
|
|
280
281
|
const threadSpawn = isThreadSpawnRequest(req.headers);
|
|
281
|
-
|
|
282
|
+
const preserveShadowModel = shadowCallPreservesSelectedModel(body, req.headers, options.inboundTransport);
|
|
283
|
+
if (!concreteSelection && !options.compactionRoutingOverride && !options.internalDecisionCall && !preserveShadowModel && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) {
|
|
282
284
|
const shadowIntercept = config.shadowCallIntercept;
|
|
283
285
|
const rawShadowModel = (body as { model?: unknown }).model;
|
|
284
286
|
if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
|
|
@@ -613,7 +615,7 @@ export async function prepareResponsesRequest(
|
|
|
613
615
|
}
|
|
614
616
|
const _sci = config.shadowCallIntercept;
|
|
615
617
|
let shadowRoute: RouteResult | undefined;
|
|
616
|
-
if (!concreteSelection && !memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !options.internalDecisionCall && !
|
|
618
|
+
if (!concreteSelection && !memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !options.internalDecisionCall && !preserveShadowModel && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
|
|
617
619
|
const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
|
|
618
620
|
let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
|
|
619
621
|
try {
|