@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
package/src/combos/resolve.ts
CHANGED
|
@@ -143,13 +143,18 @@ export function noteComboFailure(comboId: string, target: OcxComboTarget): void
|
|
|
143
143
|
export function advanceComboAfterFailure(
|
|
144
144
|
config: OcxConfig,
|
|
145
145
|
pick: ComboPick,
|
|
146
|
-
options: {
|
|
146
|
+
options: {
|
|
147
|
+
retryAfter?: string | null;
|
|
148
|
+
now?: number;
|
|
149
|
+
eligible?: (target: Required<OcxComboTarget>) => boolean;
|
|
150
|
+
} = {},
|
|
147
151
|
): ComboPick | null {
|
|
148
152
|
noteComboFailure(pick.comboId, pick.target);
|
|
149
153
|
coolComboTarget(pick.comboId, pick.target, options);
|
|
150
154
|
return pickComboTarget(config, pick.comboId, {
|
|
151
155
|
exclude: pick.attempted,
|
|
152
|
-
eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
|
|
156
|
+
eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
|
|
157
|
+
&& (options.eligible?.(target) ?? true),
|
|
153
158
|
});
|
|
154
159
|
}
|
|
155
160
|
|
package/src/config.ts
CHANGED
|
@@ -442,6 +442,11 @@ const configSchema = z.object({
|
|
|
442
442
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
443
443
|
contextCapValue: z.number().int().positive().optional(),
|
|
444
444
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
445
|
+
codexShimAutoRestore: z.boolean().optional(),
|
|
446
|
+
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
447
|
+
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
448
|
+
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
449
|
+
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
|
|
445
450
|
}).passthrough().superRefine((config, ctx) => {
|
|
446
451
|
for (const name of Object.keys(config.providers)) {
|
|
447
452
|
if (!isValidProviderName(name)) {
|
|
@@ -642,6 +647,19 @@ export function hardenExistingSecret(path: string): void {
|
|
|
642
647
|
}
|
|
643
648
|
}
|
|
644
649
|
}
|
|
650
|
+
/**
|
|
651
|
+
* The schema's `.catch(undefined)` silently degrades an invalid persisted
|
|
652
|
+
* `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
|
|
653
|
+
* "legacy_tee") is discoverable instead of silently changing stream shape.
|
|
654
|
+
*/
|
|
655
|
+
function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
|
|
656
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
657
|
+
const raw = (rawParsed as Record<string, unknown>).streamMode;
|
|
658
|
+
if (raw !== undefined && validated.streamMode === undefined) {
|
|
659
|
+
console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
645
663
|
export function loadConfig(): OcxConfig {
|
|
646
664
|
const dir = getConfigDir();
|
|
647
665
|
const configPath = getConfigPath();
|
|
@@ -655,7 +673,10 @@ export function loadConfig(): OcxConfig {
|
|
|
655
673
|
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
656
674
|
const parsed = JSON.parse(raw);
|
|
657
675
|
const result = configSchema.safeParse(parsed);
|
|
658
|
-
if (result.success)
|
|
676
|
+
if (result.success) {
|
|
677
|
+
warnDegradedStreamMode(parsed, result.data as OcxConfig);
|
|
678
|
+
return result.data as OcxConfig;
|
|
679
|
+
}
|
|
659
680
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
660
681
|
// discarding it entirely, so pool accounts and providers survive a missing
|
|
661
682
|
// field like defaultProvider.
|
|
@@ -773,6 +794,15 @@ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">)
|
|
|
773
794
|
return config.codexAutoStart !== false;
|
|
774
795
|
}
|
|
775
796
|
|
|
797
|
+
export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE";
|
|
798
|
+
|
|
799
|
+
export function codexShimAutoRestoreEnabled(
|
|
800
|
+
config: Pick<OcxConfig, "codexShimAutoRestore">,
|
|
801
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
802
|
+
): boolean {
|
|
803
|
+
return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0";
|
|
804
|
+
}
|
|
805
|
+
|
|
776
806
|
export function multiAgentGuidanceEnabled(
|
|
777
807
|
config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
|
|
778
808
|
): boolean {
|
|
@@ -802,6 +832,7 @@ export function getDefaultConfig(): OcxConfig {
|
|
|
802
832
|
multiAgentGuidanceEnabled: true,
|
|
803
833
|
websockets: false,
|
|
804
834
|
codexAutoStart: true,
|
|
835
|
+
codexShimAutoRestore: true,
|
|
805
836
|
};
|
|
806
837
|
}
|
|
807
838
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun runtime stream-capability gate for the Windows SSE passthrough path (#314).
|
|
3
|
+
*
|
|
4
|
+
* The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer
|
|
5
|
+
* loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun
|
|
6
|
+
* PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry
|
|
7
|
+
* that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is
|
|
8
|
+
* "known-bad" until a bundle-bump commit sets it. Config `streamMode` can force
|
|
9
|
+
* either path (persisted in config.json because Windows services do not
|
|
10
|
+
* inherit shell env — see devlog/_plan/260723_win_mem_safestream/001).
|
|
11
|
+
*
|
|
12
|
+
* Prerelease conservatism: a version carrying a prerelease suffix (e.g.
|
|
13
|
+
* `1.4.0-canary.3`) is NEVER treated as fixed even when its numeric triple
|
|
14
|
+
* reaches the threshold — canaries are exactly the OPENCODEX_BUN_PATH audience
|
|
15
|
+
* and may predate the fix commit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bump in the SAME commit that bumps package.json's bundled Bun to a version
|
|
20
|
+
* verified to include Bun PR #32120. null = no released version is known-fixed.
|
|
21
|
+
*/
|
|
22
|
+
export const MIN_FIXED_BUN_VERSION: string | null = null;
|
|
23
|
+
|
|
24
|
+
export type StreamMode = "auto" | "legacy-tee" | "eager-relay";
|
|
25
|
+
|
|
26
|
+
export const STREAM_MODES: readonly StreamMode[] = ["auto", "legacy-tee", "eager-relay"];
|
|
27
|
+
|
|
28
|
+
export function isStreamMode(value: unknown): value is StreamMode {
|
|
29
|
+
return typeof value === "string" && (STREAM_MODES as readonly string[]).includes(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Numeric [major, minor, patch] triple, or null for unparseable input. */
|
|
33
|
+
export function parseBunVersion(version: string): [number, number, number] | null {
|
|
34
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Compare two version strings numerically; null when either is unparseable. */
|
|
40
|
+
export function compareBunVersions(a: string, b: string): number | null {
|
|
41
|
+
const pa = parseBunVersion(a);
|
|
42
|
+
const pb = parseBunVersion(b);
|
|
43
|
+
if (!pa || !pb) return null;
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
if (pa[i]! !== pb[i]!) return pa[i]! - pb[i]!;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasPrereleaseSuffix(version: string): boolean {
|
|
51
|
+
return /^\d+\.\d+\.\d+-/.test(version.trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True only when `version` is proven to carry the Bun#32120 async-pull cancel
|
|
56
|
+
* fix. Conservative: unknown, unparseable, prerelease, or no threshold → false.
|
|
57
|
+
*/
|
|
58
|
+
export function bunHasAsyncPullCancelFix(
|
|
59
|
+
version: string,
|
|
60
|
+
minFixed: string | null = MIN_FIXED_BUN_VERSION,
|
|
61
|
+
): boolean {
|
|
62
|
+
if (!minFixed) return false;
|
|
63
|
+
if (hasPrereleaseSuffix(version)) return false;
|
|
64
|
+
const cmp = compareBunVersions(version, minFixed);
|
|
65
|
+
return cmp !== null && cmp >= 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type EagerRelayDecision = {
|
|
69
|
+
useEagerRelay: boolean;
|
|
70
|
+
reason: "config-legacy" | "config-eager" | "auto-fixed-runtime" | "auto-known-bad";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide the win32 SSE client-path shape. `version`/`minFixed` are injectable
|
|
75
|
+
* for tests. Non-win32 callers never consult this (their default path is
|
|
76
|
+
* unchanged); the caller owns the platform check.
|
|
77
|
+
*/
|
|
78
|
+
export function decideEagerRelay(
|
|
79
|
+
mode: StreamMode,
|
|
80
|
+
version: string = Bun.version,
|
|
81
|
+
minFixed: string | null = MIN_FIXED_BUN_VERSION,
|
|
82
|
+
): EagerRelayDecision {
|
|
83
|
+
if (mode === "legacy-tee") return { useEagerRelay: false, reason: "config-legacy" };
|
|
84
|
+
if (mode === "eager-relay") return { useEagerRelay: true, reason: "config-eager" };
|
|
85
|
+
return bunHasAsyncPullCancelFix(version, minFixed)
|
|
86
|
+
? { useEagerRelay: true, reason: "auto-fixed-runtime" }
|
|
87
|
+
: { useEagerRelay: false, reason: "auto-known-bad" };
|
|
88
|
+
}
|
package/src/lib/crash-guard.ts
CHANGED
|
@@ -162,7 +162,9 @@ const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
|
|
|
162
162
|
* disconnects mid-SSE on the tee()'d passthrough path (responses.ts Bun#32111
|
|
163
163
|
* workaround), Bun's sink-close teardown tries to cancel the tee-locked source body
|
|
164
164
|
* and rejects off-path. Request lifecycle is already settled at that point; same
|
|
165
|
-
* benign handling applies.
|
|
165
|
+
* benign handling applies. The tee path remains the DEFAULT passthrough shape;
|
|
166
|
+
* the gated eager relay (relay-eager.ts, #314) does not tee and may not produce
|
|
167
|
+
* this shape — detection stays unchanged either way.
|
|
166
168
|
*/
|
|
167
169
|
export function isBenignAbortTeardown(err: unknown): boolean {
|
|
168
170
|
if (!(err instanceof TypeError)) return false;
|
package/src/lib/sse-decoder.ts
CHANGED
|
@@ -3,6 +3,10 @@ export interface ServerSentEvent {
|
|
|
3
3
|
data: string;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
export type SseRecord =
|
|
7
|
+
| { kind: "event"; event?: string; data: string }
|
|
8
|
+
| { kind: "comment"; comment: string };
|
|
9
|
+
|
|
6
10
|
/**
|
|
7
11
|
* Decode text/event-stream records across arbitrary fetch chunk boundaries.
|
|
8
12
|
*
|
|
@@ -10,10 +14,18 @@ export interface ServerSentEvent {
|
|
|
10
14
|
* final newline. That matters for compatible APIs that place a terminal event in the last bytes of
|
|
11
15
|
* the body: dropping that record turns a successful response into an adapter_eof failure.
|
|
12
16
|
*/
|
|
17
|
+
export function decodeServerSentEvents(
|
|
18
|
+
source: ReadableStream<Uint8Array>,
|
|
19
|
+
options: { includeComments: true; signal?: AbortSignal },
|
|
20
|
+
): AsyncGenerator<SseRecord>;
|
|
21
|
+
export function decodeServerSentEvents(
|
|
22
|
+
source: ReadableStream<Uint8Array>,
|
|
23
|
+
options?: { includeComments?: false; signal?: AbortSignal },
|
|
24
|
+
): AsyncGenerator<ServerSentEvent>;
|
|
13
25
|
export async function* decodeServerSentEvents(
|
|
14
26
|
source: ReadableStream<Uint8Array>,
|
|
15
|
-
options?: { signal?: AbortSignal },
|
|
16
|
-
): AsyncGenerator<ServerSentEvent> {
|
|
27
|
+
options?: { includeComments?: boolean; signal?: AbortSignal },
|
|
28
|
+
): AsyncGenerator<ServerSentEvent | SseRecord> {
|
|
17
29
|
const reader = source.getReader();
|
|
18
30
|
const decoder = new TextDecoder();
|
|
19
31
|
let buffer = "";
|
|
@@ -27,7 +39,9 @@ export async function* decodeServerSentEvents(
|
|
|
27
39
|
if (signal?.aborted) onAbort();
|
|
28
40
|
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
29
41
|
|
|
30
|
-
const
|
|
42
|
+
const includeComments = options?.includeComments === true;
|
|
43
|
+
|
|
44
|
+
const dispatch = (): ServerSentEvent | SseRecord | undefined => {
|
|
31
45
|
if (dataLines.length === 0) {
|
|
32
46
|
event = undefined;
|
|
33
47
|
return undefined;
|
|
@@ -35,13 +49,18 @@ export async function* decodeServerSentEvents(
|
|
|
35
49
|
const record = { ...(event ? { event } : {}), data: dataLines.join("\n") };
|
|
36
50
|
event = undefined;
|
|
37
51
|
dataLines = [];
|
|
38
|
-
return record;
|
|
52
|
+
return includeComments ? { kind: "event", ...record } : record;
|
|
39
53
|
};
|
|
40
54
|
|
|
41
|
-
const acceptLine = (rawLine: string): ServerSentEvent | undefined => {
|
|
55
|
+
const acceptLine = (rawLine: string): ServerSentEvent | SseRecord | undefined => {
|
|
42
56
|
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
43
57
|
if (line === "") return dispatch();
|
|
44
|
-
if (line.startsWith(":"))
|
|
58
|
+
if (line.startsWith(":")) {
|
|
59
|
+
if (!includeComments) return undefined;
|
|
60
|
+
let comment = line.slice(1);
|
|
61
|
+
if (comment.startsWith(" ")) comment = comment.slice(1);
|
|
62
|
+
return { kind: "comment", comment };
|
|
63
|
+
}
|
|
45
64
|
|
|
46
65
|
const colon = line.indexOf(":");
|
|
47
66
|
const field = colon < 0 ? line : line.slice(0, colon);
|
package/src/responses/parser.ts
CHANGED
|
@@ -129,7 +129,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
129
129
|
out.push({
|
|
130
130
|
name: t.name,
|
|
131
131
|
description: (t.description as string) ?? "",
|
|
132
|
-
parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input (
|
|
132
|
+
parameters: { type: "object", properties: { input: { type: "string", description: "Raw tool input. For apply_patch, begin exactly with `*** Begin Patch` (no trailing `***`), then use its standard patch envelope." } }, required: ["input"] },
|
|
133
133
|
freeform: true,
|
|
134
134
|
});
|
|
135
135
|
}
|
|
@@ -594,6 +594,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
594
594
|
stream: data.stream === true,
|
|
595
595
|
options,
|
|
596
596
|
_rawBody: body,
|
|
597
|
+
...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
|
|
597
598
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
598
599
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
|
599
600
|
...(compactionRequest ? { _compactionRequest: true } : {}),
|
package/src/responses/state.ts
CHANGED
|
@@ -241,9 +241,13 @@ export function previousResponseProviderState(responseId: string | undefined): O
|
|
|
241
241
|
return providers ? structuredClone(providers) : undefined;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Cache completed output and max_output_tokens partial output for previous_response_id replay.
|
|
246
|
+
* Content-filtered incomplete and failed output are not authoritative replay history.
|
|
247
|
+
*/
|
|
244
248
|
export function rememberResponseState(
|
|
245
249
|
requestBody: unknown,
|
|
246
|
-
response: { id?: unknown; output?: unknown; status?: unknown },
|
|
250
|
+
response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
|
|
247
251
|
providerState?: OcxProviderContinuationState | string,
|
|
248
252
|
opts?: { force?: boolean },
|
|
249
253
|
): void {
|
|
@@ -256,7 +260,11 @@ export function rememberResponseState(
|
|
|
256
260
|
// real server-side response storage.
|
|
257
261
|
if (request.store === false && !opts?.force) return;
|
|
258
262
|
if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
|
|
259
|
-
if (response.status
|
|
263
|
+
if (response.status === "incomplete") {
|
|
264
|
+
const details = response.incomplete_details;
|
|
265
|
+
if (!details || typeof details !== "object" || Array.isArray(details)
|
|
266
|
+
|| (details as { reason?: unknown }).reason !== "max_output_tokens") return;
|
|
267
|
+
} else if (response.status !== undefined && response.status !== "completed") return;
|
|
260
268
|
ensureLoaded();
|
|
261
269
|
const normalizedProviderState: OcxProviderContinuationState = typeof providerState === "string"
|
|
262
270
|
? { cursor: { conversationId: providerState } }
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -80,7 +80,10 @@ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, s
|
|
|
80
80
|
return {
|
|
81
81
|
"Access-Control-Allow-Origin": allowOrigin,
|
|
82
82
|
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
83
|
-
|
|
83
|
+
// ChatGPT-Account-Id is required for browser/Electron ChatGPT & Codex App voice preflights
|
|
84
|
+
// (direct forward auth matches the bearer to this account id). The OpenAI-Alpha .. X-OAI-Attestation
|
|
85
|
+
// block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
|
|
86
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
|
|
84
87
|
"Vary": "Origin",
|
|
85
88
|
};
|
|
86
89
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "../config";
|
|
19
19
|
import { reconcileOAuthProviders } from "../oauth";
|
|
20
20
|
import { invalidateCodexModelsCache } from "../codex/catalog";
|
|
21
|
+
import { startMemoryWatchdog } from "./memory-watchdog";
|
|
21
22
|
import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
|
|
22
23
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
23
24
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
@@ -121,11 +122,90 @@ import { handleChatCompletions } from "./chat-completions";
|
|
|
121
122
|
import { anthropicErrorResponse } from "../claude/outbound";
|
|
122
123
|
import { buildDesktop3pRegistry } from "../claude/desktop-3p";
|
|
123
124
|
import { handleImages } from "./images";
|
|
125
|
+
import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
|
|
124
126
|
import { handleSearch } from "./search";
|
|
125
127
|
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
|
|
126
128
|
|
|
127
129
|
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
128
130
|
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
131
|
+
const LIVE_SIDEBAND_PENDING_MAX = 32;
|
|
132
|
+
|
|
133
|
+
function closeLiveSideband(ws: ServerWebSocket<WsData>, code = 1000, reason = ""): void {
|
|
134
|
+
try {
|
|
135
|
+
ws.data.liveUpstream?.close(code, reason);
|
|
136
|
+
} catch {
|
|
137
|
+
/* upstream already gone */
|
|
138
|
+
}
|
|
139
|
+
ws.data.liveUpstream = undefined;
|
|
140
|
+
ws.data.livePending = undefined;
|
|
141
|
+
try {
|
|
142
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
143
|
+
ws.close(code, reason);
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
/* client already gone */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function attachLiveSidebandUpstream(ws: ServerWebSocket<WsData>): void {
|
|
151
|
+
const url = ws.data.liveUpstreamUrl;
|
|
152
|
+
if (!url) {
|
|
153
|
+
closeLiveSideband(ws, 1011, "missing upstream");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
let upstream: WebSocket;
|
|
157
|
+
try {
|
|
158
|
+
// Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
|
|
159
|
+
upstream = new WebSocket(url, { headers: ws.data.liveUpstreamHeaders ?? {} } as unknown as string[]);
|
|
160
|
+
} catch {
|
|
161
|
+
closeLiveSideband(ws, 1011, "upstream connect failed");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
ws.data.liveUpstream = upstream;
|
|
165
|
+
ws.data.cancel = () => {
|
|
166
|
+
try {
|
|
167
|
+
upstream.close(1000, "client closed");
|
|
168
|
+
} catch {
|
|
169
|
+
/* ignore */
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
upstream.addEventListener("open", () => {
|
|
174
|
+
ws.data.liveOpened = true;
|
|
175
|
+
const pending = ws.data.livePending ?? [];
|
|
176
|
+
ws.data.livePending = undefined;
|
|
177
|
+
for (const frame of pending) {
|
|
178
|
+
try {
|
|
179
|
+
upstream.send(frame);
|
|
180
|
+
} catch {
|
|
181
|
+
closeLiveSideband(ws, 1011, "upstream send failed");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
upstream.addEventListener("message", (event) => {
|
|
187
|
+
try {
|
|
188
|
+
if (typeof event.data === "string") ws.send(event.data);
|
|
189
|
+
else if (event.data instanceof ArrayBuffer) ws.send(event.data);
|
|
190
|
+
else if (ArrayBuffer.isView(event.data)) {
|
|
191
|
+
ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength));
|
|
192
|
+
} else ws.send(event.data as Buffer);
|
|
193
|
+
} catch {
|
|
194
|
+
closeLiveSideband(ws, 1011, "client send failed");
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
upstream.addEventListener("close", (event) => {
|
|
198
|
+
try {
|
|
199
|
+
ws.close(event.code || 1000, event.reason || "");
|
|
200
|
+
} catch {
|
|
201
|
+
/* ignore */
|
|
202
|
+
}
|
|
203
|
+
ws.data.liveUpstream = undefined;
|
|
204
|
+
});
|
|
205
|
+
upstream.addEventListener("error", () => {
|
|
206
|
+
closeLiveSideband(ws, 1011, "upstream error");
|
|
207
|
+
});
|
|
208
|
+
}
|
|
129
209
|
|
|
130
210
|
// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
|
|
131
211
|
// "../src/server" import surface stable for tests/callers.
|
|
@@ -134,8 +214,12 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
|
134
214
|
|
|
135
215
|
// Source invariant for tests/passthrough-abort.test.ts after the pure module split:
|
|
136
216
|
// if (isEventStream && upstreamResponse.body) {
|
|
137
|
-
// upstreamResponse.body.tee()
|
|
138
217
|
// const repairConfig = route.provider.responsesItemIdRepair;
|
|
218
|
+
// #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime):
|
|
219
|
+
// decideEagerRelay(config.streamMode ?? "auto")
|
|
220
|
+
// relaySseEagerBounded(upstreamResponse.body, turnAc,
|
|
221
|
+
// Default shape (tee + background inspection):
|
|
222
|
+
// upstreamResponse.body.tee()
|
|
139
223
|
// const repairedBody = hasResponsesItemIdRepair(repairConfig)
|
|
140
224
|
// process.platform === "win32"
|
|
141
225
|
// && !hasResponsesItemIdRepair(repairConfig)
|
|
@@ -184,6 +268,9 @@ export function startServer(port?: number) {
|
|
|
184
268
|
// usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
|
|
185
269
|
// /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
|
|
186
270
|
hydrateRequestLogsFromDisk();
|
|
271
|
+
// #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
|
|
272
|
+
// startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
|
|
273
|
+
startMemoryWatchdog();
|
|
187
274
|
|
|
188
275
|
const listenPort = port ?? config.port ?? 10100;
|
|
189
276
|
setCorsOrigin(listenPort);
|
|
@@ -490,6 +577,77 @@ export function startServer(port?: number) {
|
|
|
490
577
|
return withCors(response, req, config);
|
|
491
578
|
}
|
|
492
579
|
|
|
580
|
+
// ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
|
|
581
|
+
// Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
|
|
582
|
+
// public Realtime API). Sideband WS joins are handled just below.
|
|
583
|
+
if (
|
|
584
|
+
req.method === "POST"
|
|
585
|
+
&& (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls")
|
|
586
|
+
) {
|
|
587
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
588
|
+
if (isDraining()) {
|
|
589
|
+
return new Response("Service shutting down", {
|
|
590
|
+
status: 503,
|
|
591
|
+
headers: { ...corsHeaders(req, config), "Retry-After": "5" },
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
595
|
+
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
596
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
597
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
|
|
598
|
+
}
|
|
599
|
+
const start = Date.now();
|
|
600
|
+
const requestId = nextRequestLogId(start);
|
|
601
|
+
const logCtx: RequestLogContext = { model: "gpt-live", provider: "unknown" };
|
|
602
|
+
const response = await handleLive(req, config, logCtx);
|
|
603
|
+
addFinalRequestLog(
|
|
604
|
+
requestId,
|
|
605
|
+
start,
|
|
606
|
+
logCtx,
|
|
607
|
+
response.status,
|
|
608
|
+
response.status === 499 ? { closeReason: "client_cancel" } : undefined,
|
|
609
|
+
);
|
|
610
|
+
return withCors(response, req, config);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Voice / Realtime sideband WebSocket: Frameless joins /v1/live/{callId}; Realtime v1 joins
|
|
614
|
+
// /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Transparent bidirectional relay.
|
|
615
|
+
const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
616
|
+
? parseLiveSidebandTarget(url.pathname, url.searchParams)
|
|
617
|
+
: null;
|
|
618
|
+
if (liveSidebandTarget) {
|
|
619
|
+
if (isDraining()) {
|
|
620
|
+
return new Response("Service shutting down", {
|
|
621
|
+
status: 503,
|
|
622
|
+
headers: { ...corsHeaders(req, config), "Retry-After": "5" },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
626
|
+
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
627
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
628
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
|
|
629
|
+
}
|
|
630
|
+
const start = Date.now();
|
|
631
|
+
const requestId = nextRequestLogId(start);
|
|
632
|
+
const logCtx: RequestLogContext = { model: "gpt-live", provider: "unknown" };
|
|
633
|
+
const resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget);
|
|
634
|
+
if (resolved instanceof Response) {
|
|
635
|
+
addFinalRequestLog(requestId, start, logCtx, resolved.status);
|
|
636
|
+
return withCors(resolved, req, config);
|
|
637
|
+
}
|
|
638
|
+
addFinalRequestLog(requestId, start, logCtx, 101);
|
|
639
|
+
if (server.upgrade(req, {
|
|
640
|
+
data: {
|
|
641
|
+
kind: "live-sideband",
|
|
642
|
+
liveUpstreamUrl: resolved.upstreamWsUrl,
|
|
643
|
+
liveUpstreamHeaders: resolved.headers,
|
|
644
|
+
livePending: [],
|
|
645
|
+
liveOpened: false,
|
|
646
|
+
} satisfies WsData,
|
|
647
|
+
})) return undefined as unknown as Response;
|
|
648
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
|
|
649
|
+
}
|
|
650
|
+
|
|
493
651
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
494
652
|
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
495
653
|
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
@@ -511,10 +669,37 @@ export function startServer(port?: number) {
|
|
|
511
669
|
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
|
|
512
670
|
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
|
|
513
671
|
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
|
|
672
|
+
// Live sideband sockets (kind=live-sideband) are a transparent bidirectional relay instead.
|
|
514
673
|
open(ws: ServerWebSocket<WsData>) {
|
|
674
|
+
if (ws.data.kind === "live-sideband") {
|
|
675
|
+
attachLiveSidebandUpstream(ws);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
515
678
|
registerCodexWebSocket(ws);
|
|
516
679
|
},
|
|
517
680
|
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
681
|
+
if (ws.data.kind === "live-sideband") {
|
|
682
|
+
const upstream = ws.data.liveUpstream;
|
|
683
|
+
if (!upstream || upstream.readyState === WebSocket.CONNECTING || !ws.data.liveOpened) {
|
|
684
|
+
const pending = ws.data.livePending ?? (ws.data.livePending = []);
|
|
685
|
+
if (pending.length >= LIVE_SIDEBAND_PENDING_MAX) {
|
|
686
|
+
closeLiveSideband(ws, 1009, "too many pending frames");
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
pending.push(raw);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
693
|
+
closeLiveSideband(ws, 1011, "upstream not open");
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
try {
|
|
697
|
+
upstream.send(raw);
|
|
698
|
+
} catch {
|
|
699
|
+
closeLiveSideband(ws, 1011, "upstream send failed");
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
518
703
|
const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
|
|
519
704
|
if (rawBytes > MAX_WS_FRAME_BYTES) {
|
|
520
705
|
sendJsonFrame(ws, buildWsErrorFrame(413, {
|
|
@@ -631,6 +816,11 @@ export function startServer(port?: number) {
|
|
|
631
816
|
})();
|
|
632
817
|
},
|
|
633
818
|
close(ws: ServerWebSocket<WsData>) {
|
|
819
|
+
if (ws.data.kind === "live-sideband") {
|
|
820
|
+
ws.data.cancel?.();
|
|
821
|
+
ws.data.liveUpstream = undefined;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
634
824
|
unregisterCodexWebSocket(ws);
|
|
635
825
|
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
636
826
|
},
|