@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.
Files changed (66) 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-BhUTxmCy.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/doctor.ts +197 -2
  27. package/src/cli/index.ts +17 -3
  28. package/src/cli/status.ts +80 -0
  29. package/src/cli/v2.ts +14 -2
  30. package/src/codex/auth-context.ts +18 -2
  31. package/src/codex/catalog/bundled.ts +83 -27
  32. package/src/codex/catalog/effort.ts +95 -3
  33. package/src/codex/catalog/parsing.ts +17 -0
  34. package/src/codex/catalog/provider-fetch.ts +31 -8
  35. package/src/codex/exec-invocation.ts +22 -0
  36. package/src/codex/model-cache.ts +44 -0
  37. package/src/codex/runtime.ts +529 -0
  38. package/src/codex/shim.ts +608 -10
  39. package/src/combos/resolve.ts +7 -2
  40. package/src/config.ts +32 -1
  41. package/src/lib/bun-stream-caps.ts +88 -0
  42. package/src/lib/crash-guard.ts +3 -1
  43. package/src/lib/sse-decoder.ts +25 -6
  44. package/src/responses/parser.ts +2 -1
  45. package/src/responses/state.ts +10 -2
  46. package/src/server/auth-cors.ts +4 -1
  47. package/src/server/index.ts +191 -1
  48. package/src/server/live.ts +491 -0
  49. package/src/server/management/config-routes.ts +79 -3
  50. package/src/server/management/provider-routes.ts +2 -0
  51. package/src/server/management/shared.ts +6 -6
  52. package/src/server/management/system-routes.ts +65 -0
  53. package/src/server/management-api.ts +3 -1
  54. package/src/server/memory-watchdog.ts +112 -0
  55. package/src/server/relay-eager.ts +199 -0
  56. package/src/server/relay.ts +131 -81
  57. package/src/server/responses/collaboration.ts +20 -3
  58. package/src/server/responses/core.ts +236 -21
  59. package/src/server/responses/encrypted-payload.ts +118 -41
  60. package/src/server/ws-bridge.ts +7 -0
  61. package/src/types.ts +25 -0
  62. package/src/usage/cost.ts +0 -0
  63. package/src/usage/expected-prices.ts +19 -0
  64. package/src/usage/summary.ts +11 -8
  65. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  66. package/gui/dist/assets/index-ZmFopEYw.js +0 -52
