@bitkyc08/opencodex 2.7.36 → 2.7.37
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.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-BhUTxmCy.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +17 -3
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/auth-context.ts +18 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/catalog/provider-fetch.ts +31 -8
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +529 -0
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +32 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +2 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +191 -1
- package/src/server/live.ts +491 -0
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/provider-routes.ts +2 -0
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +236 -21
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +25 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-ZmFopEYw.js +0 -52
|
@@ -281,11 +281,29 @@ export function subagentRosterText(models: Array<{ model: string; efforts: strin
|
|
|
281
281
|
|
|
282
282
|
|
|
283
283
|
|
|
284
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
285
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function isGeneratedDeveloperItem(item: unknown, text: string): boolean {
|
|
289
|
+
if (!isRecord(item) || item.type !== "message" || item.role !== "developer") return false;
|
|
290
|
+
if (!Array.isArray(item.content) || item.content.length !== 1) return false;
|
|
291
|
+
const [part] = item.content;
|
|
292
|
+
return isRecord(part) && part.type === "input_text" && part.text === text;
|
|
293
|
+
}
|
|
294
|
+
|
|
284
295
|
export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void {
|
|
285
|
-
parsed.context.messages.push({ role: "developer", content: text, timestamp: Date.now() });
|
|
286
296
|
const raw = parsed._rawBody as { input?: unknown } | undefined;
|
|
297
|
+
const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] };
|
|
298
|
+
if (raw && Array.isArray(raw.input)) {
|
|
299
|
+
const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, raw.input.length);
|
|
300
|
+
if (raw.input.slice(0, replayPrefixLen).some(item => isGeneratedDeveloperItem(item, text))) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
parsed.context.messages.push({ role: "developer", content: text, timestamp: Date.now() });
|
|
287
306
|
if (raw && Array.isArray(raw.input)) {
|
|
288
|
-
const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] };
|
|
289
307
|
// compaction_trigger must remain the final input item (codex-rs + ChatGPT backend both
|
|
290
308
|
// validate this). Insert the developer message BEFORE the trigger when present.
|
|
291
309
|
const last = raw.input[raw.input.length - 1];
|
|
@@ -297,4 +315,3 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string):
|
|
|
297
315
|
}
|
|
298
316
|
}
|
|
299
317
|
|
|
300
|
-
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
headersForCodexAuthContext,
|
|
50
50
|
isCodexAuthContextUsable,
|
|
51
51
|
resolveCodexAuthContext,
|
|
52
|
+
stripCodexRuntimeProviderFields,
|
|
52
53
|
type CodexAuthContext,
|
|
53
54
|
} from "../../codex/auth-context";
|
|
54
55
|
import {
|
|
@@ -89,11 +90,15 @@ import type { AttemptRecoveryKind } from "../../usage/log";
|
|
|
89
90
|
import {
|
|
90
91
|
consumeForInspection,
|
|
91
92
|
consumeForResponseLogMetadata,
|
|
93
|
+
createSseInspector,
|
|
92
94
|
markNativePassthroughSseResponse,
|
|
93
95
|
relaySseWithFailedTail,
|
|
94
96
|
relayWithAbort,
|
|
95
97
|
sanitizePassthroughHeaders,
|
|
96
98
|
} from "../relay";
|
|
99
|
+
import { relaySseEagerBounded } from "../relay-eager";
|
|
100
|
+
import { decideEagerRelay } from "../../lib/bun-stream-caps";
|
|
101
|
+
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
97
102
|
import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair";
|
|
98
103
|
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
99
104
|
|
|
@@ -147,6 +152,45 @@ export function usesCodexForwardPoolAuth(
|
|
|
147
152
|
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
function normalizeCodexUnsupportedModelDetail(value: string): string {
|
|
156
|
+
return value.trim().replace(/\s+/gu, " ").toLocaleLowerCase("en-US");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAllowListedCodexAccountModel400(
|
|
160
|
+
status: number,
|
|
161
|
+
bodyText: string,
|
|
162
|
+
modelId: string,
|
|
163
|
+
): boolean {
|
|
164
|
+
if (status !== 400) return false;
|
|
165
|
+
try {
|
|
166
|
+
const payload = JSON.parse(bodyText) as unknown;
|
|
167
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
168
|
+
const detail = (payload as { detail?: unknown }).detail;
|
|
169
|
+
if (typeof detail !== "string") return false;
|
|
170
|
+
const expected = `The '${modelId}' model is not supported when using Codex with a ChatGPT account.`;
|
|
171
|
+
return normalizeCodexUnsupportedModelDetail(detail)
|
|
172
|
+
=== normalizeCodexUnsupportedModelDetail(expected);
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function shouldRetryCodexPoolAccountModel400(
|
|
179
|
+
response: Response,
|
|
180
|
+
modelId: string,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<boolean> {
|
|
183
|
+
if (response.status !== 400) return false;
|
|
184
|
+
try {
|
|
185
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
186
|
+
return body.displaySafe
|
|
187
|
+
&& !body.truncated
|
|
188
|
+
&& isAllowListedCodexAccountModel400(response.status, body.text, modelId);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
150
194
|
|
|
151
195
|
|
|
152
196
|
export function codexForwardTerminalOutcomeRecorder(
|
|
@@ -342,6 +386,22 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
|
342
386
|
return childHeaders;
|
|
343
387
|
}
|
|
344
388
|
|
|
389
|
+
const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
|
|
390
|
+
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
|
|
391
|
+
|
|
392
|
+
function unreadableEncryptedAgentTaskResponse(): Response {
|
|
393
|
+
return new Response(
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
error: {
|
|
396
|
+
message: UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE,
|
|
397
|
+
type: "invalid_request_error",
|
|
398
|
+
code: "unreadable_encrypted_agent_task",
|
|
399
|
+
},
|
|
400
|
+
}),
|
|
401
|
+
{ status: 400, headers: { "Content-Type": "application/json" } },
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
345
405
|
|
|
346
406
|
|
|
347
407
|
export async function handleComboResponses(
|
|
@@ -366,9 +426,30 @@ export async function handleComboResponses(
|
|
|
366
426
|
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
367
427
|
}
|
|
368
428
|
|
|
429
|
+
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
430
|
+
(rawBody as { input?: unknown } | undefined)?.input,
|
|
431
|
+
);
|
|
432
|
+
const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
|
|
433
|
+
const provider = config.providers[target.provider];
|
|
434
|
+
if (!provider || provider.disabled === true) return false;
|
|
435
|
+
try {
|
|
436
|
+
const route = routeModel(config, `${target.provider}/${target.model}`);
|
|
437
|
+
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
|
|
443
|
+
!unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
|
|
444
|
+
|
|
445
|
+
if (unreadableEncryptedAgentTask && !combo.targets.some(canDecryptUnreadableAgentTask)) {
|
|
446
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
447
|
+
}
|
|
448
|
+
|
|
369
449
|
const initialNow = Date.now();
|
|
370
450
|
let pick = pickComboTarget(config, comboId, {
|
|
371
|
-
eligible: target =>
|
|
451
|
+
eligible: target => payloadEligible(target)
|
|
452
|
+
&& !isComboTargetInCooldown(comboId, target, initialNow),
|
|
372
453
|
});
|
|
373
454
|
if (!pick) {
|
|
374
455
|
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
@@ -529,6 +610,7 @@ export async function handleComboResponses(
|
|
|
529
610
|
pick = advanceComboAfterFailure(config, pick, {
|
|
530
611
|
retryAfter: failure.retryAfter,
|
|
531
612
|
now: Date.now(),
|
|
613
|
+
eligible: payloadEligible,
|
|
532
614
|
});
|
|
533
615
|
}
|
|
534
616
|
return lastFailure!;
|
|
@@ -552,12 +634,12 @@ export async function handleResponses(
|
|
|
552
634
|
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
553
635
|
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
554
636
|
}
|
|
555
|
-
const originalBody = body;
|
|
556
|
-
body = expandPreviousResponseInput(body);
|
|
557
|
-
const previousResponseInputExpanded = body !== originalBody;
|
|
558
637
|
const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
|
|
559
638
|
(body as { input?: unknown } | undefined)?.input,
|
|
560
639
|
);
|
|
640
|
+
const originalBody = body;
|
|
641
|
+
body = expandPreviousResponseInput(body);
|
|
642
|
+
const previousResponseInputExpanded = body !== originalBody;
|
|
561
643
|
|
|
562
644
|
// Spawn-message compatibility (both directions): agent_message task payloads ride in
|
|
563
645
|
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
@@ -580,6 +662,8 @@ export async function handleResponses(
|
|
|
580
662
|
if (previousResponseInputExpanded) parsed._previousResponseInputExpanded = true;
|
|
581
663
|
parsed._providerContinuation = previousResponseProviderState(parsed.previousResponseId);
|
|
582
664
|
parsed._cursorConversationId = parsed._providerContinuation?.cursor?.conversationId;
|
|
665
|
+
const clientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim();
|
|
666
|
+
if (clientThreadId) parsed._clientThreadId = clientThreadId;
|
|
583
667
|
} catch (err) {
|
|
584
668
|
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
585
669
|
}
|
|
@@ -605,7 +689,10 @@ export async function handleResponses(
|
|
|
605
689
|
(parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
|
|
606
690
|
}
|
|
607
691
|
(logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
|
|
692
|
+
// Helpers must not resume/append into the parent thread's Cursor conversation.
|
|
693
|
+
parsed._cursorIsolateConversation = true;
|
|
608
694
|
}
|
|
695
|
+
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
609
696
|
|
|
610
697
|
let route;
|
|
611
698
|
try {
|
|
@@ -621,11 +708,7 @@ export async function handleResponses(
|
|
|
621
708
|
// providers cannot. Reject the raw-input classification before adapter construction
|
|
622
709
|
// or provider dispatch so an unreadable worker task cannot trigger a cost storm.
|
|
623
710
|
if (!isCanonicalOpenAiForwardProvider(route.provider) && unreadableEncryptedAgentTask) {
|
|
624
|
-
return
|
|
625
|
-
400,
|
|
626
|
-
"invalid_request_error",
|
|
627
|
-
"Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.",
|
|
628
|
-
);
|
|
711
|
+
return unreadableEncryptedAgentTaskResponse();
|
|
629
712
|
}
|
|
630
713
|
|
|
631
714
|
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace
|
|
@@ -772,6 +855,10 @@ export async function handleResponses(
|
|
|
772
855
|
}
|
|
773
856
|
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
774
857
|
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
858
|
+
// Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without
|
|
859
|
+
// codexAccountMode still get a credential-derived scope inside the Cursor adapter.
|
|
860
|
+
const identityScope = codexLogAccountId(authCtx);
|
|
861
|
+
if (identityScope) parsed._cursorIdentityScope = identityScope;
|
|
775
862
|
|
|
776
863
|
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
777
864
|
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
@@ -917,7 +1004,7 @@ export async function handleResponses(
|
|
|
917
1004
|
+ `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
|
|
918
1005
|
);
|
|
919
1006
|
}
|
|
920
|
-
|
|
1007
|
+
let request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
921
1008
|
const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
922
1009
|
? request.usageLog.inputTokens
|
|
923
1010
|
: undefined;
|
|
@@ -931,6 +1018,20 @@ export async function handleResponses(
|
|
|
931
1018
|
linkAbortSignal(upstream, options.abortSignal);
|
|
932
1019
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
933
1020
|
let upstreamResponse: Response;
|
|
1021
|
+
const transportFailureResponse = (err: unknown): Response => {
|
|
1022
|
+
upstream.abort();
|
|
1023
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1024
|
+
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
1025
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1026
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1027
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const msg = outcome === "timeout"
|
|
1031
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
1032
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
1033
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
1034
|
+
};
|
|
934
1035
|
try {
|
|
935
1036
|
// Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
|
|
936
1037
|
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
@@ -947,18 +1048,80 @@ export async function handleResponses(
|
|
|
947
1048
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
948
1049
|
);
|
|
949
1050
|
} catch (err) {
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1051
|
+
return transportFailureResponse(err);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
if (
|
|
1055
|
+
usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
1056
|
+
&& await shouldRetryCodexPoolAccountModel400(
|
|
1057
|
+
upstreamResponse,
|
|
1058
|
+
route.modelId,
|
|
1059
|
+
options.abortSignal,
|
|
1060
|
+
)
|
|
1061
|
+
) {
|
|
1062
|
+
const firstAuthCtx = authCtx;
|
|
1063
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
1064
|
+
try {
|
|
1065
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
1066
|
+
req.headers,
|
|
1067
|
+
config,
|
|
1068
|
+
"pool",
|
|
1069
|
+
{ excludeAccountId: firstAuthCtx.accountId },
|
|
1070
|
+
);
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
if (
|
|
1073
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
1074
|
+
&& !(error instanceof CodexAuthContextError)
|
|
1075
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
1076
|
+
) throw error;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (retryAuthCtx?.kind === "pool" || retryAuthCtx?.kind === "main-pool") {
|
|
1080
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, 400, {
|
|
955
1081
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
956
1082
|
});
|
|
1083
|
+
|
|
1084
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
1085
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
1086
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
1087
|
+
retryAuthCtx,
|
|
1088
|
+
"pool",
|
|
1089
|
+
);
|
|
1090
|
+
const retryAdapter = resolveAdapter(
|
|
1091
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
|
|
1092
|
+
config.cacheRetention,
|
|
1093
|
+
);
|
|
1094
|
+
request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
|
|
1095
|
+
|
|
1096
|
+
await upstreamResponse.body?.cancel().catch(() => undefined);
|
|
1097
|
+
authCtx = retryAuthCtx;
|
|
1098
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
1099
|
+
selectedForwardHeaders = retryHeaders;
|
|
1100
|
+
route.provider = retryProvider;
|
|
1101
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
1102
|
+
route.providerName,
|
|
1103
|
+
retryAuthCtx.accountId,
|
|
1104
|
+
config,
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
1108
|
+
try {
|
|
1109
|
+
upstreamResponse = await fetchWithHeaderTimeout(
|
|
1110
|
+
request.url,
|
|
1111
|
+
{
|
|
1112
|
+
method: request.method,
|
|
1113
|
+
headers: request.headers,
|
|
1114
|
+
body: request.body,
|
|
1115
|
+
},
|
|
1116
|
+
upstream.signal,
|
|
1117
|
+
connectMs,
|
|
1118
|
+
parsed.stream,
|
|
1119
|
+
providerFetch(route.provider),
|
|
1120
|
+
);
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
return transportFailureResponse(err);
|
|
1123
|
+
}
|
|
957
1124
|
}
|
|
958
|
-
const msg = outcome === "timeout"
|
|
959
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
960
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
961
|
-
return formatErrorResponse(502, "upstream_error", msg);
|
|
962
1125
|
}
|
|
963
1126
|
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
964
1127
|
const resolvedModel = headers.get("openai-model")?.trim();
|
|
@@ -1021,9 +1184,57 @@ export async function handleResponses(
|
|
|
1021
1184
|
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
1022
1185
|
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
1023
1186
|
// background for terminal-outcome/quota inspection only.
|
|
1187
|
+
// #314 alternative shape: on win32 (no repair) with a runtime carrying the
|
|
1188
|
+
// Bun#32111 fix — or explicit `streamMode: "eager-relay"` opt-in — the tee
|
|
1189
|
+
// is skipped entirely and relaySseEagerBounded provides a single eager
|
|
1190
|
+
// bounded reader with inline inspection (see src/server/relay-eager.ts and
|
|
1191
|
+
// devlog/_plan/260723_win_mem_safestream/020). Default on the bundled
|
|
1192
|
+
// known-bad runtime remains the tee path below.
|
|
1024
1193
|
if (upstreamResponse.ok && isEventStream && upstreamResponse.body) {
|
|
1025
|
-
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
1026
1194
|
const repairConfig = route.provider.responsesItemIdRepair;
|
|
1195
|
+
const winNoRepair = process.platform === "win32" && !hasResponsesItemIdRepair(repairConfig);
|
|
1196
|
+
const eagerDecision = winNoRepair ? decideEagerRelay(config.streamMode ?? "auto") : null;
|
|
1197
|
+
if (eagerDecision?.useEagerRelay) {
|
|
1198
|
+
const turnAc = new AbortController();
|
|
1199
|
+
linkAbortSignal(upstream, turnAc.signal);
|
|
1200
|
+
registerTurn(turnAc);
|
|
1201
|
+
const reportNativeTerminal = recordTerminalOutcomes
|
|
1202
|
+
? (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
1203
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
1204
|
+
options.onNativePassthroughTerminal?.(status);
|
|
1205
|
+
}
|
|
1206
|
+
: undefined;
|
|
1207
|
+
const inspector = createSseInspector({
|
|
1208
|
+
onTerminal: reportNativeTerminal,
|
|
1209
|
+
logCtx,
|
|
1210
|
+
onCompletedResponse: rememberPassthroughResponse,
|
|
1211
|
+
onFirstOutput: options.onFirstOutput,
|
|
1212
|
+
});
|
|
1213
|
+
const eagerBody = relaySseEagerBounded(upstreamResponse.body, turnAc, {
|
|
1214
|
+
inspectChunk: chunk => inspector.feed(chunk),
|
|
1215
|
+
finishInspection: () => inspector.finish(),
|
|
1216
|
+
sawTerminal: () => inspector.reported(),
|
|
1217
|
+
onSynthetic: kind => {
|
|
1218
|
+
if (!reportNativeTerminal) return;
|
|
1219
|
+
if (kind === "incomplete") {
|
|
1220
|
+
logCtx.terminalSource = "synthetic";
|
|
1221
|
+
reportNativeTerminal("incomplete");
|
|
1222
|
+
} else {
|
|
1223
|
+
logCtx.transportPhase = "mid_stream";
|
|
1224
|
+
logCtx.terminalSource = "synthetic";
|
|
1225
|
+
reportNativeTerminal("failed", 502);
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
1229
|
+
onDone: () => unregisterTurn(turnAc),
|
|
1230
|
+
});
|
|
1231
|
+
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1232
|
+
return markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
1233
|
+
status: upstreamResponse.status,
|
|
1234
|
+
headers,
|
|
1235
|
+
}));
|
|
1236
|
+
}
|
|
1237
|
+
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
1027
1238
|
const turnAc = new AbortController();
|
|
1028
1239
|
linkAbortSignal(upstream, turnAc.signal);
|
|
1029
1240
|
registerTurn(turnAc);
|
|
@@ -1102,7 +1313,9 @@ export async function handleResponses(
|
|
|
1102
1313
|
if (adapter.runTurn) {
|
|
1103
1314
|
const runTurnAbort = new AbortController();
|
|
1104
1315
|
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
1105
|
-
const queue = createAdapterEventQueue(
|
|
1316
|
+
const queue = createAdapterEventQueue({
|
|
1317
|
+
onBacklogExceeded: () => runTurnAbort.abort(),
|
|
1318
|
+
});
|
|
1106
1319
|
const runTurn = async (): Promise<void> => {
|
|
1107
1320
|
try {
|
|
1108
1321
|
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
@@ -1419,6 +1632,8 @@ export async function handleResponses(
|
|
|
1419
1632
|
}
|
|
1420
1633
|
}
|
|
1421
1634
|
|
|
1635
|
+
cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
|
|
1636
|
+
|
|
1422
1637
|
if (parsed.stream) {
|
|
1423
1638
|
const eventStream = activeAdapter.parseStream(upstreamResponse);
|
|
1424
1639
|
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
@@ -104,52 +104,131 @@ export function looksLikeBackendCiphertext(payload: string): boolean {
|
|
|
104
104
|
|
|
105
105
|
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Backend-minted ciphertext runs are Fernet tokens (base64url, version byte 0x80).
|
|
109
|
+
* Used to carve embedded blobs out of MIXED slots: plugin hooks may prepend
|
|
110
|
+
* plaintext control metadata to a task body that is already backend-encrypted.
|
|
111
|
+
*/
|
|
112
|
+
const FERNET_TOKEN_CANDIDATE = /g[A-Za-z0-9_-]{97,}={0,2}/g;
|
|
113
|
+
const FERNET_TOKEN_BOUNDARY_CHAR = /[A-Za-z0-9_=-]/;
|
|
114
|
+
|
|
115
|
+
interface FernetTokenRun {
|
|
116
|
+
index: number;
|
|
117
|
+
token: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Validate only the key-independent Fernet wire structure. Authenticity cannot be
|
|
122
|
+
* checked without the backend key, but a real token must still be canonical base64url
|
|
123
|
+
* containing version(1) + timestamp(8) + IV(16) + AES-CBC ciphertext(16*n) + HMAC(32).
|
|
124
|
+
* Timestamp freshness is deliberately not enforced: old history can contain valid tokens.
|
|
125
|
+
*/
|
|
126
|
+
function isStructurallyValidFernetToken(token: string): boolean {
|
|
127
|
+
if (token.length < 100 || token.length % 4 !== 0) return false;
|
|
128
|
+
if (!/^[A-Za-z0-9_-]+={0,2}$/.test(token)) return false;
|
|
129
|
+
|
|
130
|
+
const unpadded = token.replace(/=+$/, "");
|
|
131
|
+
const paddingLength = token.length - unpadded.length;
|
|
132
|
+
const expectedPadding = (4 - (unpadded.length % 4)) % 4;
|
|
133
|
+
if (expectedPadding > 2 || paddingLength !== expectedPadding) return false;
|
|
134
|
+
|
|
135
|
+
let decoded: Buffer;
|
|
136
|
+
try {
|
|
137
|
+
decoded = Buffer.from(unpadded, "base64url");
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (decoded.toString("base64url") !== unpadded) return false;
|
|
142
|
+
if (decoded.length < 73 || decoded[0] !== 0x80) return false;
|
|
143
|
+
|
|
144
|
+
const ciphertextLength = decoded.length - 57;
|
|
145
|
+
return ciphertextLength >= 16 && ciphertextLength % 16 === 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Maximal, boundary-delimited and structurally valid Fernet runs embedded in a slot. */
|
|
149
|
+
function fernetTokenRuns(payload: string): FernetTokenRun[] {
|
|
150
|
+
const runs: FernetTokenRun[] = [];
|
|
151
|
+
for (const match of payload.matchAll(FERNET_TOKEN_CANDIDATE)) {
|
|
152
|
+
const index = match.index ?? 0;
|
|
153
|
+
const token = match[0];
|
|
154
|
+
const before = index > 0 ? payload[index - 1] : undefined;
|
|
155
|
+
const after = payload[index + token.length];
|
|
156
|
+
if (before && FERNET_TOKEN_BOUNDARY_CHAR.test(before)) continue;
|
|
157
|
+
if (after && FERNET_TOKEN_BOUNDARY_CHAR.test(after)) continue;
|
|
158
|
+
if (!isStructurallyValidFernetToken(token)) continue;
|
|
159
|
+
runs.push({ index, token });
|
|
160
|
+
}
|
|
161
|
+
return runs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[]): string {
|
|
165
|
+
let last = 0;
|
|
166
|
+
let text = "";
|
|
167
|
+
for (const run of runs) {
|
|
168
|
+
text += `${payload.slice(last, run.index)}\n\n`;
|
|
169
|
+
last = run.index + run.token.length;
|
|
170
|
+
}
|
|
171
|
+
return `${text}${payload.slice(last)}`;
|
|
172
|
+
}
|
|
108
173
|
|
|
109
174
|
export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*NEW_TASK[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
|
|
110
175
|
|
|
111
|
-
|
|
176
|
+
// CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
|
|
177
|
+
// later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
|
|
178
|
+
// removed independently, and a following routing envelope remains available to the
|
|
179
|
+
// envelope stripper below.
|
|
180
|
+
export const AGENT_MESSAGE_CONTROL_PREAMBLE = /(?:^|\n)\[CXC-[A-Z0-9-]+\][^\n]*(?:\n(?!\n|Message Type\s*:)[^\n]*)*(?=\n{2,}|\nMessage Type\s*:|$)/gi;
|
|
112
181
|
|
|
113
182
|
export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
114
183
|
if (!Array.isArray(input)) return false;
|
|
115
184
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
185
|
+
// codex-rs appends one NEW_TASK agent_message at the current input tail. Historical
|
|
186
|
+
// agent messages may be adjacent in full-history bodies; they must not poison the
|
|
187
|
+
// later task. compaction_trigger/additional_tools are trailing metadata rather than
|
|
188
|
+
// a newer user turn.
|
|
189
|
+
let index = input.length - 1;
|
|
190
|
+
while (index >= 0) {
|
|
191
|
+
const item = input[index];
|
|
192
|
+
const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
|
|
193
|
+
if (type !== "compaction_trigger" && type !== "additional_tools") break;
|
|
194
|
+
index -= 1;
|
|
195
|
+
}
|
|
196
|
+
const item = input[index];
|
|
197
|
+
if (!item || typeof item !== "object" || (item as { type?: unknown }).type !== "agent_message") {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
120
200
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
let hasFernetTask = false;
|
|
125
|
-
const readableParts: string[] = [];
|
|
126
|
-
for (const part of content) {
|
|
127
|
-
if (!part || typeof part !== "object") continue;
|
|
128
|
-
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
|
|
129
|
-
if (
|
|
130
|
-
(record.type === "input_text" || record.type === "text" || record.type === "output_text")
|
|
131
|
-
&& typeof record.text === "string"
|
|
132
|
-
) {
|
|
133
|
-
readableParts.push(record.text);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
201
|
+
const content = (item as { content?: unknown }).content;
|
|
202
|
+
if (!Array.isArray(content)) return false;
|
|
139
203
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
204
|
+
let hasFernetTask = false;
|
|
205
|
+
const readableParts: string[] = [];
|
|
206
|
+
for (const part of content) {
|
|
207
|
+
if (!part || typeof part !== "object") continue;
|
|
208
|
+
const record = part as { type?: unknown; text?: unknown; encrypted_content?: unknown };
|
|
209
|
+
if (
|
|
210
|
+
(record.type === "input_text" || record.type === "text")
|
|
211
|
+
&& typeof record.text === "string"
|
|
212
|
+
) {
|
|
213
|
+
readableParts.push(record.text);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (record.type !== "encrypted_content" || typeof record.encrypted_content !== "string") {
|
|
217
|
+
continue;
|
|
143
218
|
}
|
|
144
219
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
220
|
+
const runs = fernetTokenRuns(record.encrypted_content);
|
|
221
|
+
if (runs.length > 0) hasFernetTask = true;
|
|
222
|
+
readableParts.push(textWithoutFernetRuns(record.encrypted_content, runs));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!hasFernetTask) return false;
|
|
226
|
+
const readableTask = readableParts
|
|
227
|
+
.join("\n\n")
|
|
228
|
+
.replace(AGENT_MESSAGE_CONTROL_PREAMBLE, "\n")
|
|
229
|
+
.replace(AGENT_MESSAGE_ROUTING_ENVELOPE, "\n")
|
|
230
|
+
.trim();
|
|
231
|
+
return readableTask.length === 0;
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
|
|
@@ -157,12 +236,11 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
|
157
236
|
export function encryptedSlotParts(payload: string): Array<Record<string, string>> {
|
|
158
237
|
const parts: Array<Record<string, string>> = [];
|
|
159
238
|
let last = 0;
|
|
160
|
-
for (const
|
|
161
|
-
const
|
|
162
|
-
const before = payload.slice(last, index);
|
|
239
|
+
for (const run of fernetTokenRuns(payload)) {
|
|
240
|
+
const before = payload.slice(last, run.index);
|
|
163
241
|
if (before.trim().length > 0) parts.push({ type: "input_text", text: before });
|
|
164
|
-
parts.push({ type: "encrypted_content", encrypted_content:
|
|
165
|
-
last = index +
|
|
242
|
+
parts.push({ type: "encrypted_content", encrypted_content: run.token });
|
|
243
|
+
last = run.index + run.token.length;
|
|
166
244
|
}
|
|
167
245
|
const rest = payload.slice(last);
|
|
168
246
|
if (rest.trim().length > 0) parts.push({ type: "input_text", text: rest });
|
|
@@ -228,4 +306,3 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
|
228
306
|
return rewritten;
|
|
229
307
|
}
|
|
230
308
|
|
|
231
|
-
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -22,6 +22,13 @@ export interface WsData {
|
|
|
22
22
|
authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup
|
|
23
23
|
cancel?: () => void; // cancels the in-flight stream reader/fetch
|
|
24
24
|
turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
|
|
25
|
+
/** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
|
|
26
|
+
kind?: "responses" | "live-sideband";
|
|
27
|
+
liveUpstream?: WebSocket;
|
|
28
|
+
liveUpstreamUrl?: string;
|
|
29
|
+
liveUpstreamHeaders?: Record<string, string>;
|
|
30
|
+
livePending?: Array<string | Buffer>;
|
|
31
|
+
liveOpened?: boolean;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export class WsSendDroppedError extends Error {
|
package/src/types.ts
CHANGED
|
@@ -5,10 +5,24 @@ export interface OcxParsedRequest {
|
|
|
5
5
|
stream: boolean;
|
|
6
6
|
options: OcxRequestOptions;
|
|
7
7
|
_rawBody?: unknown;
|
|
8
|
+
/** Number of leading raw input items restored from local previous_response_id state. */
|
|
9
|
+
_replayPrefixLen?: number;
|
|
8
10
|
/** True when the proxy expanded a previous_response_id request into a full input replay. */
|
|
9
11
|
_previousResponseInputExpanded?: boolean;
|
|
10
12
|
/** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */
|
|
11
13
|
_cursorConversationId?: string;
|
|
14
|
+
/** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */
|
|
15
|
+
_clientThreadId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
|
|
18
|
+
* When absent (single-operator local proxy), derivation stays local-scoped.
|
|
19
|
+
*/
|
|
20
|
+
_cursorIdentityScope?: string;
|
|
21
|
+
/**
|
|
22
|
+
* True for helper/shadow/compaction turns that must not append into the main Cursor conversation
|
|
23
|
+
* derived from the parent thread id.
|
|
24
|
+
*/
|
|
25
|
+
_cursorIsolateConversation?: boolean;
|
|
12
26
|
/** Provider-private continuation metadata resolved from the Responses previous_response_id chain. */
|
|
13
27
|
_providerContinuation?: OcxProviderContinuationState;
|
|
14
28
|
/**
|
|
@@ -445,6 +459,15 @@ export interface OcxConfig {
|
|
|
445
459
|
* Undefined = passthrough (don't modify what the client sends).
|
|
446
460
|
*/
|
|
447
461
|
fastMode?: boolean;
|
|
462
|
+
/**
|
|
463
|
+
* Windows SSE passthrough stream shape (#314 mitigation).
|
|
464
|
+
* "auto" (default): eager bounded relay only on runtimes proven to carry the
|
|
465
|
+
* Bun#32111 fix (none today → legacy tee). "eager-relay": force the new relay
|
|
466
|
+
* (accepts #32111 crash risk on Bun 1.3.14). "legacy-tee": pin the tee path.
|
|
467
|
+
* Persisted in config.json because Windows services do not inherit shell env.
|
|
468
|
+
* See src/lib/bun-stream-caps.ts.
|
|
469
|
+
*/
|
|
470
|
+
streamMode?: "auto" | "legacy-tee" | "eager-relay";
|
|
448
471
|
/**
|
|
449
472
|
* Custom override for the injected multi-agent guidance body (the text inside the
|
|
450
473
|
* <multi_agent_mode> tags). When set, it replaces the built-in prompt on whichever
|
|
@@ -529,6 +552,8 @@ export interface OcxConfig {
|
|
|
529
552
|
apiKeys?: Array<{ id: string; name: string; key: string; createdAt: string }>;
|
|
530
553
|
/** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
|
|
531
554
|
codexAutoStart?: boolean;
|
|
555
|
+
/** Restore an installed shim after a stable external Codex update replaces it. Default true. */
|
|
556
|
+
codexShimAutoRestore?: boolean;
|
|
532
557
|
/**
|
|
533
558
|
* Compatibility mode: temporarily rewrite Codex resume-history metadata while the proxy is active
|
|
534
559
|
* so Codex App can show old OpenAI chats and opencodex-created exec chats under its default
|
package/src/usage/cost.ts
CHANGED
|
Binary file
|