@bitkyc08/opencodex 2.89.0 → 2.90.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-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +6 -2
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/server/chat-native.ts +8 -7
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
applyUpstreamRecoveryInit,
|
|
32
32
|
fetchWithResetRetry,
|
|
33
33
|
fetchWithTransientRetry,
|
|
34
|
-
isNonReplayableResponse,
|
|
34
|
+
isNonReplayableResponse, isSameTargetRetryHold,
|
|
35
35
|
isReplayRefusalCode,
|
|
36
36
|
isReplayRefusalResponse,
|
|
37
37
|
prepareSameTarget429Wait,
|
|
@@ -45,10 +45,7 @@ import {
|
|
|
45
45
|
UPSTREAM_RESET_REPLAY_REFUSED_CODE,
|
|
46
46
|
wrapWithZeroOutputRefetch,
|
|
47
47
|
} from "../lib/upstream-retry";
|
|
48
|
-
import {
|
|
49
|
-
isTranslatorBudgetExceededError,
|
|
50
|
-
type TranslatorBudget,
|
|
51
|
-
} from "../lib/translator-budget";
|
|
48
|
+
import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
|
|
52
49
|
import { authorizeResendForRecovery } from "../lib/request-resend-gate";
|
|
53
50
|
import { ambiguousResendAllowanceFor, selfContainedChatBody } from "./responses/reset-replay";
|
|
54
51
|
import {
|
|
@@ -88,6 +85,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
|
|
|
88
85
|
import type { ComboProtocolSource } from "./responses/core-combo-native";
|
|
89
86
|
import type { ProtocolEnvelope } from "../protocols/envelope";
|
|
90
87
|
import { nativeChatReplayScope, bindNativeChatReplayScope, nativeChatReasoningCapture } from "./chat-native-reasoning";
|
|
88
|
+
import { resolveMistralTransport } from "../providers/mistral-transport";
|
|
91
89
|
|
|
92
90
|
export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility";
|
|
93
91
|
|
|
@@ -335,7 +333,8 @@ export async function runNativeChatAttempt(
|
|
|
335
333
|
refusal.headers.set("Retry-After", String(error.retryAfterSeconds));
|
|
336
334
|
return refusal;
|
|
337
335
|
}
|
|
338
|
-
|
|
336
|
+
// Key selection re-resolves the transport; a single-key route still needs canonical Mistral pacing.
|
|
337
|
+
let activeProvider: OcxProviderConfig = resolveMistralTransport(route.provider);
|
|
339
338
|
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
340
339
|
const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx);
|
|
341
340
|
const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
|
|
@@ -365,6 +364,8 @@ export async function runNativeChatAttempt(
|
|
|
365
364
|
config.fastMode,
|
|
366
365
|
route.providerName,
|
|
367
366
|
replayScope,
|
|
367
|
+
undefined,
|
|
368
|
+
translatorBudget,
|
|
368
369
|
);
|
|
369
370
|
observeAdvisorNativeReasoning(req.headers, logCtx, request.body);
|
|
370
371
|
return request;
|
|
@@ -610,7 +611,7 @@ export async function runNativeChatAttempt(
|
|
|
610
611
|
// limit: waiting and re-sending here is exactly the duplicate inference the refusal
|
|
611
612
|
// exists to stop. It kept the same shape under the old 502 only because 502 never
|
|
612
613
|
// matched this branch.
|
|
613
|
-
&& !isNonReplayableResponse(response)
|
|
614
|
+
&& !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
|
|
614
615
|
&& retryPolicy
|
|
615
616
|
&& retries < retryPolicy.attempts
|
|
616
617
|
&& transientSendAvailable()
|
|
@@ -206,6 +206,7 @@ import type { WorkflowRefusalLog } from "../workflow-refusal";
|
|
|
206
206
|
import { readyProtocolMetadata } from "../../remote/protocol";
|
|
207
207
|
import { modelCapabilityFields } from "../models-capabilities";
|
|
208
208
|
import { createWebsocketHandler } from "./websocket-handler";
|
|
209
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
209
210
|
import { withGrokSessionIdentity } from "../../grok/session-identity";
|
|
210
211
|
import { withCallerSessionIdentity } from "../caller-session-identity";
|
|
211
212
|
|
|
@@ -1347,8 +1348,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1347
1348
|
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
|
|
1348
1349
|
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
|
|
1349
1350
|
});
|
|
1350
|
-
} catch {
|
|
1351
|
-
response =
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
response = reasoningArchiveErrorResponse(error)
|
|
1353
|
+
?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
|
|
1352
1354
|
}
|
|
1353
1355
|
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1354
1356
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
@@ -1519,6 +1521,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1519
1521
|
},
|
|
1520
1522
|
});
|
|
1521
1523
|
} catch (error) {
|
|
1524
|
+
const archiveError = reasoningArchiveErrorResponse(error);
|
|
1525
|
+
if (archiveError) return withRequestLogId(
|
|
1526
|
+
withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
|
|
1527
|
+
);
|
|
1522
1528
|
// A bounded upstream body can fail before handleResponses returns a client response.
|
|
1523
1529
|
// Finalize before rethrow so accounting observes the physical send while the caller
|
|
1524
1530
|
// retains the existing reset/rejection instead of receiving a synthesized response.
|
|
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
|
|
|
2
2
|
import { NativeInjectionChannel } from "../responses/native-injection";
|
|
3
3
|
import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
|
|
4
4
|
import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
|
|
5
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
5
6
|
import type { Server, ServerWebSocket } from "bun";
|
|
6
7
|
import {
|
|
7
8
|
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
|
|
|
396
397
|
}));
|
|
397
398
|
return;
|
|
398
399
|
}
|
|
400
|
+
// A local archive refusal happens before any send. Carry the same sanitized 503
|
|
401
|
+
// body and stable code as HTTP so no filesystem path reaches the client frame.
|
|
402
|
+
const archiveError = reasoningArchiveErrorResponse(err);
|
|
403
|
+
if (archiveError) {
|
|
404
|
+
finalizeLog(archiveError.status);
|
|
405
|
+
const body = await archiveError.json() as { error: Record<string, unknown> };
|
|
406
|
+
sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
399
409
|
finalizeLog(502);
|
|
400
410
|
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
401
411
|
type: "proxy_error",
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
|
|
|
476
476
|
registerTurn(ac, lease);
|
|
477
477
|
const reader = body.getReader();
|
|
478
478
|
let closed = false;
|
|
479
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
480
|
+
// A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
|
|
481
|
+
// so the tracked stream itself has to stop.
|
|
482
|
+
const onAbort = () => {
|
|
483
|
+
if (closed) return;
|
|
484
|
+
// Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
|
|
485
|
+
reader.cancel(ac.signal.reason).catch(() => {});
|
|
486
|
+
try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
|
|
487
|
+
finish();
|
|
488
|
+
};
|
|
479
489
|
const finish = () => {
|
|
480
490
|
if (closed) return;
|
|
481
491
|
closed = true;
|
|
492
|
+
ac.signal.removeEventListener("abort", onAbort);
|
|
482
493
|
unregisterTurn(ac);
|
|
483
494
|
onDone?.();
|
|
484
495
|
};
|
|
485
|
-
|
|
496
|
+
const tracked = new ReadableStream<Uint8Array>({
|
|
497
|
+
start(controller) { streamController = controller; },
|
|
486
498
|
async pull(controller) {
|
|
487
499
|
try {
|
|
488
500
|
const { done, value } = await reader.read();
|
|
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
|
|
|
499
511
|
reader.cancel(reason).catch(() => {});
|
|
500
512
|
},
|
|
501
513
|
});
|
|
514
|
+
// After registerTurn, so the lease's own abort listener (release the turn) runs first. A
|
|
515
|
+
// controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
|
|
516
|
+
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
517
|
+
return tracked;
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
export async function drainAndShutdown(
|
|
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
374
374
|
// Without a flow id this cancels the provider's newest in-flight login, which is what the
|
|
375
375
|
// single-user GUI has always meant by "Cancel". A host running concurrent code logins for
|
|
376
376
|
// several users passes the flowId it was given at /api/oauth/login.
|
|
377
|
-
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined
|
|
377
|
+
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
|
|
378
|
+
{ dismissSettledFailure: true });
|
|
378
379
|
return jsonResponse({ ok: true, cancelled });
|
|
379
380
|
}
|
|
380
381
|
|
|
@@ -849,6 +849,17 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
849
849
|
}
|
|
850
850
|
releaseRetainedRequest();
|
|
851
851
|
|
|
852
|
+
// The last pool account refused on spent quota and rotation found no replacement: answer the
|
|
853
|
+
// usage limit with its reset (#6991), as the bridge lane does, not the upstream "try again later".
|
|
854
|
+
const poolUsageLimit = response.status === 429 && oauthBinding
|
|
855
|
+
? anthropicRoutingFor(nativeInstance!).anthropicPoolUsageLimitAfterRefusal(Date.now(), oauthBinding.routeDecision, route.modelId)
|
|
856
|
+
: null;
|
|
857
|
+
if (poolUsageLimit) {
|
|
858
|
+
discard(response);
|
|
859
|
+
cleanupAbort();
|
|
860
|
+
logCtx.errorCode = "usage_limit_reached";
|
|
861
|
+
return retainUpstreamMessagesRequestId(localOAuthFailure(poolUsageLimit)!, response.headers);
|
|
862
|
+
}
|
|
852
863
|
if (!response.ok) {
|
|
853
864
|
let bodyText = "";
|
|
854
865
|
try {
|
|
@@ -1720,8 +1720,10 @@ export function noteProviderAttemptSend(
|
|
|
1720
1720
|
if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
|
|
1721
1721
|
// An input estimate is not evidence that a failed send used that many tokens.
|
|
1722
1722
|
delete attempt.inputTokenEstimate;
|
|
1723
|
+
// An account-scoped recovery kind names the status the sealed account answered.
|
|
1723
1724
|
finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
|
|
1724
|
-
: recovery === "key-401" ? 401 : recovery
|
|
1725
|
+
: recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403
|
|
1726
|
+
: recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502,
|
|
1725
1727
|
Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
|
|
1726
1728
|
// This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
|
|
1727
1729
|
// is direct evidence here rather than an inference from history. Without this the sealed
|
|
@@ -1850,16 +1852,20 @@ export function finishRequestAttempt(
|
|
|
1850
1852
|
export function aggregateAttemptUsage(
|
|
1851
1853
|
attempts: readonly PersistedUsageAttempt[],
|
|
1852
1854
|
): FinalizedUsageResult {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
+
// A usage-less attempt that never reached upstream spent nothing (summary.ts `spentNothing`),
|
|
1856
|
+
// so it cannot leave the row's usage unknown.
|
|
1857
|
+
const measured = attempts.filter(attempt => attempt.usage
|
|
1858
|
+
|| (attempt.sendCount !== 0 && attempt.locallyAnswered !== true));
|
|
1859
|
+
const status: UsageStatus = measured.length > 0
|
|
1860
|
+
&& measured.every(attempt => attempt.usageStatus === "unsupported")
|
|
1855
1861
|
? "unsupported"
|
|
1856
|
-
:
|
|
1862
|
+
: measured.some(attempt => (
|
|
1857
1863
|
attempt.usageStatus === "unreported" || attempt.usageStatus === "unsupported"
|
|
1858
1864
|
))
|
|
1859
1865
|
? "unreported"
|
|
1860
|
-
:
|
|
1866
|
+
: measured.some(attempt => attempt.usageStatus === "estimated")
|
|
1861
1867
|
? "estimated"
|
|
1862
|
-
:
|
|
1868
|
+
: measured.length > 0
|
|
1863
1869
|
? "reported"
|
|
1864
1870
|
: "unreported";
|
|
1865
1871
|
|
|
@@ -1876,7 +1882,10 @@ export function aggregateAttemptUsage(
|
|
|
1876
1882
|
return present.length > 0 ? present.reduce((sum, value) => sum + value, 0) : undefined;
|
|
1877
1883
|
};
|
|
1878
1884
|
const cachedInputTokens = sumOptional("cachedInputTokens");
|
|
1879
|
-
|
|
1885
|
+
// Readers take `cacheReadInputTokens ?? cachedInputTokens` (usage/cost.ts), so once any attempt
|
|
1886
|
+
// reports the read field, an attempt that only reports `cachedInputTokens` must count in it too.
|
|
1887
|
+
const cacheReadInputTokens = sumOptional("cacheReadInputTokens") === undefined ? undefined
|
|
1888
|
+
: usages.reduce((sum, usage) => sum + (usage.cacheReadInputTokens ?? usage.cachedInputTokens ?? 0), 0);
|
|
1880
1889
|
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
|
|
1881
1890
|
const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
|
|
1882
1891
|
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
fetchWithTransientRetry,
|
|
29
29
|
fetchWithResetRetry,
|
|
30
30
|
applyUpstreamRecoveryInit,
|
|
31
|
-
isNonReplayableResponse,
|
|
31
|
+
isNonReplayableResponse, isSameTargetRetryHold,
|
|
32
32
|
prepareSameTarget429Wait,
|
|
33
33
|
SendBudgetExhaustedError,
|
|
34
34
|
} from "../../lib/upstream-retry";
|
|
@@ -349,7 +349,7 @@ export function createAdapterContinuations(
|
|
|
349
349
|
&& !kiroReplaySetupFailed
|
|
350
350
|
// A synthesized replay refusal is not a rate limit; replaying the continuation on
|
|
351
351
|
// it would re-send a turn whose first send may already have been processed.
|
|
352
|
-
&& !isNonReplayableResponse(response)
|
|
352
|
+
&& !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
|
|
353
353
|
&& rateLimitPolicy !== null
|
|
354
354
|
&& adapterExchange.rateLimitRetries < rateLimitPolicy.attempts
|
|
355
355
|
// The main recovery loop and the passthrough ladder both consult the shared remainder
|
|
@@ -9,7 +9,7 @@ import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthrop
|
|
|
9
9
|
import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
|
|
10
10
|
import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
|
|
11
11
|
import { transientSendCapFor } from "./request-send-budget";
|
|
12
|
-
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
12
|
+
import { isNonReplayableResponse, isSameTargetRetryHold } from "../../lib/upstream-retry";
|
|
13
13
|
import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
|
|
14
14
|
import { isLocalUpstream } from "../../lib/local-upstream";
|
|
15
15
|
import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
|
|
@@ -1239,7 +1239,7 @@ export async function prepareAdapterExchange(
|
|
|
1239
1239
|
// replay is lossless. Runs before key failover so "primary-first" setups keep the same
|
|
1240
1240
|
// key on rate-limit blips; only after the attempts are exhausted does failover run.
|
|
1241
1241
|
while (
|
|
1242
|
-
upstreamResponse.status === 429
|
|
1242
|
+
upstreamResponse.status === 429 && !isSameTargetRetryHold(upstreamResponse)
|
|
1243
1243
|
&& rateLimitPolicy !== null
|
|
1244
1244
|
&& rateLimitRetries < rateLimitPolicy.attempts
|
|
1245
1245
|
&& !sendBudgetExhausted()
|
|
@@ -458,6 +458,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
458
458
|
return;
|
|
459
459
|
}
|
|
460
460
|
if (terminal || settledPreOpen || signal?.aborted) return;
|
|
461
|
+
// New sockets have just validated their upgrade; retained sockets carry the
|
|
462
|
+
// same connection signal into a fresh exchange, never its predecessor's frames.
|
|
463
|
+
metadata?.seed(session.upgradeHeaders());
|
|
461
464
|
sent = true;
|
|
462
465
|
try {
|
|
463
466
|
if (nativeControl) {
|
|
@@ -6,11 +6,48 @@ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
|
|
|
6
6
|
export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;
|
|
7
7
|
|
|
8
8
|
export type CodexWsQuotaObserver = (headers: Headers) => void;
|
|
9
|
+
const REASONING_INCLUDED_HEADER = "x-reasoning-included";
|
|
10
|
+
const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
|
|
9
11
|
|
|
10
12
|
function record(value: unknown): value is Record<string, unknown> {
|
|
11
13
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
/** Bun emits this payload before validating the upgrade; the session must wait for open. */
|
|
17
|
+
export function readCodexWsUpgradeHeaders(payload: unknown): Headers {
|
|
18
|
+
try {
|
|
19
|
+
if (!record(payload) || payload.statusCode !== 101 || !Array.isArray(payload.rawHeaders)
|
|
20
|
+
|| payload.rawHeaders.length % 2 !== 0 || payload.rawHeaders.length > CODEX_WS_METADATA_MAX_HEADERS * 2) return new Headers();
|
|
21
|
+
const headers = new Headers();
|
|
22
|
+
let bytes = 0;
|
|
23
|
+
let connectionNominated = false;
|
|
24
|
+
for (let i = 0; i < payload.rawHeaders.length; i += 2) {
|
|
25
|
+
const name = payload.rawHeaders[i];
|
|
26
|
+
const value = payload.rawHeaders[i + 1];
|
|
27
|
+
if (typeof name !== "string" || typeof value !== "string"
|
|
28
|
+
|| name.length + value.length > CODEX_WS_METADATA_MAX_BYTES - bytes || !HTTP_HEADER_TOKEN.test(name)
|
|
29
|
+
|| /[\r\n\0]/.test(value)) return new Headers();
|
|
30
|
+
bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
31
|
+
if (bytes > CODEX_WS_METADATA_MAX_BYTES) return new Headers();
|
|
32
|
+
const lower = name.toLowerCase();
|
|
33
|
+
if (lower === "connection" && value.split(",").some(token => token.trim().toLowerCase() === REASONING_INCLUDED_HEADER)) {
|
|
34
|
+
connectionNominated = true;
|
|
35
|
+
}
|
|
36
|
+
// Other allowlisted response headers (quota, request id, turn state, model)
|
|
37
|
+
// describe a response, not a reusable connection. Never retain them here.
|
|
38
|
+
if (lower === REASONING_INCLUDED_HEADER) {
|
|
39
|
+
if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
|
|
40
|
+
headers.append(lower, value);
|
|
41
|
+
if (Buffer.byteLength(headers.get(lower)!) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return connectionNominated ? new Headers() : headers;
|
|
45
|
+
} catch {
|
|
46
|
+
// Missing/unsupported or malformed optional metadata cannot authorize a retry.
|
|
47
|
+
return new Headers();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
function finiteNonnegative(value: unknown): value is number {
|
|
15
52
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
16
53
|
}
|
|
@@ -91,6 +128,13 @@ export class CodexWsMetadata {
|
|
|
91
128
|
|
|
92
129
|
snapshot(): Headers { return new Headers(this.headers); }
|
|
93
130
|
|
|
131
|
+
/** Seed only the connection-level inclusion signal; per-response frames still win. */
|
|
132
|
+
seed(headers: Headers): void {
|
|
133
|
+
if (this.ended || this.committed || this.headers.has(REASONING_INCLUDED_HEADER)) return;
|
|
134
|
+
const value = headers.get(REASONING_INCLUDED_HEADER);
|
|
135
|
+
if (value !== null) setMetadataHeader(this.headers, REASONING_INCLUDED_HEADER, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
94
138
|
private publishQuota(headers: Headers): void {
|
|
95
139
|
// Observation is auxiliary bookkeeping; a consumer exception cannot turn
|
|
96
140
|
// a valid provider frame into a retryable transport failure.
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata";
|
|
2
|
+
|
|
1
3
|
export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;
|
|
2
4
|
|
|
3
5
|
/** Owns one physical socket; request listeners belong to the exchange, not this object. */
|
|
@@ -8,10 +10,13 @@ export class CodexWsSession {
|
|
|
8
10
|
busy = false;
|
|
9
11
|
private owner?: (reason: Error) => void;
|
|
10
12
|
private readonly completedIds = new Set<string>();
|
|
13
|
+
private pendingUpgradeHeaders?: Headers;
|
|
14
|
+
private connectionHeaders?: Headers;
|
|
11
15
|
|
|
12
16
|
constructor(url: string, headers: Record<string, string>, readonly retainable = false,
|
|
13
17
|
private readonly changed: () => void = () => {}, proxy?: string) {
|
|
14
18
|
this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
|
|
19
|
+
this.socket.addEventListener("handshake", this.onHandshake);
|
|
15
20
|
this.socket.addEventListener("open", this.onOpen);
|
|
16
21
|
this.socket.addEventListener("message", this.onIdleMessage);
|
|
17
22
|
this.socket.addEventListener("close", this.onClose);
|
|
@@ -20,6 +25,7 @@ export class CodexWsSession {
|
|
|
20
25
|
|
|
21
26
|
get reused(): boolean { return this.completedIds.size > 0; }
|
|
22
27
|
hasCompleted(id: string): boolean { return this.completedIds.has(id); }
|
|
28
|
+
upgradeHeaders(): Headers { return new Headers(this.opened && !this.closed ? this.connectionHeaders : undefined); }
|
|
23
29
|
|
|
24
30
|
reserve(): boolean {
|
|
25
31
|
if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
|
|
@@ -71,7 +77,18 @@ export class CodexWsSession {
|
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
private
|
|
80
|
+
private onHandshake = (event: Event): void => {
|
|
81
|
+
if (this.opened || this.closed) return;
|
|
82
|
+
this.pendingUpgradeHeaders = readCodexWsUpgradeHeaders((event as MessageEvent<unknown>).data);
|
|
83
|
+
};
|
|
84
|
+
private onOpen = (): void => {
|
|
85
|
+
if (this.closed) return;
|
|
86
|
+
// A 101 payload alone is not proof that Bun accepted the handshake.
|
|
87
|
+
this.connectionHeaders = this.pendingUpgradeHeaders;
|
|
88
|
+
this.pendingUpgradeHeaders = undefined;
|
|
89
|
+
this.socket.removeEventListener("handshake", this.onHandshake);
|
|
90
|
+
this.opened = true;
|
|
91
|
+
};
|
|
75
92
|
private onIdleMessage = (): void => {
|
|
76
93
|
if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
|
|
77
94
|
};
|
|
@@ -85,6 +102,9 @@ export class CodexWsSession {
|
|
|
85
102
|
// The active exchange's close listener retains pre-send fallback semantics.
|
|
86
103
|
};
|
|
87
104
|
private detach(): void {
|
|
105
|
+
this.pendingUpgradeHeaders = undefined;
|
|
106
|
+
this.connectionHeaders = undefined;
|
|
107
|
+
this.socket.removeEventListener("handshake", this.onHandshake);
|
|
88
108
|
this.socket.removeEventListener("open", this.onOpen);
|
|
89
109
|
this.socket.removeEventListener("message", this.onIdleMessage);
|
|
90
110
|
this.socket.removeEventListener("close", this.onClose);
|
|
@@ -224,8 +224,13 @@ function failedTerminalResponse(
|
|
|
224
224
|
: topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)
|
|
225
225
|
? topLevelError as Record<string, unknown>
|
|
226
226
|
: {
|
|
227
|
-
type:
|
|
228
|
-
|
|
227
|
+
// A caller-validated flat error can carry its identity beside type:error.
|
|
228
|
+
// Retain it when that caller established a 400, rather than discarding the
|
|
229
|
+
// rejection code before the single-shot opaque-state recovery sees it.
|
|
230
|
+
type: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
|
|
231
|
+
? "invalid_request_error" : "upstream_error",
|
|
232
|
+
code: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
|
|
233
|
+
? (typeof terminalPayload.code === "string" ? terminalPayload.code : null) : "upstream_server_error",
|
|
229
234
|
message: typeof terminalPayload.message === "string"
|
|
230
235
|
? terminalPayload.message
|
|
231
236
|
: logCtx.upstreamError ?? "Provider stream failed before producing output",
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
recordCodexUpstreamOutcome,
|
|
89
89
|
type CodexUpstreamOutcome,
|
|
90
90
|
} from "../../codex/routing";
|
|
91
|
+
import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
|
|
91
92
|
import {
|
|
92
93
|
applyAccountChangeConversationStateScrub,
|
|
93
94
|
conversationStateBindingFromAuth,
|
|
@@ -1062,6 +1063,7 @@ export async function handleResponsesCompact(
|
|
|
1062
1063
|
if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
|
|
1063
1064
|
recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
|
|
1064
1065
|
...meta,
|
|
1066
|
+
...codexFailureWindowOutcomeMeta(ctx),
|
|
1065
1067
|
threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
|
|
1066
1068
|
fixedAccount: ctx.fixedAccount,
|
|
1067
1069
|
modelId: selectedModelId,
|
|
@@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types";
|
|
|
2
2
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
3
3
|
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
4
4
|
import type { RouteResult } from "../../router";
|
|
5
|
+
import { routeConcreteModel } from "../../router";
|
|
5
6
|
import type { HandleResponsesOptions } from "./core-options";
|
|
6
7
|
import {
|
|
7
8
|
hasForwardableCodexBearer,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
CodexAuthContextError,
|
|
25
26
|
applyCodexAuthContextToProvider,
|
|
26
27
|
stripCodexRuntimeProviderFields,
|
|
28
|
+
mainAccountHardLockCallerProof,
|
|
27
29
|
} from "../../codex/auth-context";
|
|
28
30
|
import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
|
|
29
31
|
import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
|
|
@@ -590,3 +592,21 @@ export async function refreshNativeMainForwardAuth(args: {
|
|
|
590
592
|
}) ?? nativeMainRefreshFailureResponse(error) };
|
|
591
593
|
}
|
|
592
594
|
}
|
|
595
|
+
|
|
596
|
+
/** Inspect the original credential domain, before fallback changes the route or drops headers. */
|
|
597
|
+
export function mainHardLockFallbackCallerCheck(
|
|
598
|
+
req: Request, config: OcxConfig, options: HandleResponsesOptions,
|
|
599
|
+
): (sourceModel: string) => Promise<(() => boolean) | null> {
|
|
600
|
+
return async sourceModel => {
|
|
601
|
+
let sourceRoute: ReturnType<typeof routeConcreteModel>;
|
|
602
|
+
try { sourceRoute = routeConcreteModel(config, sourceModel); }
|
|
603
|
+
// This preview cannot replace the normal routing phase's existing error response.
|
|
604
|
+
catch { return null; }
|
|
605
|
+
const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config);
|
|
606
|
+
const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options);
|
|
607
|
+
return mainAccountHardLockCallerProof(headers, options.codexAuthPolicy ?? config, {
|
|
608
|
+
substituteMainCredentialForDirect: substituteMainCredential,
|
|
609
|
+
signal: options.abortSignal ?? req.signal,
|
|
610
|
+
});
|
|
611
|
+
};
|
|
612
|
+
}
|
|
@@ -11,6 +11,7 @@ import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDis
|
|
|
11
11
|
import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
12
12
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
13
13
|
import { isCodexAccountGenerationLive } from "../../codex/account-store";
|
|
14
|
+
import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
|
|
14
15
|
import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota";
|
|
15
16
|
import type { RouteResult } from "../../router";
|
|
16
17
|
import {
|
|
@@ -93,20 +94,19 @@ export function sidecarOutcomeRecorder(
|
|
|
93
94
|
config: OcxConfig,
|
|
94
95
|
authCtx: CodexAuthContext,
|
|
95
96
|
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
: undefined;
|
|
97
|
+
if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
|
|
98
|
+
const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
|
|
99
|
+
return outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
100
|
+
...windowMeta,
|
|
101
|
+
threadId: authCtx.affinityKey,
|
|
102
|
+
fixedAccount: authCtx.fixedAccount,
|
|
103
|
+
probeLeaseId: authCtx.probeLeaseId,
|
|
104
|
+
probeQuotaScope: authCtx.probeQuotaScope,
|
|
105
|
+
writerGeneration: authCtx.writerGeneration,
|
|
106
|
+
// Quarantine retains its stored-credential fence. main-pool has no stored-record
|
|
107
|
+
// generation; its separate dispatch proof above fences only failure-window samples.
|
|
108
|
+
...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
|
|
109
|
+
});
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
|
|
@@ -148,6 +148,7 @@ export function recordCodexPreOutputRefusalOutcome(
|
|
|
148
148
|
if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
|
|
149
149
|
noteCodexWsPreOutputRefusalOutcome(response);
|
|
150
150
|
recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
|
|
151
|
+
...codexFailureWindowOutcomeMeta(authCtx),
|
|
151
152
|
threadId: authCtx.affinityKey,
|
|
152
153
|
fixedAccount: authCtx.fixedAccount,
|
|
153
154
|
modelId,
|
|
@@ -676,6 +677,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
676
677
|
if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
|
|
677
678
|
noteCodexWsPreOutputRefusalOutcome(firstResponse);
|
|
678
679
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
680
|
+
...codexFailureWindowOutcomeMeta(firstAuthCtx),
|
|
679
681
|
threadId: firstAuthCtx.affinityKey,
|
|
680
682
|
fixedAccount: firstAuthCtx.fixedAccount,
|
|
681
683
|
modelId: route.modelId,
|
|
@@ -693,6 +695,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
693
695
|
if (outcomeStatus === firstResponse.status || (outcomeStatus !== 429 && outcomeStatus !== 402)) return;
|
|
694
696
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
695
697
|
...codexQuotaOutcomeMeta(firstResponse),
|
|
698
|
+
...codexFailureWindowOutcomeMeta(firstAuthCtx),
|
|
696
699
|
threadId: firstAuthCtx.affinityKey,
|
|
697
700
|
modelId: route.modelId,
|
|
698
701
|
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
@@ -862,6 +865,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
862
865
|
}
|
|
863
866
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
864
867
|
...quotaMeta,
|
|
868
|
+
...codexFailureWindowOutcomeMeta(firstAuthCtx),
|
|
865
869
|
threadId: firstAuthCtx.affinityKey,
|
|
866
870
|
modelId: route.modelId,
|
|
867
871
|
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
@@ -1149,6 +1153,7 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
1149
1153
|
logCtx?: RequestLogContext,
|
|
1150
1154
|
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
1151
1155
|
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
1156
|
+
const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
|
|
1152
1157
|
return (status, httpStatusOverride) => {
|
|
1153
1158
|
const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus]
|
|
1154
1159
|
.find(value => value === 429 || value === 402);
|
|
@@ -1157,6 +1162,7 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
1157
1162
|
// request. Don't penalize account health; record success to clear any
|
|
1158
1163
|
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
1159
1164
|
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
1165
|
+
...windowMeta,
|
|
1160
1166
|
threadId: authCtx.affinityKey,
|
|
1161
1167
|
fixedAccount: authCtx.fixedAccount,
|
|
1162
1168
|
modelId,
|
|
@@ -1180,6 +1186,7 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
1180
1186
|
? 200
|
|
1181
1187
|
: (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
1182
1188
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1189
|
+
...windowMeta,
|
|
1183
1190
|
threadId: authCtx.affinityKey,
|
|
1184
1191
|
fixedAccount: authCtx.fixedAccount,
|
|
1185
1192
|
modelId,
|
|
@@ -1165,6 +1165,9 @@ export async function executeComboResponses(
|
|
|
1165
1165
|
: comboFailureDecision(failure.response.status, failure.classificationText, {
|
|
1166
1166
|
code: failure.upstreamCode,
|
|
1167
1167
|
codexModelRefusal: failure.codexModelRefusal,
|
|
1168
|
+
// The adapter the failed leg actually resolved: a configured `kiro` provider can route a
|
|
1169
|
+
// model through another wire via `modelAdapters`, and an unresolved leg grants no hop.
|
|
1170
|
+
failedAdapter: childLog.providerAdapter,
|
|
1168
1171
|
});
|
|
1169
1172
|
const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
|
|
1170
1173
|
// Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
|
|
@@ -259,8 +259,15 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
262
|
+
// Flat SSE error events carry their code/message beside type:error, without a nested
|
|
263
|
+
// error object. Match only the same dedicated code or exact verification identity.
|
|
264
|
+
if ((record.error === undefined || record.error === null)
|
|
265
|
+
&& (record.type === "error" || record.type === "invalid_request_error")
|
|
266
|
+
&& (record.code === "invalid_encrypted_content" || ((record.code === null || record.code === undefined)
|
|
267
|
+
&& typeof record.message === "string" && record.message.startsWith("The encrypted content ")
|
|
268
|
+
&& record.message.endsWith(" could not be verified. Reason: Encrypted content could not be decrypted or parsed.")))) return true;
|
|
269
|
+
|
|
270
|
+
// The caller-mismatch identity also arrives as a flat typed error.
|
|
264
271
|
if (
|
|
265
272
|
record.type === "invalid_request_error"
|
|
266
273
|
&& typeof record.message === "string"
|
|
@@ -277,14 +284,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
|
277
284
|
|
|
278
285
|
|
|
279
286
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* requests. Ordinary turns keep the function-output-only inspection.
|
|
287
|
+
* Opaque state can be refused inside a 200 stream rather than as an HTTP 4xx. The passthrough
|
|
288
|
+
* inspects its bounded zero-output prelude even when a restored thread has no serving record;
|
|
289
|
+
* successful native ciphertext remains unchanged and blobless turns do not gain inspection.
|
|
284
290
|
*/
|
|
285
291
|
export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean {
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
// A restored thread has no process-local serving record. Inspect its actual outbound
|
|
293
|
+
// opaque state too; only an explicit zero-output rejection can authorize the rebuild.
|
|
294
|
+
return parsed._stripReasoningEncryptedContent !== true
|
|
288
295
|
&& outboundResponsesBodyCarriesOpaqueBlob(outboundBody);
|
|
289
296
|
}
|
|
290
297
|
|
|
@@ -310,7 +317,7 @@ export function retainedReasoningStreamRejection(
|
|
|
310
317
|
|
|
311
318
|
|
|
312
319
|
/**
|
|
313
|
-
* Whether an upstream Responses 4xx
|
|
320
|
+
* Whether an upstream Responses 4xx or explicitly identified 502 rejected opaque replay state.
|
|
314
321
|
*
|
|
315
322
|
* The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
|
|
316
323
|
* compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
|
|
@@ -329,8 +336,10 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
|
|
|
329
336
|
const acceptedStatus = (args.status >= 400 && args.status < 500)
|
|
330
337
|
|| (
|
|
331
338
|
args.status === 502
|
|
332
|
-
|
|
333
|
-
|
|
339
|
+
// Gateways can project a reasoning-only rejection as 502. The existing strict
|
|
340
|
+
// identity classifier below still excludes generic gateway/transport failures.
|
|
341
|
+
&& (!isEncryptedFunctionOutputRejection(args.errorBody)
|
|
342
|
+
|| outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody))
|
|
334
343
|
);
|
|
335
344
|
return acceptedStatus
|
|
336
345
|
&& adapterSpeaksResponsesWire(args.adapterName)
|