@@ -0,0 +1,491 @@
1
+ /**
2
+ * /v1/live and /v1/realtime/calls relay (issue #371).
3
+ *
4
+ * Codex App / ChatGPT voice (GPT‑Live / Frameless Bidi) POSTs call-create against the injected
5
+ * `base_url`, then opens a sideband WebSocket at `/v1/live/{callId}` (Frameless) or
6
+ * `/v1/realtime?call_id=` (Realtime v1). Under Design B that host is this proxy.
7
+ *
8
+ * Inbound HTTP:
9
+ * - `POST /v1/live` — Frameless / ChatGPT App shape against an injected `/v1` base
10
+ * - `POST /v1/realtime/calls` — openai/codex RealtimeCallClient and the public OpenAI Realtime API
11
+ *
12
+ * Upstream HTTP (matches openai/codex `RealtimeCallClient`):
13
+ * - ChatGPT `backend-api` → JSON `{ sdp, session? }` at
14
+ * `{base}/realtime/calls?intent=quicksilver&architecture=avas`
15
+ * - OpenAI API-key provider → multipart at
16
+ * `{base}/v1/realtime/calls?intent=quicksilver&architecture=avas`
17
+ *
18
+ * Inbound sideband WebSocket (transparent bidirectional relay):
19
+ * - `GET /v1/live/{callId}` — Frameless
20
+ * - `GET /v1/realtime/calls/{callId}` — path-form join
21
+ * - `GET /v1/realtime?call_id=` — Realtime v1/v2 join
22
+ */
23
+ import { formatErrorResponse } from "../bridge";
24
+ import {
25
+ CodexAccountCooldownError,
26
+ CodexAuthContextError,
27
+ CodexPoolAuthenticationError,
28
+ CodexThreadAffinityExpiredError,
29
+ } from "../codex/auth-context";
30
+ import { formatCodexProviderForLog } from "../codex/routing";
31
+ import { signalWithTimeout } from "../lib/abort";
32
+ import { sidecarEnter } from "../lib/sidecar-tracker";
33
+ import type { OcxConfig } from "../types";
34
+ import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
35
+ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
36
+ import type { RequestLogContext } from "./request-log";
37
+ import { codexLogAccountId } from "./responses";
38
+
39
+ /** Voice call create can wait on SDP negotiation; bound a hung upstream. */
40
+ const LIVE_UPSTREAM_TIMEOUT_MS = 120_000;
41
+ export const LIVE_REQUEST_MAX_BYTES = 16 * 1024 * 1024;
42
+ export const LIVE_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
43
+ const LIVE_RELAY_HEADERS = ["content-type", "location"] as const;
44
+
45
+ /** AVAS WebRTC call-create query (openai/codex `configure_realtime_call_request`). */
46
+ export const LIVE_AVAS_QUERY = "intent=quicksilver&architecture=avas";
47
+
48
+ /**
49
+ * Sideband WebSocket API root. openai/codex joins the sideband via the API provider default
50
+ * (`to_api_provider(AuthMode::ApiKey)` → https://api.openai.com/v1) even for ChatGPT-auth calls
51
+ * created through backend-api; chatgpt.com/backend-api rejects sideband upgrades pre-101
52
+ * (verified live 2026-07-24). The call-create bearer works on the API host unchanged.
53
+ */
54
+ export const LIVE_SIDEBAND_API_ROOT = "https://api.openai.com/v1";
55
+
56
+ /**
57
+ * Client protocol headers relayed verbatim to the upstream on call-create and sideband upgrade.
58
+ * `openai-alpha: quicksilver=v2` carries the Frameless protocol negotiation — without it the
59
+ * ChatGPT backend validates the type-less Frameless session as v1 quicksilver and 400s
60
+ * (openai/codex `realtime_request_headers`, core/src/realtime_conversation.rs). Auth headers
61
+ * (`authorization`, `chatgpt-account-id`) stay proxy-owned and are never taken from this list.
62
+ */
63
+ export const LIVE_CLIENT_PROTOCOL_HEADERS = [
64
+ "openai-alpha",
65
+ "x-session-id",
66
+ "session-id",
67
+ "thread-id",
68
+ "originator",
69
+ "x-oai-attestation",
70
+ ] as const;
71
+
72
+ function clientProtocolHeaders(reqHeaders: Headers): Record<string, string> {
73
+ const out: Record<string, string> = {};
74
+ for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) {
75
+ const value = reqHeaders.get(name);
76
+ if (value != null && value !== "") out[name] = value;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ const LIVE_CALL_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
82
+
83
+ export type LiveSidebandTarget =
84
+ | { style: "frameless-path"; callId: string }
85
+ | { style: "realtime-calls-path"; callId: string }
86
+ | { style: "realtime-query"; callId: string };
87
+
88
+ export type LiveRelayTarget = {
89
+ headers: Record<string, string>;
90
+ providerBaseUrl: string;
91
+ usesBackendShape: boolean;
92
+ keyed: boolean;
93
+ recordOutcome?: (status: number | "timeout" | "connect_error") => void;
94
+ };
95
+
96
+ function isChatGptBackendBaseUrl(baseUrl: string): boolean {
97
+ return baseUrl.includes("/backend-api");
98
+ }
99
+
100
+ function withAvasQuery(url: string): string {
101
+ if (/[?&]intent=/.test(url) && /[?&]architecture=/.test(url)) return url;
102
+ return url.includes("?") ? `${url}&${LIVE_AVAS_QUERY}` : `${url}?${LIVE_AVAS_QUERY}`;
103
+ }
104
+
105
+ export function keyedLiveUrl(baseUrl: string): string {
106
+ return withAvasQuery(`${baseUrl.replace(/\/v1\/?$/, "")}/v1/realtime/calls`);
107
+ }
108
+
109
+ export function forwardLiveUrl(baseUrl: string, usesBackendShape: boolean): string {
110
+ const root = baseUrl.replace(/\/$/, "");
111
+ if (usesBackendShape) return withAvasQuery(`${root}/realtime/calls`);
112
+ // Frameless API shape posts to /live without the AVAS query (codex RealtimeCallClient).
113
+ return `${root}/live`;
114
+ }
115
+
116
+ function httpsToWss(httpUrl: string): string {
117
+ if (httpUrl.startsWith("https://")) return `wss://${httpUrl.slice("https://".length)}`;
118
+ if (httpUrl.startsWith("http://")) return `ws://${httpUrl.slice("http://".length)}`;
119
+ return httpUrl;
120
+ }
121
+
122
+ export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearchParams): LiveSidebandTarget | null {
123
+ const liveMatch = pathname.match(/^\/v1\/live\/([^/]+)\/?$/);
124
+ if (liveMatch) {
125
+ const callId = decodeURIComponent(liveMatch[1]!);
126
+ if (!LIVE_CALL_ID_RE.test(callId)) return null;
127
+ return { style: "frameless-path", callId };
128
+ }
129
+ const callsMatch = pathname.match(/^\/v1\/realtime\/calls\/([^/]+)\/?$/);
130
+ if (callsMatch) {
131
+ const callId = decodeURIComponent(callsMatch[1]!);
132
+ if (!LIVE_CALL_ID_RE.test(callId)) return null;
133
+ return { style: "realtime-calls-path", callId };
134
+ }
135
+ if (pathname === "/v1/realtime" || pathname === "/v1/realtime/") {
136
+ const callId = searchParams.get("call_id")?.trim() ?? "";
137
+ if (!LIVE_CALL_ID_RE.test(callId)) return null;
138
+ return { style: "realtime-query", callId };
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * Build the upstream sideband WebSocket URL for a resolved OpenAI/ChatGPT provider.
145
+ * Mirrors openai/codex `websocket_url_from_api_url_for_call` + `normalize_realtime_path`.
146
+ */
147
+ export function buildLiveSidebandUpstreamWsUrl(
148
+ providerBaseUrl: string,
149
+ usesBackendShape: boolean,
150
+ target: LiveSidebandTarget,
151
+ ): string {
152
+ const root = providerBaseUrl.replace(/\/$/, "");
153
+ if (usesBackendShape) {
154
+ // ChatGPT backend-api call-create, but the sideband join lives on the public API host
155
+ // (matches openai/codex, which builds the sideband from the ApiKey provider default).
156
+ if (target.style === "frameless-path") {
157
+ return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/live/${target.callId}`);
158
+ }
159
+ if (target.style === "realtime-calls-path") {
160
+ return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/realtime/calls/${target.callId}`);
161
+ }
162
+ return httpsToWss(
163
+ `${LIVE_SIDEBAND_API_ROOT}/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
164
+ );
165
+ }
166
+ if (target.style === "frameless-path") {
167
+ // Frameless: normalize to .../live then append /{callId}.
168
+ const apiRoot = root.replace(/\/v1\/?$/, "");
169
+ return httpsToWss(`${apiRoot}/v1/live/${target.callId}`);
170
+ }
171
+ if (target.style === "realtime-calls-path") {
172
+ const apiRoot = root.replace(/\/v1\/?$/, "");
173
+ return httpsToWss(`${apiRoot}/v1/realtime/calls/${target.callId}`);
174
+ }
175
+ // Realtime v1/v2: /v1/realtime?intent=quicksilver&call_id=
176
+ const apiRoot = root.replace(/\/v1\/?$/, "");
177
+ return httpsToWss(
178
+ `${apiRoot}/v1/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
179
+ );
180
+ }
181
+
182
+ async function backendJsonBodyFromApiMultipart(
183
+ body: ArrayBuffer,
184
+ contentType: string,
185
+ ): Promise<{ body: Uint8Array; contentType: string } | Response> {
186
+ let form: FormData;
187
+ try {
188
+ form = await new Response(body, { headers: { "content-type": contentType } }).formData();
189
+ } catch {
190
+ return formatErrorResponse(
191
+ 400,
192
+ "invalid_request_error",
193
+ "ChatGPT voice relay could not parse multipart call-create body",
194
+ );
195
+ }
196
+ const sdp = form.get("sdp");
197
+ if (typeof sdp !== "string") {
198
+ return formatErrorResponse(
199
+ 400,
200
+ "invalid_request_error",
201
+ "ChatGPT voice relay expects multipart field sdp on call-create",
202
+ );
203
+ }
204
+ // `session` is optional on the public Realtime calls API; omit when the client sends SDP only.
205
+ const sessionRaw = form.get("session");
206
+ let session: unknown | undefined;
207
+ if (sessionRaw != null) {
208
+ if (typeof sessionRaw !== "string") {
209
+ return formatErrorResponse(
210
+ 400,
211
+ "invalid_request_error",
212
+ "ChatGPT voice relay expected a string multipart session field",
213
+ );
214
+ }
215
+ try {
216
+ session = JSON.parse(sessionRaw);
217
+ } catch {
218
+ return formatErrorResponse(
219
+ 400,
220
+ "invalid_request_error",
221
+ "ChatGPT voice relay expected JSON in the multipart session field",
222
+ );
223
+ }
224
+ }
225
+ const payload = session === undefined ? { sdp } : { sdp, session };
226
+ const encoded = new TextEncoder().encode(JSON.stringify(payload));
227
+ return { body: encoded, contentType: "application/json" };
228
+ }
229
+
230
+ /** Read a body stream with a hard byte cap so oversized payloads abort before full buffering. */
231
+ export async function readBodyCapped(
232
+ stream: ReadableStream<Uint8Array> | null,
233
+ maxBytes: number,
234
+ tooLargeMessage: (total: number) => string,
235
+ ): Promise<ArrayBuffer | Response> {
236
+ if (!stream) return new ArrayBuffer(0);
237
+ const reader = stream.getReader();
238
+ const chunks: Uint8Array[] = [];
239
+ let total = 0;
240
+ try {
241
+ for (;;) {
242
+ const { done, value } = await reader.read();
243
+ if (done) break;
244
+ if (!value || value.byteLength === 0) continue;
245
+ total += value.byteLength;
246
+ if (total > maxBytes) {
247
+ await reader.cancel().catch(() => {});
248
+ return formatErrorResponse(502, "upstream_error", tooLargeMessage(total));
249
+ }
250
+ chunks.push(value);
251
+ }
252
+ } finally {
253
+ try {
254
+ reader.releaseLock();
255
+ } catch {
256
+ // already released / cancelled
257
+ }
258
+ }
259
+ if (chunks.length === 0) return new ArrayBuffer(0);
260
+ if (chunks.length === 1) {
261
+ const only = chunks[0]!;
262
+ return only.buffer.slice(only.byteOffset, only.byteOffset + only.byteLength) as ArrayBuffer;
263
+ }
264
+ const merged = new Uint8Array(total);
265
+ let offset = 0;
266
+ for (const chunk of chunks) {
267
+ merged.set(chunk, offset);
268
+ offset += chunk.byteLength;
269
+ }
270
+ return merged.buffer;
271
+ }
272
+
273
+ async function readRequestBodyCapped(req: Request, maxBytes: number): Promise<ArrayBuffer | Response> {
274
+ try {
275
+ const result = await readBodyCapped(
276
+ req.body,
277
+ maxBytes,
278
+ total => `live request body too large (${total} bytes)`,
279
+ );
280
+ if (result instanceof Response) {
281
+ // Oversize inbound is a client error, not an upstream failure.
282
+ return formatErrorResponse(413, "invalid_request_error", `live request body too large`);
283
+ }
284
+ return result;
285
+ } catch (err) {
286
+ if (req.signal.aborted) {
287
+ return formatErrorResponse(499, "client_closed_request", "live request canceled by client");
288
+ }
289
+ return formatErrorResponse(
290
+ 400,
291
+ "invalid_request_error",
292
+ `live request body unreadable: ${err instanceof Error ? err.message : String(err)}`,
293
+ );
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Resolve OpenAI/ChatGPT auth + headers for live HTTP or sideband WebSocket relays.
299
+ * Shared by call-create and sideband so pool token override stays consistent.
300
+ */
301
+ export async function resolveLiveRelay(
302
+ req: Request,
303
+ config: OcxConfig,
304
+ logCtx: RequestLogContext,
305
+ ): Promise<LiveRelayTarget | Response> {
306
+ try {
307
+ validateForwardAdmissionCredential(req.headers, config);
308
+ } catch (err) {
309
+ if (err instanceof ForwardAdmissionCredentialError) {
310
+ return formatErrorResponse(401, "authentication_error", err.message);
311
+ }
312
+ throw err;
313
+ }
314
+
315
+ const candidates = selectOpenAiImagesProvider(config);
316
+ if (candidates.forwardCandidates.length === 0 && !candidates.keyed) {
317
+ return formatErrorResponse(
318
+ 400,
319
+ "invalid_request_error",
320
+ "Built-in ChatGPT voice needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), "
321
+ + "but none is configured in opencodex. Routed providers cannot serve voice call-create.",
322
+ );
323
+ }
324
+
325
+ let forward: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>> | undefined;
326
+ let forwardAuthError: Response | undefined;
327
+ if (candidates.forwardCandidates.length > 0) {
328
+ try {
329
+ forward = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, req.headers, config);
330
+ if (forward) {
331
+ logCtx.provider = formatCodexProviderForLog(
332
+ forward.providerName,
333
+ codexLogAccountId(forward.authContext),
334
+ config,
335
+ );
336
+ }
337
+ } catch (err) {
338
+ if (err instanceof CodexAccountCooldownError) {
339
+ forwardAuthError = formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
340
+ } else if (err instanceof CodexThreadAffinityExpiredError) {
341
+ forwardAuthError = formatErrorResponse(
342
+ 409,
343
+ "invalid_request_error",
344
+ "Codex thread account affinity expired; start a new session",
345
+ );
346
+ } else if (err instanceof CodexAuthContextError) {
347
+ const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config);
348
+ console.error(`[live] Pool account ${safeAccountLabel} token failed; reauthentication required`);
349
+ forwardAuthError = formatErrorResponse(
350
+ 401,
351
+ "authentication_error",
352
+ "Selected Codex account needs reauthentication",
353
+ );
354
+ } else if (err instanceof CodexPoolAuthenticationError) {
355
+ forwardAuthError = formatErrorResponse(401, "authentication_error", err.message);
356
+ } else {
357
+ throw err;
358
+ }
359
+ }
360
+ }
361
+
362
+ // Client protocol headers first so provider/auth headers below always win on conflict.
363
+ const headers: Record<string, string> = clientProtocolHeaders(req.headers);
364
+ if (forward) {
365
+ const { provider } = forward;
366
+ if (provider.headers) Object.assign(headers, provider.headers);
367
+ for (const [name, value] of forward.headers) headers[name] = value;
368
+ logCtx.model = "gpt-live";
369
+ return {
370
+ headers,
371
+ providerBaseUrl: provider.baseUrl,
372
+ usesBackendShape: isChatGptBackendBaseUrl(provider.baseUrl),
373
+ keyed: false,
374
+ recordOutcome: status => forward.recordOutcome?.(status),
375
+ };
376
+ }
377
+ if (forwardAuthError) return forwardAuthError;
378
+ if (candidates.keyed) {
379
+ const { provider, apiKey, providerName } = candidates.keyed;
380
+ if (provider.headers) Object.assign(headers, provider.headers);
381
+ headers.authorization = `Bearer ${apiKey}`;
382
+ logCtx.provider = providerName;
383
+ logCtx.model = "gpt-live";
384
+ return {
385
+ headers,
386
+ providerBaseUrl: provider.baseUrl,
387
+ usesBackendShape: false,
388
+ keyed: true,
389
+ };
390
+ }
391
+ return formatErrorResponse(
392
+ 401,
393
+ "authentication_error",
394
+ "voice relay needs ChatGPT auth (Authorization header) or an OpenAI API-key provider",
395
+ );
396
+ }
397
+
398
+ export async function handleLive(
399
+ req: Request,
400
+ config: OcxConfig,
401
+ logCtx: RequestLogContext,
402
+ ): Promise<Response> {
403
+ const inboundContentType = req.headers.get("content-type") ?? "application/octet-stream";
404
+ const inboundBodyOrError = await readRequestBodyCapped(req, LIVE_REQUEST_MAX_BYTES);
405
+ if (inboundBodyOrError instanceof Response) return inboundBodyOrError;
406
+ const inboundBody = inboundBodyOrError;
407
+
408
+ const relay = await resolveLiveRelay(req, config, logCtx);
409
+ if (relay instanceof Response) return relay;
410
+
411
+ const headers: Record<string, string> = { ...relay.headers };
412
+ let url: string;
413
+ let outboundBody: ArrayBuffer = inboundBody;
414
+ let outboundContentType = inboundContentType;
415
+
416
+ if (!relay.keyed) {
417
+ url = forwardLiveUrl(relay.providerBaseUrl, relay.usesBackendShape);
418
+ if (relay.usesBackendShape && inboundContentType.toLowerCase().includes("multipart/form-data")) {
419
+ const rewritten = await backendJsonBodyFromApiMultipart(inboundBody, inboundContentType);
420
+ if (rewritten instanceof Response) return rewritten;
421
+ outboundBody = rewritten.body.buffer.slice(
422
+ rewritten.body.byteOffset,
423
+ rewritten.body.byteOffset + rewritten.body.byteLength,
424
+ ) as ArrayBuffer;
425
+ outboundContentType = rewritten.contentType;
426
+ }
427
+ } else {
428
+ url = keyedLiveUrl(relay.providerBaseUrl);
429
+ }
430
+
431
+ headers["content-type"] = outboundContentType;
432
+
433
+ const linkedSignal = signalWithTimeout(LIVE_UPSTREAM_TIMEOUT_MS, req.signal);
434
+ const sidecarExit = sidecarEnter("live");
435
+ try {
436
+ const upstreamResponse = await fetch(url, {
437
+ method: "POST",
438
+ headers,
439
+ body: outboundBody,
440
+ signal: linkedSignal.signal,
441
+ });
442
+ // Record every completed upstream response before body size handling so account health /
443
+ // cooldown still updates when we reject an oversized payload.
444
+ relay.recordOutcome?.(upstreamResponse.status);
445
+ const payload = await readBodyCapped(
446
+ upstreamResponse.body,
447
+ LIVE_RESPONSE_MAX_BYTES,
448
+ total => `live response too large (${total} bytes)`,
449
+ );
450
+ if (payload instanceof Response) return payload;
451
+ const relayHeaders: Record<string, string> = {};
452
+ for (const name of LIVE_RELAY_HEADERS) {
453
+ const value = upstreamResponse.headers.get(name);
454
+ if (value) relayHeaders[name] = value;
455
+ }
456
+ return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
457
+ } catch (err) {
458
+ if (req.signal.aborted) {
459
+ return formatErrorResponse(499, "client_closed_request", "live request canceled by client");
460
+ }
461
+ if (err instanceof Error && err.name === "TimeoutError") {
462
+ relay.recordOutcome?.("timeout");
463
+ return formatErrorResponse(504, "upstream_error", "live upstream timed out");
464
+ }
465
+ relay.recordOutcome?.("connect_error");
466
+ return formatErrorResponse(
467
+ 502,
468
+ "upstream_error",
469
+ `live relay failed: ${err instanceof Error ? err.message : String(err)}`,
470
+ );
471
+ } finally {
472
+ sidecarExit();
473
+ linkedSignal.cleanup();
474
+ }
475
+ }
476
+
477
+ /** Resolve sideband upstream WebSocket URL + headers for an accepted upgrade. */
478
+ export async function resolveLiveSidebandUpgrade(
479
+ req: Request,
480
+ config: OcxConfig,
481
+ logCtx: RequestLogContext,
482
+ target: LiveSidebandTarget,
483
+ ): Promise<{ headers: Record<string, string>; upstreamWsUrl: string; recordOutcome?: LiveRelayTarget["recordOutcome"] } | Response> {
484
+ const relay = await resolveLiveRelay(req, config, logCtx);
485
+ if (relay instanceof Response) return relay;
486
+ return {
487
+ headers: relay.headers,
488
+ upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(relay.providerBaseUrl, relay.usesBackendShape, target),
489
+ recordOutcome: relay.recordOutcome,
490
+ };
491
+ }
@@ -23,6 +23,7 @@ import {
23
23
  } from "../../oauth";
24
24
  import { removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
+ import { isStreamMode } from "../../lib/bun-stream-caps";
26
27
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
28
  import { deriveProviderPresets } from "../../providers/derive";
28
29
  import { providerCodexAccountMode } from "../../providers/registry";
@@ -58,6 +59,7 @@ import { applySystemEnvToggle } from "../system-env";
58
59
  import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
59
60
  import { runWindowsTrayAction } from "../windows-tray-control";
60
61
  import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control";
62
+ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
61
63
 
62
64
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
63
65
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
@@ -74,11 +76,63 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
74
76
  }
75
77
 
76
78
  if (url.pathname === "/api/settings" && req.method === "GET") {
79
+ let resolved: ReturnType<typeof resolveCodexRuntime>;
80
+ try {
81
+ // Full alternative discovery (memoized) so newerAvailable warnings work.
82
+ resolved = resolveCodexRuntime();
83
+ } catch {
84
+ resolved = {
85
+ runtime: { command: "codex", version: null, source: "fallback" },
86
+ failures: [],
87
+ };
88
+ }
89
+ const lastClamp = loadLastEffortClamp();
90
+ const clampActive = effortClampAppliesToRuntime(lastClamp, resolved.runtime);
91
+ const warningParts: string[] = [];
92
+ if (resolved.replacedConfigured) {
93
+ warningParts.push(
94
+ `Preferred Codex runtime is unavailable; using ${displayCodexRuntimePath(resolved.runtime.command)} instead.`,
95
+ );
96
+ } else if (
97
+ resolved.runtime.source === "fallback"
98
+ && resolved.failures.length > 0
99
+ && !resolved.runtime.version
100
+ ) {
101
+ warningParts.push("No validated Codex runtime found; falling back to `codex`.");
102
+ }
103
+ if (clampActive) {
104
+ const clampVersion = lastClamp?.runtimeVersion ?? resolved.runtime.version ?? "an older binary";
105
+ warningParts.push(
106
+ `Some reasoning effort options were hidden because OpenCodex used Codex ${clampVersion}.${resolved.newerAvailable ? " A newer Codex installation is available." : ""}`,
107
+ );
108
+ } else if (resolved.newerAvailable) {
109
+ warningParts.push(
110
+ `OpenCodex is using an older Codex binary (${resolved.runtime.version ?? "unknown"}). A newer Codex installation is available.`,
111
+ );
112
+ }
77
113
  return jsonResponse({
78
114
  codexAutoStart: codexAutoStartEnabled(config),
79
115
  port: config.port,
80
116
  hostname: config.hostname ?? "127.0.0.1",
117
+ streamMode: config.streamMode ?? "auto",
81
118
  startupHealth: await getCachedStartupHealth(config),
119
+ codexRuntime: {
120
+ path: displayCodexRuntimePath(resolved.runtime.command),
121
+ version: resolved.runtime.version,
122
+ source: resolved.runtime.source,
123
+ newerAvailable: resolved.newerAvailable
124
+ ? {
125
+ path: displayCodexRuntimePath(resolved.newerAvailable.command),
126
+ version: resolved.newerAvailable.version,
127
+ }
128
+ : null,
129
+ catalogClamp: {
130
+ active: clampActive,
131
+ removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [],
132
+ runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
133
+ },
134
+ warning: warningParts.length > 0 ? warningParts.join(" ") : null,
135
+ },
82
136
  });
83
137
  }
84
138
 
@@ -127,17 +181,39 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
127
181
  }
128
182
 
129
183
  if (url.pathname === "/api/settings" && req.method === "PUT") {
130
- let body: { codexAutoStart?: unknown };
184
+ // Each field is optional but at least one must be present; fields are
185
+ // validated when present. streamMode-only PUTs must work: Windows-memory
186
+ // troubleshooting docs tell service users to set it here (a service does
187
+ // not inherit shell env, so config.json is its only input). A stream-shape
188
+ // change applies to NEW turns only — the config object is shared by
189
+ // reference with the request handlers, no restart needed.
190
+ let body: { codexAutoStart?: unknown; streamMode?: unknown };
131
191
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
132
- if (typeof body.codexAutoStart !== "boolean") {
192
+ if (body.codexAutoStart === undefined && body.streamMode === undefined) {
133
193
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
134
194
  }
135
- config.codexAutoStart = body.codexAutoStart;
195
+ if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
196
+ return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
197
+ }
198
+ if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
199
+ return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
200
+ }
201
+ if (typeof body.codexAutoStart === "boolean") {
202
+ config.codexAutoStart = body.codexAutoStart;
203
+ }
204
+ if (body.streamMode !== undefined) {
205
+ if (body.streamMode === "auto") {
206
+ delete config.streamMode;
207
+ } else {
208
+ config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
209
+ }
210
+ }
136
211
  saveConfig(config);
137
212
  invalidateStartupHealthCache();
138
213
  return jsonResponse({
139
214
  ok: true,
140
215
  codexAutoStart: codexAutoStartEnabled(config),
216
+ streamMode: config.streamMode ?? "auto",
141
217
  startupHealth: await getCachedStartupHealth(config),
142
218
  });
143
219
  }
