@bitkyc08/opencodex 2.56.0 → 2.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +32 -1
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +7 -1
- package/src/bridge/sse.ts +19 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +157 -7
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/restore.ts +29 -2
- package/src/codex/inject.ts +9 -9
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/pending-teardown.ts +31 -0
- package/src/generated/compatibility-version.json +163 -135
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/registry/entries-core.ts +7 -0
- package/src/providers/registry/entries-extended.ts +9 -0
- package/src/providers/registry/model-seeds.ts +4 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +6 -2
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +103 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +19 -6
- package/src/server/responses/passthrough-dispatch.ts +28 -10
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +132 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +4 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
1
2
|
import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
|
|
2
3
|
import type { PreparedResponsesRequest } from "./request-prepare";
|
|
3
4
|
import type { ResponsesTransport } from "./request-transport";
|
|
@@ -11,7 +12,6 @@ import { trackStreamLifetime } from "../lifecycle";
|
|
|
11
12
|
import {
|
|
12
13
|
recordAdapterReasoning,
|
|
13
14
|
recordAdapterTier,
|
|
14
|
-
noteAttemptSend,
|
|
15
15
|
sealRequestAttemptIdentity,
|
|
16
16
|
recordAttemptCredentialSource,
|
|
17
17
|
} from "../request-log";
|
|
@@ -72,7 +72,12 @@ import { consumeComboFailure } from "./core-combo-failure";
|
|
|
72
72
|
import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
|
|
73
73
|
import { isFixedCodexAccount } from "./core-codex-account";
|
|
74
74
|
import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
isCyberPolicyCode,
|
|
77
|
+
CYBER_POLICY_FALLBACK_MESSAGE,
|
|
78
|
+
CYBER_POLICY_ERROR_CODE,
|
|
79
|
+
SEND_BUDGET_EXHAUSTED_CODE,
|
|
80
|
+
} from "../../lib/errors";
|
|
76
81
|
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
77
82
|
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
78
83
|
|
|
@@ -115,11 +120,12 @@ export async function prepareAdapterExchange(
|
|
|
115
120
|
| "genericFailoverAccountId"
|
|
116
121
|
| "genericFailovers"
|
|
117
122
|
| "applyFailoverSnapshot"
|
|
123
|
+
| "noteRoutedAttemptSend"
|
|
118
124
|
>,
|
|
119
125
|
responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
|
|
120
126
|
sendBudgetState: Pick<
|
|
121
127
|
ResponsesSendBudget,
|
|
122
|
-
| "
|
|
128
|
+
| "adapterDispatchBudget"
|
|
123
129
|
| "noteAdapterPhysicalSend"
|
|
124
130
|
| "remainingTransientSendBudget"
|
|
125
131
|
| "noteTransientSends"
|
|
@@ -127,6 +133,7 @@ export async function prepareAdapterExchange(
|
|
|
127
133
|
| "recoveryClassFor"
|
|
128
134
|
| "sendBudgetExhausted"
|
|
129
135
|
| "reserveCredentialHop"
|
|
136
|
+
| "pendingHopPermit"
|
|
130
137
|
>,
|
|
131
138
|
) {
|
|
132
139
|
const { options, config, logCtx, req } = requestContext;
|
|
@@ -151,7 +158,7 @@ export async function prepareAdapterExchange(
|
|
|
151
158
|
} = requestState;
|
|
152
159
|
const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
|
|
153
160
|
const {
|
|
154
|
-
|
|
161
|
+
adapterDispatchBudget,
|
|
155
162
|
noteAdapterPhysicalSend,
|
|
156
163
|
remainingTransientSendBudget,
|
|
157
164
|
noteTransientSends,
|
|
@@ -272,15 +279,16 @@ export async function prepareAdapterExchange(
|
|
|
272
279
|
let upstreamResponse: Response;
|
|
273
280
|
try {
|
|
274
281
|
if (transportState.activeAdapter.fetchResponse) {
|
|
275
|
-
|
|
282
|
+
transportState.noteRoutedAttemptSend(inputTokenEstimate);
|
|
276
283
|
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
277
284
|
upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
|
|
278
285
|
abortSignal: upstream.signal,
|
|
279
286
|
timeoutMs: connectMs,
|
|
280
|
-
sendBudget:
|
|
287
|
+
sendBudget: adapterDispatchBudget,
|
|
281
288
|
onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
|
|
282
289
|
stream: parsed.stream,
|
|
283
290
|
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
291
|
+
pacingSlotAcquired: true,
|
|
284
292
|
dispatchOverride: oauthDispatch(builtInitialRequest),
|
|
285
293
|
providerName: route.providerName,
|
|
286
294
|
modelId: route.modelId,
|
|
@@ -300,7 +308,7 @@ export async function prepareAdapterExchange(
|
|
|
300
308
|
: fetchWithResetRetry;
|
|
301
309
|
upstreamResponse = await fetchWithRetryPolicy(
|
|
302
310
|
recovery => {
|
|
303
|
-
|
|
311
|
+
transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
|
|
304
312
|
return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
|
|
305
313
|
method: builtInitialRequest.method,
|
|
306
314
|
headers: builtInitialRequest.headers,
|
|
@@ -331,6 +339,13 @@ export async function prepareAdapterExchange(
|
|
|
331
339
|
cleanupUpstreamAbort();
|
|
332
340
|
upstream.abort();
|
|
333
341
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
342
|
+
// A budget refusal is a decision this process made, not an upstream fault. Reporting it as
|
|
343
|
+
// 502 does more than mislabel it: the Codex client retries 5xx and does not retry a 429, so
|
|
344
|
+
// blaming the provider makes the caller send the whole turn again -- the amplification this
|
|
345
|
+
// budget exists to stop. The passthrough path has answered 429 here since #4546.
|
|
346
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
347
|
+
return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message);
|
|
348
|
+
}
|
|
334
349
|
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
335
350
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
336
351
|
} finally {
|
|
@@ -412,10 +427,10 @@ export async function prepareAdapterExchange(
|
|
|
412
427
|
logCtx.providerAdapter = transportState.activeAdapter.name;
|
|
413
428
|
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
|
|
414
429
|
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
|
|
415
|
-
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
416
430
|
try {
|
|
417
431
|
try {
|
|
418
432
|
if (transportState.activeAdapter.fetchResponse) {
|
|
433
|
+
transportState.noteRoutedAttemptSend(retryEstimate, recovery);
|
|
419
434
|
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
420
435
|
// The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
|
|
421
436
|
// an abort, a saturated queue, an expired slot or a removed provider, and none of
|
|
@@ -425,10 +440,11 @@ export async function prepareAdapterExchange(
|
|
|
425
440
|
return await transportState.activeAdapter.fetchResponse(retryRequest, {
|
|
426
441
|
abortSignal: upstream.signal,
|
|
427
442
|
timeoutMs: connectMs,
|
|
428
|
-
sendBudget:
|
|
443
|
+
sendBudget: adapterDispatchBudget,
|
|
429
444
|
onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
|
|
430
445
|
stream: parsed.stream,
|
|
431
446
|
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
447
|
+
pacingSlotAcquired: true,
|
|
432
448
|
dispatchOverride: oauthDispatch(retryRequest),
|
|
433
449
|
providerName: route.providerName,
|
|
434
450
|
modelId: route.modelId,
|
|
@@ -461,6 +477,7 @@ export async function prepareAdapterExchange(
|
|
|
461
477
|
if (refetchAllowance?.permit && !refetchAllowance.permit.use()) {
|
|
462
478
|
throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
|
|
463
479
|
}
|
|
480
|
+
transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
|
|
464
481
|
// Same boundary on the helper path: the thunk is what reaches the wire, and it
|
|
465
482
|
// can be refused above before it does. use() past the first attempt is a no-op.
|
|
466
483
|
onDispatch?.();
|
|
@@ -499,12 +516,23 @@ export async function prepareAdapterExchange(
|
|
|
499
516
|
if (options.abortSignal?.aborted) {
|
|
500
517
|
return { failed: clientCancelledResponse() };
|
|
501
518
|
}
|
|
519
|
+
// Same rule on the recovery leg: the ladder refused to send again, so the answer names
|
|
520
|
+
// this proxy rather than the provider it never reached.
|
|
521
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
522
|
+
return { failed: formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message) };
|
|
523
|
+
}
|
|
502
524
|
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
503
525
|
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
504
526
|
}
|
|
505
527
|
};
|
|
506
528
|
// Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
|
|
507
529
|
recovery: for (;;) {
|
|
530
|
+
// Preserve the terminal verdict through adapter and combo error formatting.
|
|
531
|
+
// This also covers a reset reached by a 401/429/413 recovery refetch.
|
|
532
|
+
if (isNonReplayableResponse(upstreamResponse)) {
|
|
533
|
+
cleanupUpstreamAbort();
|
|
534
|
+
return upstreamResponse;
|
|
535
|
+
}
|
|
508
536
|
if (
|
|
509
537
|
upstreamResponse.status === 401
|
|
510
538
|
&& isOAuth401ReplayProvider
|
|
@@ -593,6 +621,11 @@ export async function prepareAdapterExchange(
|
|
|
593
621
|
const result = await rebuildAndRefetch("key-401");
|
|
594
622
|
if ("failed" in result) return result.failed;
|
|
595
623
|
upstreamResponse = result;
|
|
624
|
+
// A recovery refetch can itself die on an ambiguous pre-header reset, and the refusal
|
|
625
|
+
// that answers it is a 429. Every arm below keys on 429, so letting it fall through
|
|
626
|
+
// hands the marked refusal to the next waiting arm and replays the send it exists to
|
|
627
|
+
// stop. Re-enter the loop guard instead, which returns it unchanged.
|
|
628
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
596
629
|
}
|
|
597
630
|
|
|
598
631
|
// Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
|
|
@@ -633,6 +666,9 @@ export async function prepareAdapterExchange(
|
|
|
633
666
|
const result = await rebuildAndRefetch("rate-limit-429");
|
|
634
667
|
if ("failed" in result) return result.failed;
|
|
635
668
|
upstreamResponse = result;
|
|
669
|
+
// The refusal is a 429 too: without this the while condition is still true and the
|
|
670
|
+
// next configured attempt replays it on the same target.
|
|
671
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
636
672
|
}
|
|
637
673
|
|
|
638
674
|
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
@@ -664,6 +700,9 @@ export async function prepareAdapterExchange(
|
|
|
664
700
|
const result = await rebuildAndRefetch("key-429");
|
|
665
701
|
if ("failed" in result) return result.failed;
|
|
666
702
|
upstreamResponse = result;
|
|
703
|
+
// Rotating on the refusal would also write a cooldown against a key that rate-limited
|
|
704
|
+
// nothing, which outlives the request.
|
|
705
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
667
706
|
}
|
|
668
707
|
|
|
669
708
|
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
@@ -700,6 +739,7 @@ export async function prepareAdapterExchange(
|
|
|
700
739
|
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
701
740
|
if ("failed" in result) return result.failed;
|
|
702
741
|
upstreamResponse = result;
|
|
742
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
703
743
|
} catch {
|
|
704
744
|
break;
|
|
705
745
|
}
|
|
@@ -722,9 +762,23 @@ export async function prepareAdapterExchange(
|
|
|
722
762
|
// rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
|
|
723
763
|
// an allowance the rest of the request cannot see. A refusal ends the ladder with the
|
|
724
764
|
// real 429 already in hand, which is the decided exhaustion contract.
|
|
765
|
+
//
|
|
766
|
+
// Who settles this reservation depends on who dispatches the replay (#4709). An
|
|
767
|
+
// adapter that owns its ladder -- Kiro's reset loop, Cursor's transport loop --
|
|
768
|
+
// reserves once per physical send and would charge the same replay again; the helper
|
|
769
|
+
// path reports it again through `onSendsConsumed`. Both turned one physical send into
|
|
770
|
+
// two charges, and once the allowance was spent, into a synthetic error in place of
|
|
771
|
+
// the 429 this hop was recovering from. The wire protocol is resolved from the
|
|
772
|
+
// provider and model, not from the account, so an account rotation cannot move the
|
|
773
|
+
// replay between these two shapes.
|
|
774
|
+
const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
|
|
725
775
|
const hop = reserveCredentialHop(
|
|
726
776
|
"auth-recovery",
|
|
727
777
|
`${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
|
|
778
|
+
// Only a helper-routed replay reports this send back. A reset-only refetch reports
|
|
779
|
+
// nothing and an adapter ladder settles the booking itself, so promising an external
|
|
780
|
+
// report on either would leave a booking pending until it swallowed a later charge.
|
|
781
|
+
!adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
|
|
728
782
|
);
|
|
729
783
|
if (!hop.allowed) break;
|
|
730
784
|
const nextAccountId = rotateGenericOAuthAccountOn429(
|
|
@@ -755,16 +809,33 @@ export async function prepareAdapterExchange(
|
|
|
755
809
|
);
|
|
756
810
|
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
|
|
757
811
|
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
812
|
+
// The replay IS this hop's send, so hand the reservation down and let the layer that
|
|
813
|
+
// dispatches settle it: `adapterDispatchBudget` spends it on the adapter's first
|
|
814
|
+
// reservation, and the retry helper's reporter settles the external booking.
|
|
815
|
+
sendBudgetState.pendingHopPermit = hop.permit;
|
|
816
|
+
let result: Response | { failed: Response };
|
|
817
|
+
try {
|
|
818
|
+
// Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
|
|
819
|
+
// request and return `{ failed }` without reaching the wire, and a permit confirmed
|
|
820
|
+
// before that would hold the charge for a send that never happened. An
|
|
821
|
+
// adapter-owned ladder is the exception -- its own reservation is the confirmation,
|
|
822
|
+
// and settling here first would hand it a dead permit, which it reads as an
|
|
823
|
+
// exhausted request and stops sending on.
|
|
824
|
+
result = await rebuildAndRefetch("oauth-account-429", () => {
|
|
825
|
+
if (!adapterOwnsDispatch) hop.permit?.use();
|
|
826
|
+
});
|
|
827
|
+
} finally {
|
|
828
|
+
sendBudgetState.pendingHopPermit = undefined;
|
|
829
|
+
}
|
|
762
830
|
if ("failed" in result) {
|
|
763
831
|
// A no-op if the boundary was reached; a refund if the rebuild died before it.
|
|
764
832
|
hop.permit?.release();
|
|
765
833
|
return result.failed;
|
|
766
834
|
}
|
|
767
835
|
upstreamResponse = result;
|
|
836
|
+
// The hop's permit is already settled by the dispatch boundary above; continuing
|
|
837
|
+
// only skips the remaining arms, it does not abandon a reservation.
|
|
838
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
768
839
|
} catch {
|
|
769
840
|
// A throw before the send — snapshot fetch, credential application, adapter
|
|
770
841
|
// resolution — must hand the reservation back. Without this the ladder charges the
|
|
@@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
|
|
|
108
108
|
const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
|
|
109
109
|
|
|
110
110
|
export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
|
|
111
|
+
const current = codexWsStageByResponse.get(response);
|
|
112
|
+
if (current) {
|
|
113
|
+
Object.assign(current, record);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
111
116
|
codexWsStageByResponse.set(response, record);
|
|
112
117
|
}
|
|
113
118
|
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../../combos";
|
|
31
31
|
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
32
32
|
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
33
|
-
import { dottedToolName, modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
|
|
33
|
+
import { dottedToolName, modelInList, namespacedToolName, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, toolChoiceToolPredicate } from "../../types";
|
|
34
34
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
35
35
|
import {
|
|
36
36
|
forceRefreshOAuthAccessSnapshot,
|
|
@@ -83,7 +83,6 @@ import {
|
|
|
83
83
|
catalogModelSupportsServiceTier,
|
|
84
84
|
finishRequestAttempt,
|
|
85
85
|
inspectResponseLogJson,
|
|
86
|
-
noteAttemptSend,
|
|
87
86
|
readConfiguredCodexServiceTier,
|
|
88
87
|
requestLogSpeedLabel,
|
|
89
88
|
sealRequestAttemptIdentity,
|
|
@@ -152,6 +151,36 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
152
151
|
dottedAliasOwners.set(t.name, null);
|
|
153
152
|
}
|
|
154
153
|
}
|
|
154
|
+
// Bare echo alias (`name` with no namespace spelling, #4679): some providers — observed
|
|
155
|
+
// on the muse family via Command Code — echo a namespaced tool by its bare name. The
|
|
156
|
+
// bare spelling is only a safe alias while it names ONE tool and cannot be read as
|
|
157
|
+
// another identity's canonical or dotted spelling.
|
|
158
|
+
// Code-mode helper spellings never gain a bare alias (#4679 review), whatever namespace
|
|
159
|
+
// declares them and whatever put the alias there. The list is owned by `src/types/tools.ts`,
|
|
160
|
+
// beside the names it protects, because the copy that used to live here drifted to a single
|
|
161
|
+
// namespace and had to be widened twice.
|
|
162
|
+
const bareAliasOwners = new Map<string, string | null>();
|
|
163
|
+
for (const t of authorizedTools) {
|
|
164
|
+
// Bare (no-namespace) declarations participate as owners too: a namespaced tool whose
|
|
165
|
+
// bare name equals a bare-declared function must not gain the bare alias, mirroring how
|
|
166
|
+
// the tool_choice bare path refuses ambiguous owners across the whole request catalog.
|
|
167
|
+
const identity = JSON.stringify([t.namespace ?? null, t.name]);
|
|
168
|
+
const owner = bareAliasOwners.get(t.name);
|
|
169
|
+
if (owner === undefined) bareAliasOwners.set(t.name, identity);
|
|
170
|
+
else if (owner !== identity) bareAliasOwners.set(t.name, null);
|
|
171
|
+
}
|
|
172
|
+
for (const t of authorizedTools) {
|
|
173
|
+
const canonical = namespacedToolName(t.namespace, t.name);
|
|
174
|
+
const owner = bareAliasOwners.get(canonical);
|
|
175
|
+
if (owner !== undefined && owner !== JSON.stringify([t.namespace, t.name])) {
|
|
176
|
+
bareAliasOwners.set(canonical, null);
|
|
177
|
+
}
|
|
178
|
+
const dotted = dottedToolName(t.namespace, t.name);
|
|
179
|
+
const dottedOwner = bareAliasOwners.get(dotted);
|
|
180
|
+
if (dottedOwner !== undefined && dottedOwner !== JSON.stringify([t.namespace, t.name])) {
|
|
181
|
+
bareAliasOwners.set(dotted, null);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
155
184
|
for (const t of authorizedTools) {
|
|
156
185
|
// Upstream output is untrusted: only restore calls for tools the caller authorized.
|
|
157
186
|
const wireName = namespacedToolName(t.namespace, t.name);
|
|
@@ -175,6 +204,30 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
175
204
|
toolNsMap.set(dottedName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
176
205
|
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(dottedName, t.parameters);
|
|
177
206
|
}
|
|
207
|
+
// Bare echo alias (`name` with no namespace spelling, #4679): same tool identity as
|
|
208
|
+
// the flattened wire name, so a provider that drops the namespace prefix still
|
|
209
|
+
// restores against this entry. Ambiguous bare names were resolved to null above;
|
|
210
|
+
// skipping them falls back to the spellings every provider can still echo.
|
|
211
|
+
// Code-mode helper spellings never gain a bare alias, from ANY namespace (#4792 review).
|
|
212
|
+
// Scoping this to `collaboration` was too narrow to be a boundary: a declaration such as
|
|
213
|
+
// `mcp__remote.exec` donated bare `exec` to the declared set, and normalizeDeclaredToolName
|
|
214
|
+
// then rewrote an undeclared `apply_patch`, `exec_command` or `write_stdin` onto it
|
|
215
|
+
// (src/types/tools.ts). No namespace may turn helper normalization on for a catalog that
|
|
216
|
+
// never declared the code-mode shell.
|
|
217
|
+
//
|
|
218
|
+
// The namespaced tool stays usable AS ITSELF: `ns__name` is added unconditionally above
|
|
219
|
+
// and `ns.name` whenever it is unambiguous, so only the namespace-dropping echo fallback
|
|
220
|
+
// is withdrawn, and only for these six spellings.
|
|
221
|
+
if (
|
|
222
|
+
bareAliasOwners.get(t.name) === JSON.stringify([t.namespace, t.name])
|
|
223
|
+
&& !NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)
|
|
224
|
+
) {
|
|
225
|
+
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
226
|
+
declaredToolNames.add(t.name);
|
|
227
|
+
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
228
|
+
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
229
|
+
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(t.name, t.parameters);
|
|
230
|
+
}
|
|
178
231
|
}
|
|
179
232
|
if (t.freeform) {
|
|
180
233
|
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
@@ -201,6 +254,20 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
201
254
|
// Some routed providers echo a bare tool_choice selector instead of the flattened catalog
|
|
202
255
|
// name. Accept only selectors the client actually sent and only when the full request catalog
|
|
203
256
|
// contains one tool with that logical name.
|
|
257
|
+
//
|
|
258
|
+
// A helper spelling selected this way is split rather than refused (#4819). The two things a
|
|
259
|
+
// bare alias does are separable, and passthrough already relies on that: identity RESTORATION
|
|
260
|
+
// runs before authorization there, rewriting the echoed bare name to the namespaced identity
|
|
261
|
+
// the caller declared, and the guard then authorizes `ns__name`. DECLARATION is the part that
|
|
262
|
+
// is unsafe, because a declared-name set carrying bare `exec` is what makes
|
|
263
|
+
// `normalizeDeclaredToolName` rewrite an undeclared `apply_patch`, `exec_command` or
|
|
264
|
+
// `write_stdin` onto the selected tool (src/types/tools.ts).
|
|
265
|
+
//
|
|
266
|
+
// So a helper spelling gets the `toolNsMap` entry and not the `declaredToolNames` entry. The
|
|
267
|
+
// caller nominated exactly one tool by name, `bareNameCounts` proves nothing else answers to
|
|
268
|
+
// it, and restoring it authorizes nothing the request did not already declare. The echo path
|
|
269
|
+
// above withholds both, because a bare echo is a guess rather than a nomination and #4679
|
|
270
|
+
// pinned that shape (`tests/responses/bare-echo-alias.test.ts`).
|
|
204
271
|
const choice = parsed.options.toolChoice;
|
|
205
272
|
const bareChoiceNames = new Set(
|
|
206
273
|
choice && typeof choice === "object"
|
|
@@ -213,8 +280,11 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
213
280
|
}
|
|
214
281
|
for (const t of authorizedTools) {
|
|
215
282
|
if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
|
|
216
|
-
|
|
217
|
-
|
|
283
|
+
// Restore the identity; declare the name only when it is not a helper spelling.
|
|
284
|
+
if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)) {
|
|
285
|
+
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
286
|
+
declaredToolNames.add(t.name);
|
|
287
|
+
}
|
|
218
288
|
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
219
289
|
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
220
290
|
if (t.parameters && typeof t.parameters === "object") {
|
|
@@ -8,14 +8,46 @@ interface ComboRecallEntry {
|
|
|
8
8
|
target: Pick<OcxComboTarget, "provider" | "model">;
|
|
9
9
|
responseModel: string;
|
|
10
10
|
at: number;
|
|
11
|
+
/** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
|
|
12
|
+
bytes: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const RECALL_CAPACITY = 256;
|
|
14
16
|
const RECALL_TTL_MS = 30 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* A model id is provider-reported and arrives on the response, so nothing upstream of here
|
|
19
|
+
* bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
|
|
20
|
+
* field that can grow, and 256 lanes alone do not bound the bytes they hold.
|
|
21
|
+
*/
|
|
22
|
+
const RECALL_MODEL_BYTES_MAX = 1024;
|
|
23
|
+
const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
|
|
15
24
|
const recall = new Map<string, ComboRecallEntry>();
|
|
25
|
+
let recallBytes = 0;
|
|
16
26
|
let lastReconciledGeneration = 0;
|
|
17
27
|
let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
|
|
18
28
|
|
|
29
|
+
/** Every removal path goes through here so the byte counter can never drift from the map. */
|
|
30
|
+
function deleteEntry(lane: string): boolean {
|
|
31
|
+
const entry = recall.get(lane);
|
|
32
|
+
if (!entry) return false;
|
|
33
|
+
recall.delete(lane);
|
|
34
|
+
recallBytes -= entry.bytes;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* UTF-8 size of a remembered model id, or null when it is too large to retain.
|
|
40
|
+
*
|
|
41
|
+
* The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
|
|
42
|
+
* than the code-unit count, so an oversized string is rejected without encoding it, and the
|
|
43
|
+
* bound cannot be defeated by paying the allocation it exists to prevent.
|
|
44
|
+
*/
|
|
45
|
+
function boundedModelBytes(responseModel: string): number | null {
|
|
46
|
+
if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
|
|
47
|
+
const bytes = Buffer.byteLength(responseModel, "utf8");
|
|
48
|
+
return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
|
|
20
52
|
return context.comboIds.has(entry.comboId)
|
|
21
53
|
&& context.providerNames.has(entry.target.provider)
|
|
@@ -32,14 +64,29 @@ export function rememberComboForLane(
|
|
|
32
64
|
if (!lane || !comboId || !responseModel.trim()) return;
|
|
33
65
|
// Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
|
|
34
66
|
if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
|
|
35
|
-
|
|
67
|
+
// An unretainable model id DECLINES the write; it must not clear the lane. Every other
|
|
68
|
+
// rejection above returns the same way, and clearing here would let a late completion erase
|
|
69
|
+
// a newer selection that this function has no ordering information to compare against.
|
|
70
|
+
const bytes = boundedModelBytes(responseModel);
|
|
71
|
+
if (bytes === null) return;
|
|
72
|
+
const entry = {
|
|
73
|
+
comboId,
|
|
74
|
+
target: { provider: target.provider, model: target.model },
|
|
75
|
+
responseModel,
|
|
76
|
+
at: Date.now(),
|
|
77
|
+
bytes,
|
|
78
|
+
};
|
|
36
79
|
if (liveOwners && !ownsEntry(liveOwners, entry)) return;
|
|
37
|
-
|
|
80
|
+
deleteEntry(lane);
|
|
38
81
|
recall.set(lane, entry);
|
|
39
|
-
|
|
82
|
+
recallBytes += bytes;
|
|
83
|
+
// Insertion order is recency order, because every write re-inserts its lane at the back.
|
|
84
|
+
// Evicting from the front therefore drops the least recently written lane, never this one:
|
|
85
|
+
// a single entry is capped well below the aggregate budget, so it always fits.
|
|
86
|
+
while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
|
|
40
87
|
const oldest = recall.keys().next().value;
|
|
41
|
-
if (oldest === undefined) break;
|
|
42
|
-
|
|
88
|
+
if (oldest === undefined || oldest === lane) break;
|
|
89
|
+
deleteEntry(oldest);
|
|
43
90
|
}
|
|
44
91
|
}
|
|
45
92
|
|
|
@@ -57,12 +104,25 @@ export function recallComboForLane(
|
|
|
57
104
|
|| !Object.hasOwn(config.providers, entry.target.provider)
|
|
58
105
|
|| !provider || provider.disabled === true
|
|
59
106
|
|| !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
|
|
60
|
-
|
|
107
|
+
deleteEntry(lane);
|
|
61
108
|
return undefined;
|
|
62
109
|
}
|
|
63
110
|
return entry.responseModel === model ? entry.comboId : undefined;
|
|
64
111
|
}
|
|
65
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Periodic expiry. Without it a lane that is never read again and never touched by a config
|
|
115
|
+
* reconciliation holds its entry for the life of the process: the existing TTL is only
|
|
116
|
+
* evaluated on read or on generation change.
|
|
117
|
+
*/
|
|
118
|
+
export function sweepExpiredComboRecall(now: number): number {
|
|
119
|
+
let removed = 0;
|
|
120
|
+
for (const [lane, entry] of recall) {
|
|
121
|
+
if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
|
|
122
|
+
}
|
|
123
|
+
return removed;
|
|
124
|
+
}
|
|
125
|
+
|
|
66
126
|
export function reconcileComboRecall(context: GenerationContext): number {
|
|
67
127
|
if (context.generation <= lastReconciledGeneration) return 0;
|
|
68
128
|
lastReconciledGeneration = context.generation;
|
|
@@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
|
|
|
74
134
|
let removed = 0;
|
|
75
135
|
for (const [lane, entry] of recall) {
|
|
76
136
|
if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
|
|
77
|
-
|
|
78
|
-
removed += 1;
|
|
137
|
+
if (deleteEntry(lane)) removed += 1;
|
|
79
138
|
}
|
|
80
139
|
}
|
|
81
140
|
return removed;
|
|
@@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
|
|
|
84
143
|
/** Test-only reset, alongside the combo rotation/cooldown resets. */
|
|
85
144
|
export function clearComboRecallForTests(): void {
|
|
86
145
|
recall.clear();
|
|
146
|
+
recallBytes = 0;
|
|
87
147
|
lastReconciledGeneration = 0;
|
|
88
148
|
liveOwners = undefined;
|
|
89
149
|
}
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
resolveCodexAuthContext,
|
|
54
54
|
codexPoolAffinityKey,
|
|
55
55
|
codexProbeLeaseId,
|
|
56
|
+
codexTransientProbeGrant,
|
|
56
57
|
codexProbeQuotaScope,
|
|
57
58
|
releaseCodexAuthContextProbeLease,
|
|
58
59
|
stripCodexRuntimeProviderFields,
|
|
@@ -71,7 +72,9 @@ import {
|
|
|
71
72
|
import {
|
|
72
73
|
applyAccountChangeConversationStateScrub,
|
|
73
74
|
conversationStateBindingFromAuth,
|
|
75
|
+
accountChangeFileReferenceRefusal,
|
|
74
76
|
rememberServingConversationStateIssuer,
|
|
77
|
+
conversationCarriesUploadedFiles,
|
|
75
78
|
} from "./account-change-state";
|
|
76
79
|
import {
|
|
77
80
|
TokenRefreshError,
|
|
@@ -82,6 +85,7 @@ import {
|
|
|
82
85
|
fetchWithResetRetry,
|
|
83
86
|
fetchWithTransientRetry,
|
|
84
87
|
applyUpstreamRecoveryInit,
|
|
88
|
+
isNonReplayableResponse,
|
|
85
89
|
SendBudgetExhaustedError,
|
|
86
90
|
TRANSIENT_RETRY_MAX_ATTEMPTS,
|
|
87
91
|
type UpstreamSendRecovery,
|
|
@@ -140,7 +144,6 @@ import {
|
|
|
140
144
|
catalogModelSupportsServiceTier,
|
|
141
145
|
finishRequestAttempt,
|
|
142
146
|
inspectResponseLogJson,
|
|
143
|
-
noteAttemptSend,
|
|
144
147
|
readConfiguredCodexServiceTier,
|
|
145
148
|
requestLogSpeedLabel,
|
|
146
149
|
sealRequestAttemptIdentity,
|
|
@@ -735,6 +738,14 @@ export async function handleResponsesCompact(
|
|
|
735
738
|
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
736
739
|
signal: req.signal,
|
|
737
740
|
nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
|
|
741
|
+
// #4778: the same retention the regular Responses path passes at its final auth. The
|
|
742
|
+
// post-429 guard below only declines to move AFTER this resolution has already bound
|
|
743
|
+
// an account, so without the bit here a quota-driven rebind could have carried the
|
|
744
|
+
// conversation off its issuing account before that guard is ever consulted -- and an
|
|
745
|
+
// uploaded file is readable only by the account that received it. Answered from `raw`,
|
|
746
|
+
// the same object and the same predicate the guard below uses, so the two can never
|
|
747
|
+
// disagree about which conversations are in scope.
|
|
748
|
+
retainAccountForUploadedFiles: conversationCarriesUploadedFiles(raw),
|
|
738
749
|
});
|
|
739
750
|
logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
|
|
740
751
|
const selected = await materializeCodexUpstreamAuthAsync(req.headers, authCtx, {
|
|
@@ -789,6 +800,14 @@ export async function handleResponsesCompact(
|
|
|
789
800
|
{
|
|
790
801
|
const binding = conversationStateBindingFromAuth(authCtx, codexPoolAffinityKey(req.headers));
|
|
791
802
|
if (binding) {
|
|
803
|
+
// Refused rather than scrubbed: an uploaded file is content the caller attached, not
|
|
804
|
+
// continuation state the turn can do without.
|
|
805
|
+
const refusal = accountChangeFileReferenceRefusal({
|
|
806
|
+
body: raw,
|
|
807
|
+
bindingKey: binding.bindingKey,
|
|
808
|
+
servingAccountId: binding.accountId,
|
|
809
|
+
});
|
|
810
|
+
if (refusal) return refusal;
|
|
792
811
|
applyAccountChangeConversationStateScrub({
|
|
793
812
|
body: raw,
|
|
794
813
|
bindingKey: binding.bindingKey,
|
|
@@ -869,6 +888,7 @@ export async function handleResponsesCompact(
|
|
|
869
888
|
// replacement (#2887). Also covers the replay's own second 401.
|
|
870
889
|
...(ctx.kind === "pool" ? { credentialGeneration: ctx.generation } : {}),
|
|
871
890
|
probeQuotaScope: codexProbeQuotaScope(ctx),
|
|
891
|
+
transientProbe: codexTransientProbeGrant(ctx),
|
|
872
892
|
writerGeneration: ctx.kind === "pool" || ctx.kind === "main-pool"
|
|
873
893
|
? ctx.writerGeneration
|
|
874
894
|
: undefined,
|
|
@@ -1068,6 +1088,10 @@ export async function handleResponsesCompact(
|
|
|
1068
1088
|
// — reporting exhausted retries while another pool account sat idle (#913).
|
|
1069
1089
|
if (
|
|
1070
1090
|
(upstream.status === 429 || upstream.status === 402)
|
|
1091
|
+
// A replay refusal this proxy synthesized carries 429 for the client's benefit only.
|
|
1092
|
+
// It is not pool quota evidence, and the alternate account below is another send of a
|
|
1093
|
+
// compact turn that may already have been processed.
|
|
1094
|
+
&& !isNonReplayableResponse(upstream)
|
|
1071
1095
|
&& !storedPool401ReplayAttempted
|
|
1072
1096
|
&& usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
1073
1097
|
&& !authCtx.fixedAccount
|
|
@@ -1082,15 +1106,22 @@ export async function handleResponsesCompact(
|
|
|
1082
1106
|
].filter(Boolean);
|
|
1083
1107
|
// Build the alternate COMPLETELY before cancelling the first body: if construction
|
|
1084
1108
|
// throws, the first rejection is still intact and can be returned to the client.
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1109
|
+
// The same reasoning refuses an uploaded-file move here: no alternate can read a file the
|
|
1110
|
+
// issuing account received, so which one is chosen is irrelevant and asking before the
|
|
1111
|
+
// resolution costs nothing. It also reuses the guarantee the comment above depends on --
|
|
1112
|
+
// the first body is still uncancelled -- so the client gets the original rejection rather
|
|
1113
|
+
// than an inaccessible-file error from account B (#4710).
|
|
1114
|
+
const alternate = conversationCarriesUploadedFiles(raw)
|
|
1115
|
+
? undefined
|
|
1116
|
+
: await resolveAlternateCompactContext({
|
|
1117
|
+
req,
|
|
1118
|
+
admission,
|
|
1119
|
+
config,
|
|
1120
|
+
route,
|
|
1121
|
+
selectedModelId,
|
|
1122
|
+
excludeAccountId: authCtx.accountId,
|
|
1123
|
+
turnAdmissionLease,
|
|
1124
|
+
});
|
|
1094
1125
|
// Resolution can await a credential refresh, so the client may have gone away
|
|
1095
1126
|
// while we were choosing B. Re-check before spending anything: recording A,
|
|
1096
1127
|
// cancelling its body, and sending B are all observable side effects, and B's
|
|
@@ -1193,8 +1224,14 @@ export async function handleResponsesCompact(
|
|
|
1193
1224
|
const bufferedErrorText = buffered.ok
|
|
1194
1225
|
? ""
|
|
1195
1226
|
: await buffered.clone().text().catch(() => "");
|
|
1196
|
-
|
|
1197
|
-
|
|
1227
|
+
// The client-facing 429 of a synthesized replay refusal says nothing about this
|
|
1228
|
+
// account's quota. Pool accounting keeps reading it as the transport failure it is,
|
|
1229
|
+
// which is also what it recorded before the status was corrected for the client.
|
|
1230
|
+
const replayRefused = isNonReplayableResponse(upstream);
|
|
1231
|
+
const explicitQuotaStatus = !replayRefused
|
|
1232
|
+
&& (buffered.status === 429 || buffered.status === 402);
|
|
1233
|
+
const bodyInferredQuota = !replayRefused
|
|
1234
|
+
&& !buffered.ok
|
|
1198
1235
|
&& !explicitQuotaStatus
|
|
1199
1236
|
&& isRateLimitOrQuotaFailureMessage(bufferedErrorText);
|
|
1200
1237
|
const quotaFailure = explicitQuotaStatus || bodyInferredQuota;
|
|
@@ -1207,7 +1244,11 @@ export async function handleResponsesCompact(
|
|
|
1207
1244
|
// A body-confirmed quota failure can arrive behind a generic 5xx. Record it as
|
|
1208
1245
|
// quota evidence; otherwise preserve the real upstream status so a local buffering
|
|
1209
1246
|
// failure after a 200 cannot soft-avoid a healthy account or rotate a thread.
|
|
1210
|
-
recordCompactPoolOutcome(
|
|
1247
|
+
recordCompactPoolOutcome(
|
|
1248
|
+
outcomeCtx,
|
|
1249
|
+
bodyInferredQuota ? 429 : replayRefused ? 502 : upstream.status,
|
|
1250
|
+
{ retryAfter, resetAt },
|
|
1251
|
+
);
|
|
1211
1252
|
// Lift usage and response metadata from the buffered upstream JSON into the
|
|
1212
1253
|
// request log; the routed branch gets the same through handleResponses. The
|
|
1213
1254
|
// synthetic buffer errors are not upstream bodies and stay uninspected.
|