@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
|
@@ -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
|
-
|
|
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 (
|
|
192
|
+
if (body.codexAutoStart === undefined && body.streamMode === undefined) {
|
|
133
193
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
134
194
|
}
|
|
135
|
-
|
|
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
|
-
|