@bitkyc08/opencodex 2.64.0 → 2.65.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/README.md +28 -25
- package/bin/ocx.mjs +16 -2
- package/gui/dist/assets/App-8NMiZxT0.css +1 -0
- package/gui/dist/assets/App-DGLte6IR.js +50 -0
- package/gui/dist/assets/Tray-D4bA1vff.js +1 -0
- package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
- package/gui/dist/assets/{index-SggB6t3z.js → index-Bi1K37Wl.js} +12 -12
- package/gui/dist/assets/tray-data-BZXhB3qz.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -4
- package/src/adapters/anthropic.ts +30 -1
- package/src/adapters/claude-cli/adapter.ts +218 -0
- package/src/adapters/claude-cli/profiles.ts +38 -0
- package/src/adapters/codebuddy/profiles.ts +2 -0
- package/src/adapters/coding-agent/profile.ts +12 -4
- package/src/adapters/coding-agent/turn.ts +16 -6
- package/src/adapters/command-code-tool-text.ts +133 -23
- package/src/adapters/cursor/catalog.ts +4 -8
- package/src/adapters/cursor/discovery.ts +10 -5
- package/src/adapters/cursor/effort-map.ts +2 -6
- package/src/adapters/cursor/envelope-echo.ts +10 -5
- package/src/adapters/cursor/request-builder.ts +9 -4
- package/src/adapters/cursor/thread-continuity.ts +53 -0
- package/src/adapters/cursor.ts +29 -13
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/google-tool-schema.ts +26 -3
- package/src/adapters/identity.ts +214 -2
- package/src/adapters/openai-chat/passthrough.ts +1 -0
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
- package/src/adapters/openai-chat.ts +43 -15
- package/src/adapters/openai-responses/canonical-forward.ts +17 -10
- package/src/adapters/openai-responses/passthrough.ts +19 -4
- package/src/adapters/openai-responses/request-strips.ts +25 -0
- package/src/adapters/openai-responses/web-search.ts +22 -4
- package/src/adapters/qoder/profiles.ts +2 -0
- package/src/adapters/registry.ts +8 -0
- package/src/adapters/run-turn-queue.ts +7 -0
- package/src/bridge/response-json.ts +6 -4
- package/src/bridge/sse.ts +7 -5
- package/src/claude/alias.ts +73 -26
- package/src/claude/context-windows.ts +15 -6
- package/src/claude/desktop-3p-library.ts +3 -2
- package/src/claude/desktop-3p.ts +1 -1
- package/src/claude/desktop-first-party.ts +63 -22
- package/src/claude/desktop-picker-profile.ts +302 -0
- package/src/claude/desktop-picker.ts +365 -0
- package/src/claude/desktop-risk.ts +20 -0
- package/src/claude/gateway-cache.ts +13 -4
- package/src/claude/inbound.ts +5 -2
- package/src/claude/intercept/connect-proxy.ts +106 -29
- package/src/claude/intercept/local-ca.ts +76 -18
- package/src/claude/intercept/model-bindings.ts +145 -0
- package/src/claude/intercept/picker-bootstrap.ts +141 -0
- package/src/claude/intercept/picker-ca.ts +126 -0
- package/src/claude/intercept/picker-listener.ts +213 -0
- package/src/claude/intercept/picker-models.ts +101 -0
- package/src/claude/intercept/picker-runtime.ts +330 -0
- package/src/claude/intercept/picker-trust.ts +125 -0
- package/src/claude/intercept/runtime.ts +149 -2
- package/src/claude/model-info.ts +18 -4
- package/src/cli/agent.ts +16 -2
- package/src/cli/capabilities.ts +71 -2
- package/src/cli/claude-desktop.ts +319 -33
- package/src/cli/claude.ts +15 -7
- package/src/cli/codex-shim-autorestore.ts +1 -0
- package/src/cli/dispatch.ts +29 -23
- package/src/cli/effort.ts +16 -6
- package/src/cli/ensure-desired-integrations.ts +29 -4
- package/src/cli/ready.ts +1 -1
- package/src/cli/registry.ts +8 -0
- package/src/cli/restart-scope.ts +9 -1
- package/src/cli/runtime-api.ts +17 -0
- package/src/cli/system-command.ts +11 -12
- package/src/client/connect.ts +12 -2
- package/src/client/state.ts +39 -1
- package/src/clients/config-export.ts +57 -9
- package/src/codex/auth-context.ts +51 -11
- package/src/codex/catalog/derive-entry.ts +5 -5
- package/src/codex/catalog/gather-capture.ts +25 -6
- package/src/codex/catalog/metadata.ts +3 -3
- package/src/codex/catalog/parsing.ts +39 -1
- package/src/codex/catalog/provider-models.ts +8 -6
- package/src/codex/catalog/retained-sync.ts +17 -7
- package/src/codex/catalog/sync.ts +2 -2
- package/src/codex/desktop-app-restart.ts +8 -0
- package/src/codex/desktop-switches.ts +9 -1
- package/src/codex/home.ts +43 -4
- package/src/codex/inject/config-toml.ts +137 -0
- package/src/codex/inject/plan.ts +23 -0
- package/src/codex/inject/remove.ts +21 -1
- package/src/codex/inject.ts +63 -8
- package/src/codex/journal.ts +36 -0
- package/src/codex/main-account-hard-lock.ts +14 -1
- package/src/codex/main-account-policy-wait.ts +109 -0
- package/src/codex/native-profile-startup.ts +24 -6
- package/src/codex/native-residue.ts +28 -10
- package/src/codex/quota-types.ts +8 -1
- package/src/codex/quota.ts +49 -7
- package/src/codex/runtime.ts +31 -10
- package/src/combos/failover.ts +7 -6
- package/src/combos/resolve.ts +91 -14
- package/src/combos/types.ts +18 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/schema/config-schema.ts +21 -2
- package/src/config/schema/leaf-validators.ts +6 -3
- package/src/config/subagent-models.ts +3 -1
- package/src/generated/compatibility-version.json +275 -163
- package/src/images/artifacts.ts +14 -0
- package/src/integrations/owned-refresh.ts +1 -1
- package/src/integrations/ownership-policy.ts +32 -1
- package/src/integrations/state.ts +3 -1
- package/src/integrations/writer.ts +9 -0
- package/src/lib/config-ownership.ts +3 -0
- package/src/lib/errors.ts +87 -0
- package/src/lib/package-tree-integrity.ts +1 -1
- package/src/lib/request-execution-budget.ts +42 -14
- package/src/lib/request-resend-gate.ts +4 -3
- package/src/lib/retry-delay.ts +7 -1
- package/src/lib/tool-envelope-echo-filter.ts +236 -0
- package/src/lib/upstream-retry.ts +20 -12
- package/src/oauth/index.ts +1 -0
- package/src/oauth/kiro-credentials.ts +14 -1
- package/src/oauth/login-cli.ts +1 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fast-opt-in.ts +31 -0
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/provider-id-rewrite.ts +2 -0
- package/src/providers/quota/vendor-probes-key.ts +8 -2
- package/src/providers/registry/entries-core.ts +45 -2
- package/src/providers/registry/entries-extended.ts +99 -12
- package/src/providers/registry/model-ids.ts +2 -0
- package/src/providers/registry/types.ts +7 -1
- package/src/providers/resolved-model-policy.ts +11 -4
- package/src/providers/service-tier.ts +10 -3
- package/src/responses/code-mode-helper-compat.ts +3 -0
- package/src/responses/parser.ts +45 -3
- package/src/router.ts +6 -0
- package/src/server/auth-cors.ts +8 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/claude-messages.ts +69 -14
- package/src/server/grok-upstream-envelope-echo.ts +120 -0
- package/src/server/index/claude-intercept-lifecycle.ts +24 -0
- package/src/server/index/serve-options.ts +2 -2
- package/src/server/index.ts +7 -0
- package/src/server/management/agent-settings-routes.ts +204 -112
- package/src/server/management/claude-desktop-picker-routes.ts +128 -0
- package/src/server/management/combo-routes.ts +59 -2
- package/src/server/management/config-routes.ts +81 -39
- package/src/server/management/context.ts +3 -0
- package/src/server/management/native-integration-routes.ts +169 -93
- package/src/server/management/provider-routes.ts +46 -4
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/routing-profile-routes.ts +9 -0
- package/src/server/management/sidebar-routes.ts +52 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/proxy-liveness.ts +38 -3
- package/src/server/request-log-account-rotation.ts +53 -0
- package/src/server/request-log.ts +3 -16
- package/src/server/responses/adapter-dispatch.ts +17 -1
- package/src/server/responses/agent-task-recovery.ts +49 -26
- package/src/server/responses/codex-ws-exchange.ts +18 -7
- package/src/server/responses/codex-ws-wire.ts +27 -4
- package/src/server/responses/core-combo.ts +21 -3
- package/src/server/responses/core-normalize.ts +7 -0
- package/src/server/responses/core-opaque-recovery.ts +3 -2
- package/src/server/responses/encrypted-payload.ts +15 -14
- package/src/server/responses/fetch-helpers.ts +1 -1
- package/src/server/responses/input-admission.ts +10 -6
- package/src/server/responses/native-response-control.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +182 -12
- package/src/server/responses/passthrough-dispatch.ts +102 -44
- package/src/server/responses/policy-fallback.ts +3 -0
- package/src/server/responses/policy-refusal.ts +67 -0
- package/src/server/responses/request-send-budget.ts +4 -0
- package/src/server/responses/run-turn-execution.ts +42 -11
- package/src/server/responses/ws-upstream.ts +2 -1
- package/src/server/system-env-shell.ts +0 -1
- package/src/server/system-env.ts +25 -4
- package/src/service/cli.ts +34 -3
- package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
- package/src/tray/windows-tray.ps1 +188 -7
- package/src/tray/windows.ts +23 -2
- package/src/types/config.ts +52 -12
- package/src/types/provider.ts +22 -5
- package/src/types/tools.ts +11 -5
- package/src/types.ts +1 -0
- package/src/update/async-check.ts +109 -0
- package/src/update/badge.ts +11 -17
- package/src/update/check-types.ts +9 -0
- package/src/update/desktop-badge.ts +102 -0
- package/src/update/index.ts +48 -8
- package/src/update/install-detection.d.mts +29 -2
- package/src/update/install-detection.mjs +213 -7
- package/src/update/job.ts +17 -12
- package/src/update/notify.ts +16 -11
- package/src/update/pnpm-owner-worker.ts +12 -0
- package/src/update/refresh-scheduler.ts +141 -0
- package/src/usage/cost.ts +31 -4
- package/src/usage/expected-prices.ts +57 -0
- package/assets/download-linux.svg +0 -10
- package/assets/download-macos.svg +0 -10
- package/assets/download-windows.svg +0 -10
- package/gui/dist/assets/App-CpuDF3ci.js +0 -50
- package/gui/dist/assets/App-I5AnaSLh.css +0 -1
- package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
- package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
|
@@ -84,12 +84,47 @@ export interface LivenessIo {
|
|
|
84
84
|
createChallengeFn?: () => string;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Operator override for the per-probe fetch ceilings below (`OCX_PROBE_TIMEOUT_MS`),
|
|
89
|
+
* integer milliseconds in [1, MAX_PROBE_TIMEOUT_MS].
|
|
90
|
+
*
|
|
91
|
+
* Some hosts put a security layer (content filter, EDR network extension) in front of
|
|
92
|
+
* loopback TCP that adds a fixed per-connect cost, measured at about one second on an
|
|
93
|
+
* affected macOS machine. The shipped 750 ms probe then aborts before a healthy proxy can
|
|
94
|
+
* answer, and every CLI liveness consumer reports the proxy as down while a direct
|
|
95
|
+
* `curl /healthz` succeeds.
|
|
96
|
+
*
|
|
97
|
+
* The override only raises: each ceiling keeps its shipped floor (750 ms for the shared
|
|
98
|
+
* default, 1500 ms for the stop/start ownership budgets that guard against a duplicate
|
|
99
|
+
* proxy, #764, #5004), so a small value can never shorten them. Values above the 30 s
|
|
100
|
+
* ceiling are ignored rather than clamped: a stop multiplies its budget by the attempt
|
|
101
|
+
* count, and a typo must not turn a stop into a wait of minutes or days. Parsed once at
|
|
102
|
+
* module load; anything malformed falls back to the defaults and never breaks startup.
|
|
103
|
+
*/
|
|
104
|
+
export const MAX_PROBE_TIMEOUT_MS = 30_000;
|
|
105
|
+
const SHARED_PROBE_FLOOR_MS = 750;
|
|
106
|
+
const OWNERSHIP_PROBE_FLOOR_MS = 1500;
|
|
107
|
+
|
|
108
|
+
export function parseProbeTimeoutOverrideMs(raw: string | undefined): number | undefined {
|
|
109
|
+
const trimmed = raw?.trim();
|
|
110
|
+
if (!trimmed || !/^\d+$/.test(trimmed)) return undefined;
|
|
111
|
+
const n = Number(trimmed);
|
|
112
|
+
return n > 0 && n <= MAX_PROBE_TIMEOUT_MS ? n : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The ceiling for a probe whose shipped value is `floorMs`, raised by a valid override only. */
|
|
116
|
+
export function probeCeilingMs(floorMs: number, override: number | undefined): number {
|
|
117
|
+
return Math.max(floorMs, override ?? 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const probeTimeoutOverrideMs = parseProbeTimeoutOverrideMs(process.env.OCX_PROBE_TIMEOUT_MS);
|
|
121
|
+
|
|
87
122
|
/** Default per-probe fetch ceiling shared by liveness and readiness probes. */
|
|
88
|
-
export const DEFAULT_PROBE_TIMEOUT_MS =
|
|
123
|
+
export const DEFAULT_PROBE_TIMEOUT_MS = probeCeilingMs(SHARED_PROBE_FLOOR_MS, probeTimeoutOverrideMs);
|
|
89
124
|
|
|
90
125
|
/** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
|
|
91
126
|
export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
|
|
92
|
-
timeoutMs:
|
|
127
|
+
timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs),
|
|
93
128
|
attempts: 3,
|
|
94
129
|
};
|
|
95
130
|
|
|
@@ -105,7 +140,7 @@ export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> =
|
|
|
105
140
|
* the stop path already uses for the mirror-image decision.
|
|
106
141
|
*/
|
|
107
142
|
export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
|
|
108
|
-
timeoutMs:
|
|
143
|
+
timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs),
|
|
109
144
|
attempts: 3,
|
|
110
145
|
};
|
|
111
146
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
|
|
2
|
+
import { isCodexUsageAccountLogLabel, type PersistedUsageAttempt } from "../usage/log";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Did the account behind a sent attempt change between two sends of the same request?
|
|
6
|
+
*
|
|
7
|
+
* Any ACCOUNT change ends the attempt, not just an API-key one. Codex pool and generic OAuth
|
|
8
|
+
* labels are `p`/`o` + 6 hex, which the key-only pattern never matched, so a rotation folded the
|
|
9
|
+
* second account's sends, usage and label into the first attempt row: the request then reported
|
|
10
|
+
* one attempt on the wrong account, and per-account usage attribution was silently wrong.
|
|
11
|
+
* `isCodexUsageAccountLogLabel` (src/usage/log.ts) already persists those labels onto the
|
|
12
|
+
* attempt through the broad pattern; this is the same pattern, read back at the split.
|
|
13
|
+
*
|
|
14
|
+
* The other axis a rotation can move: Anthropic's pool carries no label at all --
|
|
15
|
+
* `stampOAuthAccountLabel` skips that base provider -- and keeps its account inside the
|
|
16
|
+
* account-qualified log provider string (`anthropic-p<hex6>`), so the label test can never see an
|
|
17
|
+
* Anthropic rotation. Comparing providers is safe because a sent attempt's provider is frozen by
|
|
18
|
+
* `sealRequestAttemptIdentity`: the only way the two diverge after a send is the account moving
|
|
19
|
+
* underneath the row.
|
|
20
|
+
*/
|
|
21
|
+
export function attemptAccountChanged(
|
|
22
|
+
previousLabel: string | undefined,
|
|
23
|
+
nextLabel: string | undefined,
|
|
24
|
+
attemptProvider: string | undefined,
|
|
25
|
+
currentProvider: string,
|
|
26
|
+
): boolean {
|
|
27
|
+
const labelChanged = previousLabel !== nextLabel
|
|
28
|
+
&& (ACCOUNT_LOG_LABEL_RE.test(previousLabel ?? "") || ACCOUNT_LOG_LABEL_RE.test(nextLabel ?? ""));
|
|
29
|
+
return labelChanged || (attemptProvider !== undefined && attemptProvider !== currentProvider && currentProvider.length > 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Stamp an attempt's identity; after its first send the account is frozen (see attemptAccountChanged). */
|
|
33
|
+
export function sealRequestAttemptIdentity(
|
|
34
|
+
attempt: PersistedUsageAttempt | undefined,
|
|
35
|
+
provider: string,
|
|
36
|
+
adapter: string,
|
|
37
|
+
accountLogLabel?: string,
|
|
38
|
+
): void {
|
|
39
|
+
if (!attempt) return;
|
|
40
|
+
if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
|
|
41
|
+
// The adapter keeps re-stamping: a mid-turn wire rotation is still this same physical send.
|
|
42
|
+
attempt.adapter = adapter;
|
|
43
|
+
if (attempt.sendCount === 0) {
|
|
44
|
+
attempt.provider = provider;
|
|
45
|
+
if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
|
|
46
|
+
else delete attempt.accountLogLabel;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// After a send the account is settled; only a label that arrives late for the same account lands.
|
|
50
|
+
if (attempt.accountLogLabel === undefined && isCodexUsageAccountLogLabel(accountLogLabel)) {
|
|
51
|
+
attempt.accountLogLabel = accountLogLabel;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { stampApiKeyAccountLabel, usesApiKeyAccount } from "../providers/label";
|
|
4
4
|
import { KEY_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
|
|
5
|
+
import { attemptAccountChanged, sealRequestAttemptIdentity } from "./request-log-account-rotation";
|
|
6
|
+
export { sealRequestAttemptIdentity };
|
|
5
7
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
6
8
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
7
9
|
import {
|
|
@@ -1748,20 +1750,6 @@ export function beginRequestAttempt(
|
|
|
1748
1750
|
};
|
|
1749
1751
|
}
|
|
1750
1752
|
|
|
1751
|
-
export function sealRequestAttemptIdentity(
|
|
1752
|
-
attempt: PersistedUsageAttempt | undefined,
|
|
1753
|
-
provider: string,
|
|
1754
|
-
adapter: string,
|
|
1755
|
-
accountLogLabel?: string,
|
|
1756
|
-
): void {
|
|
1757
|
-
if (!attempt) return;
|
|
1758
|
-
if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
|
|
1759
|
-
attempt.provider = provider;
|
|
1760
|
-
attempt.adapter = adapter;
|
|
1761
|
-
if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
|
|
1762
|
-
else delete attempt.accountLogLabel;
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
1753
|
/** Preserve metered JSON failures before key recovery consumes/cancels their body. */
|
|
1766
1754
|
export async function recordKeyAttemptFailure(logCtx: RequestLogContext, response: Response, signal?: AbortSignal): Promise<void> {
|
|
1767
1755
|
const attempt = logCtx.activeAttempt;
|
|
@@ -1805,8 +1793,7 @@ export function noteProviderAttemptSend(
|
|
|
1805
1793
|
stampApiKeyAccountLabel(logCtx, providerName, provider);
|
|
1806
1794
|
const next = logCtx.accountLogLabel;
|
|
1807
1795
|
if (attempt && usesApiKeyAccount(provider)) keyUsageOwners.add(attempt);
|
|
1808
|
-
if (attempt && attempt.sendCount > 0 && previous
|
|
1809
|
-
&& (KEY_ACCOUNT_LOG_LABEL_RE.test(previous ?? "") || KEY_ACCOUNT_LOG_LABEL_RE.test(next ?? ""))) {
|
|
1796
|
+
if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
|
|
1810
1797
|
// An input estimate is not evidence that a failed send used that many tokens.
|
|
1811
1798
|
delete attempt.inputTokenEstimate;
|
|
1812
1799
|
finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../request-log";
|
|
18
18
|
import { clientCancelledResponse, readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors";
|
|
19
19
|
import { redactSecretString } from "../../lib/redact";
|
|
20
|
+
import { rewriteUpstreamPolicyRefusal } from "./policy-refusal";
|
|
20
21
|
import { waitForProviderRequestSlot } from "../../providers/request-pacing";
|
|
21
22
|
import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers";
|
|
22
23
|
import {
|
|
@@ -41,7 +42,7 @@ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
|
41
42
|
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
42
43
|
import type { OAuthAccessSnapshot } from "../../oauth";
|
|
43
44
|
import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
|
|
44
|
-
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
45
|
+
import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
|
|
45
46
|
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
|
|
46
47
|
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
47
48
|
import { bindRouteReasoningReplayScope } from "./core-replay";
|
|
@@ -1066,6 +1067,21 @@ export async function prepareAdapterExchange(
|
|
|
1066
1067
|
? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget)
|
|
1067
1068
|
: jsonContextOverflowResponse();
|
|
1068
1069
|
}
|
|
1070
|
+
const policyRefusal = rewriteUpstreamPolicyRefusal({
|
|
1071
|
+
status: upstreamResponse.status,
|
|
1072
|
+
errorText,
|
|
1073
|
+
stream: clientRequestedStream,
|
|
1074
|
+
modelId: parsed._responseModelId ?? parsed.modelId,
|
|
1075
|
+
// The same host check covers the openai-chat wire: both xAI hosts serve Chat too.
|
|
1076
|
+
destinationIsXai: isXaiResponsesDestination(route.provider),
|
|
1077
|
+
translatorBudget,
|
|
1078
|
+
turnAdmissionLease: options.turnAdmissionLease,
|
|
1079
|
+
});
|
|
1080
|
+
if (policyRefusal) {
|
|
1081
|
+
// Codex-facing incomplete/content_filter. openai-responses passthrough
|
|
1082
|
+
// uses the same helper after its 413 block.
|
|
1083
|
+
return policyRefusal;
|
|
1084
|
+
}
|
|
1069
1085
|
if (!isFixedCodexAccount(admissionState.authCtx)) {
|
|
1070
1086
|
recordSubagentQuotaFailureForThreadSpawn(
|
|
1071
1087
|
req.headers,
|
|
@@ -75,15 +75,17 @@ interface AgentEnvelope {
|
|
|
75
75
|
encryptedStartIndex: number;
|
|
76
76
|
inputSnapshot: string;
|
|
77
77
|
headerText: string;
|
|
78
|
-
messageType: "NEW_TASK" | "MESSAGE";
|
|
79
|
-
taskName: string;
|
|
78
|
+
messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER";
|
|
79
|
+
taskName: string | null;
|
|
80
80
|
sender: string;
|
|
81
81
|
ciphertexts: readonly string[];
|
|
82
82
|
author: string;
|
|
83
83
|
recipient: string;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
|
|
86
|
+
const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE|FOLLOWUP_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
|
|
87
|
+
// FINAL_ANSWER omits the Task name line when the sender declares no recipient.
|
|
88
|
+
const FINAL_ANSWER_HEADER = /(?:^|\n)Message Type\s*:\s*FINAL_ANSWER\s*\n(?:Task name\s*:\s*(\S+)\s*\n)?Sender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
|
|
87
89
|
|
|
88
90
|
function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
89
91
|
if (!Array.isArray(input)) return null;
|
|
@@ -104,7 +106,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
104
106
|
if (!Array.isArray(content)) return null;
|
|
105
107
|
|
|
106
108
|
let headerText: string | null = null;
|
|
107
|
-
let messageType: "NEW_TASK" | "MESSAGE" | null = null;
|
|
109
|
+
let messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER" | null = null;
|
|
108
110
|
let taskName: string | null = null;
|
|
109
111
|
let sender: string | null = null;
|
|
110
112
|
let encryptedStartIndex = -1;
|
|
@@ -119,16 +121,24 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
119
121
|
&& typeof part.text === "string"
|
|
120
122
|
) {
|
|
121
123
|
const match = ROUTING_HEADER.exec(part.text);
|
|
122
|
-
|
|
124
|
+
const finalMatch = match ? null : FINAL_ANSWER_HEADER.exec(part.text);
|
|
125
|
+
if (match || finalMatch) {
|
|
123
126
|
if (headerText !== null) return null;
|
|
127
|
+
const m = match ?? finalMatch!;
|
|
124
128
|
if (
|
|
125
|
-
part.text.slice(0,
|
|
126
|
-
|| part.text.slice(
|
|
129
|
+
part.text.slice(0, m.index).trim().length > 0
|
|
130
|
+
|| part.text.slice(m.index + m[0].length).trim().length > 0
|
|
127
131
|
) return null;
|
|
128
|
-
headerText =
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
+
headerText = m[0].startsWith("\n") ? m[0].slice(1) : m[0];
|
|
133
|
+
if (match) {
|
|
134
|
+
messageType = match[1] as "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK";
|
|
135
|
+
taskName = match[2]!;
|
|
136
|
+
sender = match[3]!;
|
|
137
|
+
} else {
|
|
138
|
+
messageType = "FINAL_ANSWER";
|
|
139
|
+
taskName = finalMatch![1] ?? null;
|
|
140
|
+
sender = finalMatch![2]!;
|
|
141
|
+
}
|
|
132
142
|
}
|
|
133
143
|
}
|
|
134
144
|
if (part.type !== "encrypted_content") continue;
|
|
@@ -145,7 +155,6 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
145
155
|
if (
|
|
146
156
|
!headerText
|
|
147
157
|
|| !messageType
|
|
148
|
-
|| !taskName
|
|
149
158
|
|| !sender
|
|
150
159
|
|| encryptedStartIndex < 0
|
|
151
160
|
|| ciphertexts.length === 0
|
|
@@ -153,7 +162,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
153
162
|
|
|
154
163
|
const itemRecord = item as { author?: unknown; recipient?: unknown };
|
|
155
164
|
if (typeof itemRecord.author !== "string" || typeof itemRecord.recipient !== "string") return null;
|
|
156
|
-
|
|
165
|
+
// A FINAL_ANSWER without a Task name line declares no recipient, so the structured
|
|
166
|
+
// recipient is only cross-checked when a task name is present; admission is the
|
|
167
|
+
// trust boundary either way.
|
|
168
|
+
if (
|
|
169
|
+
itemRecord.author !== sender
|
|
170
|
+
|| (taskName !== null && itemRecord.recipient !== taskName)
|
|
171
|
+
) return null;
|
|
157
172
|
|
|
158
173
|
return {
|
|
159
174
|
itemIndex,
|
|
@@ -170,10 +185,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
|
|
|
170
185
|
}
|
|
171
186
|
|
|
172
187
|
function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null {
|
|
173
|
-
const
|
|
188
|
+
const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER;
|
|
189
|
+
const foreign = header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER;
|
|
190
|
+
if (foreign.test(assignment)) return null;
|
|
191
|
+
const match = header.exec(assignment);
|
|
174
192
|
if (!match) return assignment;
|
|
175
193
|
if (match.index !== 0) return null;
|
|
176
|
-
if (
|
|
194
|
+
if (envelope.messageType === "FINAL_ANSWER") {
|
|
195
|
+
if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null;
|
|
196
|
+
} else if (
|
|
177
197
|
match[1] !== envelope.messageType
|
|
178
198
|
|| match[2] !== envelope.taskName
|
|
179
199
|
|| match[3] !== envelope.sender
|
|
@@ -295,18 +315,21 @@ function admittedRecovery(
|
|
|
295
315
|
if (!envelope) return { admitted: false, reason: "unsupported_envelope" };
|
|
296
316
|
const admission = recoveryAdmission(req, config);
|
|
297
317
|
if (!admission) return { admitted: false, reason: "admission_denied" };
|
|
318
|
+
// A JSON-encoded fixed-order tuple, not a delimiter-joined string: a field that carries the
|
|
319
|
+
// delimiter shifts every boundary after it, so two envelopes could hash to one entry and one
|
|
320
|
+
// recovery would replay for the other. A FINAL_ANSWER that omits its Task name line carries no
|
|
321
|
+
// addressing in the header, which leaves the structured recipient as the only field separating
|
|
322
|
+
// two such envelopes and makes the boundary the whole difference.
|
|
298
323
|
const cacheKey = createHash("sha256")
|
|
299
|
-
.update(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
.update("\0")
|
|
309
|
-
.update(JSON.stringify(envelope.ciphertexts))
|
|
324
|
+
.update(JSON.stringify([
|
|
325
|
+
admission.cacheScope,
|
|
326
|
+
parentThreadId ?? "",
|
|
327
|
+
envelope.messageType,
|
|
328
|
+
envelope.taskName ?? "",
|
|
329
|
+
envelope.recipient,
|
|
330
|
+
envelope.sender,
|
|
331
|
+
envelope.ciphertexts,
|
|
332
|
+
]))
|
|
310
333
|
.digest("hex");
|
|
311
334
|
return { admitted: true, recovery: { envelope, admission, cacheKey } };
|
|
312
335
|
}
|
|
@@ -11,7 +11,7 @@ import type { CodexWsSession } from "./codex-ws-session";
|
|
|
11
11
|
import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
|
|
12
12
|
MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
|
|
13
13
|
codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
|
|
14
|
-
type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
|
|
14
|
+
markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
|
|
15
15
|
|
|
16
16
|
interface ExchangeOptions {
|
|
17
17
|
nativeControl?: NativeResponseControl;
|
|
@@ -213,14 +213,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
213
213
|
resolve(response);
|
|
214
214
|
};
|
|
215
215
|
|
|
216
|
-
const failStream = (error: unknown, status: 502 | 504 = 502) => {
|
|
216
|
+
const failStream = (error: unknown, status: 502 | 504 = 502, { socketDied = false } = {}) => {
|
|
217
217
|
if (terminal) return;
|
|
218
218
|
terminal = true;
|
|
219
219
|
if (sent && !responseCommitted && metadata) {
|
|
220
220
|
// Nothing has been promised to the client yet, so the honest answer is a gateway
|
|
221
221
|
// status, not a 200 whose body then fails. The frame may already be executing
|
|
222
|
-
// upstream: the response is marked non-replayable so no layer of this process
|
|
223
|
-
// it again, and the client applies its own retry policy as it would on the direct
|
|
222
|
+
// upstream: the response is marked non-replayable so no retry layer of this process
|
|
223
|
+
// sends it again, and the client applies its own retry policy as it would on the direct
|
|
224
224
|
// path. Same settle order as a refused create: snapshot, detach, close, dispose.
|
|
225
225
|
const prelude = metadata.snapshot();
|
|
226
226
|
// Claim the commit slot so no later path can resolve a second, 200 Response.
|
|
@@ -230,7 +230,13 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
230
230
|
session.dispose();
|
|
231
231
|
const message = error instanceof Error ? error.message : String(error);
|
|
232
232
|
const failureResponse = codexWsPreResponseFailure(status, message, prelude);
|
|
233
|
-
|
|
233
|
+
const stage = failureStage();
|
|
234
|
+
markCodexWsStage(failureResponse, stageRecord(stage.requestBytes));
|
|
235
|
+
// #4191: a socket that died under the send is the one settle the dispatch may replace,
|
|
236
|
+
// once, and only under the operator's `retryOnReset` grant. Native steering and injection
|
|
237
|
+
// are left out: their channel may already have sent continuation frames on this socket, so
|
|
238
|
+
// the create frame alone no longer describes the turn.
|
|
239
|
+
if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage);
|
|
234
240
|
resolve(failureResponse);
|
|
235
241
|
return;
|
|
236
242
|
}
|
|
@@ -549,7 +555,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
549
555
|
resolve(sseFallback(url, init));
|
|
550
556
|
return;
|
|
551
557
|
}
|
|
552
|
-
if (sent && !terminal)
|
|
558
|
+
if (sent && !terminal) {
|
|
559
|
+
failStream(closedBeforeTerminalMessage(event, failureStage()), 502, { socketDied: true });
|
|
560
|
+
}
|
|
553
561
|
};
|
|
554
562
|
|
|
555
563
|
const onError = () => {
|
|
@@ -560,7 +568,10 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
560
568
|
cleanup();
|
|
561
569
|
session.dispose();
|
|
562
570
|
resolve(sseFallback(url, init));
|
|
563
|
-
} else
|
|
571
|
+
} else {
|
|
572
|
+
const message = `codex websocket transport error${codexWsFailureDetail(failureStage())}`;
|
|
573
|
+
failStream(message, 502, { socketDied: true });
|
|
574
|
+
}
|
|
564
575
|
};
|
|
565
576
|
detachOwner = session.bindOwner(reason => cancelExchange(reason));
|
|
566
577
|
ws.addEventListener("open", onOpen);
|
|
@@ -214,10 +214,10 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu
|
|
|
214
214
|
* whose failures cannot be compared with anything else, which is the reported symptom -- every
|
|
215
215
|
* such failure reached the user as one of two bare sentences.
|
|
216
216
|
*
|
|
217
|
-
* It does not relax the transport's own rule. The
|
|
218
|
-
* `
|
|
219
|
-
*
|
|
220
|
-
*
|
|
217
|
+
* It does not relax the transport's own rule. The stage below reports where the exchange got to,
|
|
218
|
+
* and `resendPermission` agrees that everything past `before-send` is refused. When a socket dies
|
|
219
|
+
* under the send, this stage is what the resend gate is asked with (#4191), so the operator's
|
|
220
|
+
* `retryOnReset` grant is the only way past that refusal, as it is for an HTTP reset.
|
|
221
221
|
*/
|
|
222
222
|
export const CODEX_WS_FAILURE_PROJECTION = {
|
|
223
223
|
/** The create frame never left, so the origin provably never saw this turn. */
|
|
@@ -236,6 +236,29 @@ export function projectCodexWsFailure(
|
|
|
236
236
|
return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)];
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
const socketDeathStages = new WeakMap<Response, RequestFailureStage>();
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Record that a pre-response settle came from the socket closing or failing under the send (#4191),
|
|
243
|
+
* rather than from silence, a refused frame or a local limit.
|
|
244
|
+
*
|
|
245
|
+
* A fact about how the exchange ended, not a grant. The settle is the same non-replayable 502
|
|
246
|
+
* either way; whether the turn may go out once more is the resend gate's question, and only the
|
|
247
|
+
* operator's `retryOnReset` grant can answer it yes.
|
|
248
|
+
*/
|
|
249
|
+
export function markCodexWsSocketDeath(response: Response, stage: CodexWsFailureStage): void {
|
|
250
|
+
socketDeathStages.set(response, projectCodexWsFailure(stage).stage);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Where the send stood when its socket died: `pre-header` when nothing came back and
|
|
255
|
+
* `protocol-prelude` when frames arrived but none was a Responses event. Undefined for every other
|
|
256
|
+
* response.
|
|
257
|
+
*/
|
|
258
|
+
export function codexWsSocketDeathStage(response: Response): RequestFailureStage | undefined {
|
|
259
|
+
return socketDeathStages.get(response);
|
|
260
|
+
}
|
|
261
|
+
|
|
239
262
|
/**
|
|
240
263
|
* Render the stage as a suffix appended to an existing failure message.
|
|
241
264
|
*
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
comboFailureCooldownScope,
|
|
28
28
|
} from "../../combos";
|
|
29
29
|
import { formatErrorResponse } from "../../bridge";
|
|
30
|
+
import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
|
|
30
31
|
import {
|
|
31
32
|
expandPreviousResponseInput,
|
|
32
33
|
previousResponseReplayFailure,
|
|
@@ -73,6 +74,7 @@ import {
|
|
|
73
74
|
import { preflightComboStreamResponse } from "./combo-stream-preflight";
|
|
74
75
|
import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
|
|
75
76
|
import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
|
|
77
|
+
import { settleOperatorReplacement } from "../../lib/upstream-retry";
|
|
76
78
|
|
|
77
79
|
/**
|
|
78
80
|
* Sends one combo target may run on its own before the ladder moves on. A target is a whole
|
|
@@ -165,6 +167,7 @@ export function comboTargetSendBudget(
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
|
|
170
|
+
/** Dispatch a Responses combo within its shared send budget and preserve terminal child failures. */
|
|
168
171
|
export async function executeComboResponses(
|
|
169
172
|
req: Request,
|
|
170
173
|
rawBody: unknown,
|
|
@@ -449,13 +452,17 @@ export async function executeComboResponses(
|
|
|
449
452
|
countedExternally: true,
|
|
450
453
|
});
|
|
451
454
|
if (hopDecision && hopDecision.allowed) hopDecision.permit.use();
|
|
452
|
-
else if (hopDecision &&
|
|
455
|
+
else if (hopDecision && firstComboTarget) {
|
|
456
|
+
// A refused initial reservation authorizes no child send and has no upstream failure to return.
|
|
457
|
+
return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, "request send budget exhausted before combo dispatch");
|
|
458
|
+
}
|
|
459
|
+
else if (hopDecision) {
|
|
453
460
|
// Out of budget is not this target's failure. The established exhaustion contract is to
|
|
454
461
|
// return the last real upstream answer with its status, headers and any quota body
|
|
455
462
|
// intact rather than to mint a synthetic error, and a later target only exists because
|
|
456
463
|
// an earlier one already recorded one.
|
|
457
464
|
if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
|
|
458
|
-
|
|
465
|
+
return lastFailure!;
|
|
459
466
|
}
|
|
460
467
|
const targetSendBudget = comboSendScope
|
|
461
468
|
? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched)
|
|
@@ -678,9 +685,20 @@ export async function executeComboResponses(
|
|
|
678
685
|
attemptRetained = true;
|
|
679
686
|
lastFailure = failure.response;
|
|
680
687
|
lastFailedChildLog = childLog;
|
|
688
|
+
// A replacement that answers 200 is unmarked, and its zero-output failure only exists once
|
|
689
|
+
// preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
|
|
690
|
+
// client would resend becomes the refusal, and anything else reaches the client as it is.
|
|
691
|
+
const spentReplacement = !failure.nonReplayable && comboSendScope?.ambiguousResendSpent === true;
|
|
692
|
+
if (spentReplacement) {
|
|
693
|
+
const settled = settleOperatorReplacement(failure.response);
|
|
694
|
+
if (settled !== failure.response) {
|
|
695
|
+
adoptFailedChildLog(childLog);
|
|
696
|
+
return settled;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
681
699
|
// A non-replayable failure (the answer to a spent ambiguous-reset replacement) may follow a
|
|
682
700
|
// send that already ran the turn, so no later target may receive it, whatever its status says.
|
|
683
|
-
const failureDecision = failure.nonReplayable
|
|
701
|
+
const failureDecision = failure.nonReplayable || spentReplacement
|
|
684
702
|
? "stop"
|
|
685
703
|
: comboFailureDecision(failure.response.status, failure.classificationText, {
|
|
686
704
|
code: failure.upstreamCode,
|
|
@@ -19,6 +19,7 @@ import { shouldPreparePlaintextV2AgentMessages } from "../../responses/plaintext
|
|
|
19
19
|
import { hasValidatedActiveReasoningEffort } from "../../responses/parser";
|
|
20
20
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
21
21
|
import { applyOpenAiVirtualModel } from "../../providers/openai-virtual-models";
|
|
22
|
+
import { renameRoutedIdentityInContext } from "../../adapters/identity";
|
|
22
23
|
import {
|
|
23
24
|
fastPolicyForModel,
|
|
24
25
|
serviceTierSupportFromPolicy,
|
|
@@ -137,6 +138,12 @@ export async function applyFinalRouteRequestNormalization(args: {
|
|
|
137
138
|
}
|
|
138
139
|
parsed.modelId = route.modelId;
|
|
139
140
|
}
|
|
141
|
+
// #5221: the parser named the identity sentence from the CLIENT selector, because routing had
|
|
142
|
+
// not run when it read the body, and only the adapters that build their own system text rename
|
|
143
|
+
// it afterwards. Settle it on the id this request really sends, here where that id is final —
|
|
144
|
+
// every dispatch path (passthrough, runTurn, adapter request build) reads the context after
|
|
145
|
+
// this, and a combo child runs this for its own target.
|
|
146
|
+
parsed.context = renameRoutedIdentityInContext(parsed.context, route.modelId);
|
|
140
147
|
// Transport-neutral reliability policy (#875): applies to any Responses
|
|
141
148
|
// upstream whose final adapter is openai-responses, not only WS turns.
|
|
142
149
|
const responsesUpstreamStreaming = route.staticPolicy.model.responsesUpstreamStreaming;
|
|
@@ -311,14 +311,15 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
|
|
|
311
311
|
* Peek the upstream error body for the reasoning-effort downgrade. Only 400/403 are considered
|
|
312
312
|
* and the body must be complete and display-safe, the same contract the other rejection peeks
|
|
313
313
|
* use. The match is deliberately narrow: the upstream has to name reasoning effort, so an
|
|
314
|
-
* unrelated 400 never triggers a replay.
|
|
314
|
+
* unrelated 400 never triggers a replay. A non-replayable answer, such as one to a spent operator
|
|
315
|
+
* replacement, is never read: the first send may already have run the turn.
|
|
315
316
|
*/
|
|
316
317
|
export async function reasoningEffortRejectionText(
|
|
317
318
|
response: Response,
|
|
318
319
|
alreadyAttempted: boolean,
|
|
319
320
|
signal: AbortSignal,
|
|
320
321
|
): Promise<string | undefined> {
|
|
321
|
-
if (alreadyAttempted) return undefined;
|
|
322
|
+
if (alreadyAttempted || isNonReplayableResponse(response)) return undefined;
|
|
322
323
|
if (response.status !== 400 && response.status !== 403) return undefined;
|
|
323
324
|
try {
|
|
324
325
|
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
@@ -198,21 +198,21 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
|
|
|
198
198
|
/**
|
|
199
199
|
* The routing header codex-rs writes above a delegated agent payload.
|
|
200
200
|
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* parent conversation as raw `gAAAA...` ciphertext after an
|
|
204
|
-
* decides "unreadable" by stripping the envelope and asking
|
|
205
|
-
* survives, so an envelope shape it does not recognise counts as
|
|
206
|
-
*
|
|
207
|
-
* verbatim.
|
|
201
|
+
* All four codex-rs message types are recognised: NEW_TASK, MESSAGE, FOLLOWUP_TASK,
|
|
202
|
+
* and FINAL_ANSWER, whose Task name line is optional. #3021 reported a subagent
|
|
203
|
+
* `MESSAGE` arriving in the parent conversation as raw `gAAAA...` ciphertext after an
|
|
204
|
+
* `adapter_eof`. The detector decides "unreadable" by stripping the envelope and asking
|
|
205
|
+
* whether any plaintext survives, so an envelope shape it does not recognise counts as
|
|
206
|
+
* surviving text: an unrecognised type whose entire body is one Fernet token measured as
|
|
207
|
+
* READABLE and would be forwarded verbatim.
|
|
208
208
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
209
|
+
* This strip must therefore stay in step with the message types the opt-in recovery
|
|
210
|
+
* recognises (see agent-task-recovery.ts). The strip itself is still only detection: it
|
|
211
|
+
* lets the proxy notice that what it is about to forward is unreadable ciphertext and
|
|
212
|
+
* fail closed with a structured error rather than paste the token. Recovery admission,
|
|
213
|
+
* not the strip, is the trust boundary for decryption.
|
|
214
214
|
*/
|
|
215
|
-
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\
|
|
215
|
+
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\n\s*Task name\s*:[^\n]*\n\s*Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n\s*(?:Task name\s*:[^\n]*\n\s*)?Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)/gi;
|
|
216
216
|
|
|
217
217
|
// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
|
|
218
218
|
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
|
|
@@ -262,7 +262,8 @@ function splitFernetParts(content: unknown[]): Set<object> {
|
|
|
262
262
|
export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
263
263
|
if (!Array.isArray(input)) return false;
|
|
264
264
|
|
|
265
|
-
// codex-rs appends one
|
|
265
|
+
// codex-rs appends one agent_message (any of the four codex-rs message types) at the
|
|
266
|
+
// current input tail. Historical
|
|
266
267
|
// agent messages may be adjacent in full-history bodies; they must not poison the
|
|
267
268
|
// later task. compaction_trigger/additional_tools are trailing metadata rather than
|
|
268
269
|
// a newer user turn.
|
|
@@ -268,7 +268,7 @@ export function providerFetch(
|
|
|
268
268
|
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
269
269
|
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
270
270
|
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
271
|
-
const upstreamWebsocket = provider.upstreamWebsocket
|
|
271
|
+
const upstreamWebsocket = provider.upstreamWebsocket;
|
|
272
272
|
if (!options.httpOnly && typeof input === "string" && init
|
|
273
273
|
&& shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
|
|
274
274
|
const egress = egressFor(input);
|