@bitkyc08/opencodex 2.7.38-preview.20260724 → 2.7.39

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.
Files changed (46) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-B-cheu55.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/index.ts +6 -2
  27. package/src/codex/auth-context.ts +18 -2
  28. package/src/codex/catalog/provider-fetch.ts +31 -8
  29. package/src/codex/model-cache.ts +44 -0
  30. package/src/codex/runtime.ts +17 -1
  31. package/src/codex/shim.ts +608 -10
  32. package/src/combos/resolve.ts +7 -2
  33. package/src/config.ts +11 -0
  34. package/src/lib/sse-decoder.ts +25 -6
  35. package/src/responses/parser.ts +1 -1
  36. package/src/responses/state.ts +10 -2
  37. package/src/server/auth-cors.ts +4 -1
  38. package/src/server/index.ts +182 -0
  39. package/src/server/live.ts +491 -0
  40. package/src/server/management/provider-routes.ts +2 -0
  41. package/src/server/responses/core.ts +184 -20
  42. package/src/server/responses/encrypted-payload.ts +118 -41
  43. package/src/server/ws-bridge.ts +7 -0
  44. package/src/types.ts +14 -0
  45. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  46. package/gui/dist/assets/index-CprFnVjr.js +0 -52
@@ -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: { retryAfter?: string | null; now?: number } = {},
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,7 @@ 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(),
445
446
  // Invalid values degrade to undefined ("auto") instead of failing the whole
446
447
  // parse: a hand-edited typo must never trip the backup-and-defaults repair
447
448
  // path below and wipe providers/pool accounts. Warning emitted in loadConfig.
@@ -793,6 +794,15 @@ export function codexAutoStartEnabled(config: Pick<OcxConfig, "codexAutoStart">)
793
794
  return config.codexAutoStart !== false;
794
795
  }
795
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
+
796
806
  export function multiAgentGuidanceEnabled(
797
807
  config: Pick<OcxConfig, "multiAgentGuidanceEnabled">,
798
808
  ): boolean {
@@ -822,6 +832,7 @@ export function getDefaultConfig(): OcxConfig {
822
832
  multiAgentGuidanceEnabled: true,
823
833
  websockets: false,
824
834
  codexAutoStart: true,
835
+ codexShimAutoRestore: true,
825
836
  };
826
837
  }
827
838
 
@@ -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 dispatch = (): ServerSentEvent | undefined => {
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(":")) return undefined;
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);
@@ -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 (verbatim body, e.g. the apply_patch envelope)." } }, required: ["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
  }
@@ -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 !== undefined && response.status !== "completed") return;
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 } }
@@ -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
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta",
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
  }
@@ -122,11 +122,90 @@ import { handleChatCompletions } from "./chat-completions";
122
122
  import { anthropicErrorResponse } from "../claude/outbound";
123
123
  import { buildDesktop3pRegistry } from "../claude/desktop-3p";
124
124
  import { handleImages } from "./images";
125
+ import { handleLive, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live";
125
126
  import { handleSearch } from "./search";
126
127
  import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
127
128
 
128
129
  const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
129
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
+ }
130
209
 
131
210
  // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
132
211
  // "../src/server" import surface stable for tests/callers.
@@ -498,6 +577,77 @@ export function startServer(port?: number) {
498
577
  return withCors(response, req, config);
499
578
  }
500
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
+
501
651
  // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
502
652
  // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
503
653
  // endpoint clients — memories/*, realtime/* — would surface confusing
@@ -519,10 +669,37 @@ export function startServer(port?: number) {
519
669
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
520
670
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
521
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.
522
673
  open(ws: ServerWebSocket<WsData>) {
674
+ if (ws.data.kind === "live-sideband") {
675
+ attachLiveSidebandUpstream(ws);
676
+ return;
677
+ }
523
678
  registerCodexWebSocket(ws);
524
679
  },
525
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
+ }
526
703
  const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
527
704
  if (rawBytes > MAX_WS_FRAME_BYTES) {
528
705
  sendJsonFrame(ws, buildWsErrorFrame(413, {
@@ -639,6 +816,11 @@ export function startServer(port?: number) {
639
816
  })();
640
817
  },
641
818
  close(ws: ServerWebSocket<WsData>) {
819
+ if (ws.data.kind === "live-sideband") {
820
+ ws.data.cancel?.();
821
+ ws.data.liveUpstream = undefined;
822
+ return;
823
+ }
642
824
  unregisterCodexWebSocket(ws);
643
825
  ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
644
826
  },