@@ -31,6 +31,7 @@ import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../provid
31
31
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
32
  import { clearThreadAccountMap } from "../../codex/routing";
33
33
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
+ import { getProviderDiscoveryStatus } from "../../codex/model-cache";
34
35
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
36
  import { resolveCodexHomeDir } from "../../codex/home";
36
37
  import { scanStorage } from "../../storage/scanner";
@@ -78,6 +79,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
78
79
  authMode: p.authMode,
79
80
  disabled: p.disabled === true,
80
81
  codexAccountMode: providerCodexAccountMode(name, p),
82
+ discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name),
81
83
  })));
82
84
  }
83
85
 
@@ -51,7 +51,7 @@ import {
51
51
  import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
52
52
  import { drainAndShutdown } from "../lifecycle";
53
53
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
54
- import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
54
+ import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
55
55
  import type { PersistedUsageAttempt } from "../../usage/log";
56
56
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
57
57
  import { applySystemEnvToggle } from "../system-env";
@@ -88,7 +88,7 @@ export type CostResult =
88
88
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
89
89
  | { kind: "unavailable"; reason: MetricUnavailableReason };
90
90
 
91
- export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
91
+ export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier"> & {
92
92
  attempts?: readonly PersistedUsageAttempt[];
93
93
  };
94
94
 
@@ -124,9 +124,10 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
124
124
  }
125
125
 
126
126
  export function costResult(entry: MetricSource): CostResult {
127
+ const tier = effectiveServiceTier(entry);
127
128
  const estimate = entry.attempts?.length
128
- ? estimateComboCost(entry.attempts)
129
- : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
129
+ ? estimateComboCost(entry.attempts, undefined, tier)
130
+ : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
130
131
  if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
131
132
  const estimateReasons = [
132
133
  entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
@@ -152,7 +153,7 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
152
153
  ...attempt,
153
154
  displayMetrics: {
154
155
  tokPerSecond: tokPerSecondResult(attempt),
155
- cost: costResult({ ...attempt, attempts: undefined }),
156
+ cost: costResult({ ...attempt, attempts: undefined, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
156
157
  },
157
158
  })),
158
159
  }
@@ -183,4 +184,3 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
183
184
  return rest;
184
185
  }
185
186
 
186
-