@bitkyc08/opencodex 2.0.2 → 2.1.3
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.ko.md +42 -2
- package/README.md +43 -3
- package/README.zh-CN.md +21 -0
- package/gui/dist/assets/index-34pGgy8q.js +9 -0
- package/gui/dist/assets/{index-cEIM1XWY.css → index-dCS-lwCM.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +7 -3
- package/src/adapters/azure.ts +7 -7
- package/src/adapters/google.ts +4 -3
- package/src/adapters/openai-chat.ts +2 -1
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +142 -26
- package/src/cli.ts +166 -13
- package/src/codex-catalog.ts +1 -0
- package/src/codex-history-provider.ts +86 -0
- package/src/codex-inject.ts +9 -1
- package/src/codex-shim.ts +76 -32
- package/src/config.ts +31 -5
- package/src/init.ts +11 -0
- package/src/oauth/store.ts +10 -4
- package/src/open-url.ts +7 -3
- package/src/ports.ts +30 -0
- package/src/providers/registry.ts +1 -1
- package/src/responses/parser.ts +9 -6
- package/src/responses/schema.ts +1 -0
- package/src/server.ts +208 -43
- package/src/service.ts +29 -2
- package/src/types.ts +10 -0
- package/src/update.ts +12 -2
- package/src/web-search/loop.ts +9 -1
- package/src/ws-bridge.ts +1 -1
- package/gui/dist/assets/index-PrH8v83W.js +0 -9
package/src/open-url.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
export function openUrl(url: string): void {
|
|
4
|
+
if (!/^https?:\/\//i.test(url)) return;
|
|
4
5
|
const cmd =
|
|
5
6
|
process.platform === "darwin" ? "open"
|
|
6
|
-
: process.platform === "win32" ?
|
|
7
|
+
: process.platform === "win32" ? "rundll32"
|
|
7
8
|
: "xdg-open";
|
|
8
|
-
|
|
9
|
+
const args = process.platform === "win32"
|
|
10
|
+
? ["url.dll,FileProtocolHandler", url]
|
|
11
|
+
: [url];
|
|
12
|
+
spawn(cmd, args, { detached: true, stdio: "ignore", shell: false }).unref();
|
|
9
13
|
}
|
package/src/ports.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
|
|
4
|
+
return await new Promise(resolve => {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
server.once("error", () => resolve(false));
|
|
7
|
+
server.once("listening", () => {
|
|
8
|
+
server.close(() => resolve(true));
|
|
9
|
+
});
|
|
10
|
+
server.listen({ port, host: hostname });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function findAvailablePort(preferredPort: number, hostname = "127.0.0.1"): Promise<number> {
|
|
15
|
+
if (await isPortAvailable(preferredPort, hostname)) return preferredPort;
|
|
16
|
+
return await new Promise((resolve, reject) => {
|
|
17
|
+
const server = createServer();
|
|
18
|
+
server.once("error", reject);
|
|
19
|
+
server.once("listening", () => {
|
|
20
|
+
const address = server.address();
|
|
21
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
22
|
+
server.close(() => {
|
|
23
|
+
if (port > 0) resolve(port);
|
|
24
|
+
else reject(new Error("failed to allocate an available port"));
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
server.listen({ port: 0, host: hostname });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -176,7 +176,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
176
176
|
{ id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" },
|
|
177
177
|
{ id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
|
|
178
178
|
{ id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] },
|
|
179
|
-
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai
|
|
179
|
+
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
180
180
|
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
|
|
181
181
|
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
|
|
182
182
|
{ id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
|
package/src/responses/parser.ts
CHANGED
|
@@ -177,15 +177,15 @@ function outputToToolResultContent(output: string | unknown[] | undefined): stri
|
|
|
177
177
|
return parts;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
function
|
|
180
|
+
function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } {
|
|
181
181
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
182
182
|
const m = messages[i];
|
|
183
183
|
if (m.role !== "assistant") continue;
|
|
184
184
|
for (const part of m.content) {
|
|
185
|
-
if (part.type === "toolCall" && part.id === callId) return part.name;
|
|
185
|
+
if (part.type === "toolCall" && part.id === callId) return { name: part.name, namespace: part.namespace };
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
|
-
return "";
|
|
188
|
+
return { name: "" };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
@@ -327,9 +327,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
327
327
|
|
|
328
328
|
if (effectiveType === "function_call_output") {
|
|
329
329
|
const output = item as { call_id: string; output?: string | unknown[] };
|
|
330
|
+
const toolInfo = findToolById(messages, output.call_id);
|
|
330
331
|
messages.push({
|
|
331
332
|
role: "toolResult", toolCallId: output.call_id,
|
|
332
|
-
toolName:
|
|
333
|
+
toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
|
|
333
334
|
content: outputToToolResultContent(output.output), isError: false, timestamp: now,
|
|
334
335
|
});
|
|
335
336
|
continue;
|
|
@@ -337,9 +338,10 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
337
338
|
|
|
338
339
|
if (effectiveType === "custom_tool_call_output") {
|
|
339
340
|
const output = item as { call_id: string; output: string };
|
|
341
|
+
const toolInfo = findToolById(messages, output.call_id);
|
|
340
342
|
messages.push({
|
|
341
343
|
role: "toolResult", toolCallId: output.call_id,
|
|
342
|
-
toolName:
|
|
344
|
+
toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
|
|
343
345
|
content: output.output ?? "", isError: false, timestamp: now,
|
|
344
346
|
});
|
|
345
347
|
}
|
|
@@ -373,7 +375,8 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
373
375
|
if (data.reasoning?.effort && REASONING_EFFORTS.has(data.reasoning.effort)) {
|
|
374
376
|
options.reasoning = data.reasoning.effort;
|
|
375
377
|
}
|
|
376
|
-
|
|
378
|
+
const summaryMode = data.reasoning?.summary;
|
|
379
|
+
if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true;
|
|
377
380
|
if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
|
|
378
381
|
if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
|
|
379
382
|
|
package/src/responses/schema.ts
CHANGED
|
@@ -50,6 +50,7 @@ const functionCallItemSchema = z.object({
|
|
|
50
50
|
id: z.string().optional(),
|
|
51
51
|
call_id: z.string().min(1),
|
|
52
52
|
name: z.string().min(1),
|
|
53
|
+
namespace: z.string().optional(),
|
|
53
54
|
arguments: z.string().optional(),
|
|
54
55
|
});
|
|
55
56
|
const functionCallOutputItemSchema = z.object({
|
package/src/server.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type WsData,
|
|
17
17
|
} from "./ws-bridge";
|
|
18
18
|
import type { ServerWebSocket } from "bun";
|
|
19
|
-
import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
|
|
19
|
+
import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, loadConfig, saveConfig, websocketsEnabled } from "./config";
|
|
20
20
|
import { parseRequest } from "./responses/parser";
|
|
21
21
|
import { routeModel } from "./router";
|
|
22
22
|
import { namespacedToolName } from "./types";
|
|
@@ -86,6 +86,18 @@ function serveGuiFile(pathname: string): Response | null {
|
|
|
86
86
|
});
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
const ANTHROPIC_WIRE_MODELS: Record<string, Set<string>> = {
|
|
90
|
+
"opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3", "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function resolveWireProtocolOverride(providerName: string, modelId: string, providerConfig: OcxProviderConfig): OcxProviderConfig {
|
|
94
|
+
const overrideSet = ANTHROPIC_WIRE_MODELS[providerName];
|
|
95
|
+
if (overrideSet?.has(modelId) && providerConfig.adapter !== "anthropic") {
|
|
96
|
+
return { ...providerConfig, adapter: "anthropic" };
|
|
97
|
+
}
|
|
98
|
+
return providerConfig;
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
export function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
90
102
|
switch (providerConfig.adapter) {
|
|
91
103
|
case "openai-chat":
|
|
@@ -161,7 +173,8 @@ async function handleResponses(
|
|
|
161
173
|
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
|
|
162
174
|
}
|
|
163
175
|
|
|
164
|
-
const
|
|
176
|
+
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
177
|
+
const adapter = resolveAdapter(adapterProvider);
|
|
165
178
|
|
|
166
179
|
if ("passthrough" in adapter && adapter.passthrough) {
|
|
167
180
|
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
@@ -170,20 +183,29 @@ async function handleResponses(
|
|
|
170
183
|
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
171
184
|
const upstream = new AbortController();
|
|
172
185
|
linkAbortSignal(upstream, options.abortSignal);
|
|
186
|
+
const connectMs = config.connectTimeoutMs ?? 30_000;
|
|
173
187
|
let upstreamResponse: Response;
|
|
174
188
|
try {
|
|
175
|
-
upstreamResponse = await
|
|
189
|
+
upstreamResponse = await fetchWithHeaderTimeout(request.url, {
|
|
176
190
|
method: request.method,
|
|
177
191
|
headers: request.headers,
|
|
178
192
|
body: request.body,
|
|
179
|
-
|
|
180
|
-
});
|
|
193
|
+
}, upstream.signal, connectMs);
|
|
181
194
|
} catch (err) {
|
|
182
|
-
|
|
195
|
+
upstream.abort();
|
|
196
|
+
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
197
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
198
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
199
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
183
200
|
}
|
|
184
|
-
|
|
201
|
+
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
202
|
+
const isEventStream = headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
|
|
203
|
+
const body = isEventStream
|
|
204
|
+
? relaySseWithHeartbeat(upstreamResponse.body, upstream)
|
|
205
|
+
: relayWithAbort(upstreamResponse.body, upstream);
|
|
206
|
+
return new Response(body, {
|
|
185
207
|
status: upstreamResponse.status,
|
|
186
|
-
headers
|
|
208
|
+
headers,
|
|
187
209
|
});
|
|
188
210
|
}
|
|
189
211
|
|
|
@@ -200,26 +222,27 @@ async function handleResponses(
|
|
|
200
222
|
incomingHeaders: req.headers,
|
|
201
223
|
settings: wsPlan.settings,
|
|
202
224
|
maxSearches: wsPlan.maxSearches,
|
|
225
|
+
forceEmptyResponseId: true,
|
|
203
226
|
abortSignal: options.abortSignal,
|
|
204
227
|
});
|
|
205
228
|
}
|
|
206
229
|
|
|
207
|
-
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
208
|
-
|
|
209
|
-
// Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
|
|
210
|
-
// not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
|
|
211
230
|
const upstream = new AbortController();
|
|
212
231
|
linkAbortSignal(upstream, options.abortSignal);
|
|
232
|
+
const connectMs = config.connectTimeoutMs ?? 30_000;
|
|
233
|
+
|
|
234
|
+
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
213
235
|
let upstreamResponse: Response;
|
|
214
236
|
try {
|
|
215
|
-
upstreamResponse = await
|
|
216
|
-
method: request.method,
|
|
217
|
-
|
|
218
|
-
body: request.body,
|
|
219
|
-
signal: upstream.signal,
|
|
220
|
-
});
|
|
237
|
+
upstreamResponse = await fetchWithHeaderTimeout(request.url, {
|
|
238
|
+
method: request.method, headers: request.headers, body: request.body,
|
|
239
|
+
}, upstream.signal, connectMs);
|
|
221
240
|
} catch (err) {
|
|
222
|
-
|
|
241
|
+
upstream.abort();
|
|
242
|
+
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
243
|
+
? `Provider connect timeout after ${connectMs}ms`
|
|
244
|
+
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
245
|
+
return formatErrorResponse(502, "upstream_error", msg);
|
|
223
246
|
}
|
|
224
247
|
|
|
225
248
|
if (!upstreamResponse.ok) {
|
|
@@ -229,8 +252,6 @@ async function handleResponses(
|
|
|
229
252
|
|
|
230
253
|
if (parsed.stream) {
|
|
231
254
|
const eventStream = adapter.parseStream(upstreamResponse);
|
|
232
|
-
// Map flattened MCP tool names back to {namespace, name} so the bridge can restore the
|
|
233
|
-
// namespace field Codex needs to route the call to the right MCP server.
|
|
234
255
|
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
235
256
|
const freeformToolNames = new Set<string>();
|
|
236
257
|
const toolSearchToolNames = new Set<string>();
|
|
@@ -240,31 +261,36 @@ async function handleResponses(
|
|
|
240
261
|
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
241
262
|
}
|
|
242
263
|
const sseStream = bridgeToResponsesSSE(
|
|
243
|
-
eventStream,
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
options.forceEmptyResponseId ? { responseId: "" } : undefined,
|
|
264
|
+
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
265
|
+
() => upstream.abort(), 2_000,
|
|
266
|
+
{
|
|
267
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
268
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
269
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
270
|
+
},
|
|
251
271
|
);
|
|
252
272
|
return new Response(sseStream, {
|
|
253
|
-
headers: {
|
|
254
|
-
"Content-Type": "text/event-stream",
|
|
255
|
-
"Cache-Control": "no-cache",
|
|
256
|
-
"Connection": "keep-alive",
|
|
257
|
-
"X-Accel-Buffering": "no",
|
|
258
|
-
},
|
|
273
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
259
274
|
});
|
|
260
275
|
}
|
|
261
276
|
|
|
262
277
|
if (adapter.parseResponse) {
|
|
263
278
|
const events = await adapter.parseResponse(upstreamResponse);
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
279
|
+
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
280
|
+
const freeformToolNames = new Set<string>();
|
|
281
|
+
const toolSearchToolNames = new Set<string>();
|
|
282
|
+
for (const t of parsed.context.tools ?? []) {
|
|
283
|
+
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
|
|
284
|
+
if (t.freeform) freeformToolNames.add(t.name);
|
|
285
|
+
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
286
|
+
}
|
|
287
|
+
const json = buildResponseJSON(events, parsed.modelId, {
|
|
288
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
289
|
+
toolNsMap,
|
|
290
|
+
freeformToolNames,
|
|
291
|
+
toolSearchToolNames,
|
|
267
292
|
});
|
|
293
|
+
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
268
294
|
}
|
|
269
295
|
|
|
270
296
|
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
|
|
@@ -279,6 +305,26 @@ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal)
|
|
|
279
305
|
signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
|
|
280
306
|
}
|
|
281
307
|
|
|
308
|
+
async function fetchWithHeaderTimeout(
|
|
309
|
+
url: string,
|
|
310
|
+
init: Omit<RequestInit, "signal">,
|
|
311
|
+
abortSignal: AbortSignal,
|
|
312
|
+
timeoutMs: number,
|
|
313
|
+
): Promise<Response> {
|
|
314
|
+
const timeout = new AbortController();
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError"));
|
|
317
|
+
}, timeoutMs);
|
|
318
|
+
try {
|
|
319
|
+
return await fetch(url, {
|
|
320
|
+
...init,
|
|
321
|
+
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
322
|
+
});
|
|
323
|
+
} finally {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
282
328
|
const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
|
|
283
329
|
const MAX_LOG_SIZE = 200;
|
|
284
330
|
|
|
@@ -320,6 +366,56 @@ export function relayWithAbort(
|
|
|
320
366
|
});
|
|
321
367
|
}
|
|
322
368
|
|
|
369
|
+
export function relaySseWithHeartbeat(
|
|
370
|
+
body: ReadableStream<Uint8Array> | null,
|
|
371
|
+
upstream: AbortController,
|
|
372
|
+
heartbeatMs = 15_000,
|
|
373
|
+
): ReadableStream<Uint8Array> | null {
|
|
374
|
+
if (!body) return null;
|
|
375
|
+
const reader = body.getReader();
|
|
376
|
+
const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n");
|
|
377
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
378
|
+
let closed = false;
|
|
379
|
+
|
|
380
|
+
const cleanup = () => {
|
|
381
|
+
closed = true;
|
|
382
|
+
if (timer) clearInterval(timer);
|
|
383
|
+
timer = undefined;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
return new ReadableStream<Uint8Array>({
|
|
387
|
+
start(controller) {
|
|
388
|
+
timer = setInterval(() => {
|
|
389
|
+
if (closed) return;
|
|
390
|
+
try {
|
|
391
|
+
controller.enqueue(heartbeat);
|
|
392
|
+
} catch {
|
|
393
|
+
cleanup();
|
|
394
|
+
}
|
|
395
|
+
}, heartbeatMs);
|
|
396
|
+
},
|
|
397
|
+
async pull(controller) {
|
|
398
|
+
try {
|
|
399
|
+
const { done, value } = await reader.read();
|
|
400
|
+
if (done) {
|
|
401
|
+
cleanup();
|
|
402
|
+
controller.close();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
controller.enqueue(value);
|
|
406
|
+
} catch (err) {
|
|
407
|
+
cleanup();
|
|
408
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
cancel(reason) {
|
|
412
|
+
cleanup();
|
|
413
|
+
upstream.abort(reason);
|
|
414
|
+
reader.cancel(reason).catch(() => {});
|
|
415
|
+
},
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
323
419
|
/**
|
|
324
420
|
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
|
|
325
421
|
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
|
|
@@ -346,9 +442,11 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
|
|
|
346
442
|
return out;
|
|
347
443
|
}
|
|
348
444
|
|
|
445
|
+
let _corsOrigin = "http://localhost:10100";
|
|
446
|
+
function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
|
|
349
447
|
function corsHeaders(): Record<string, string> {
|
|
350
448
|
return {
|
|
351
|
-
"Access-Control-Allow-Origin":
|
|
449
|
+
"Access-Control-Allow-Origin": _corsOrigin,
|
|
352
450
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
353
451
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
354
452
|
};
|
|
@@ -361,7 +459,18 @@ function jsonResponse(data: unknown, status = 200): Response {
|
|
|
361
459
|
});
|
|
362
460
|
}
|
|
363
461
|
|
|
462
|
+
function isLocalOrigin(req: Request): boolean {
|
|
463
|
+
const origin = req.headers.get("Origin");
|
|
464
|
+
if (!origin) return true;
|
|
465
|
+
const localhostOrigin = _corsOrigin;
|
|
466
|
+
const loopbackOrigin = _corsOrigin.replace("localhost", "127.0.0.1");
|
|
467
|
+
return origin === localhostOrigin || origin === loopbackOrigin;
|
|
468
|
+
}
|
|
469
|
+
|
|
364
470
|
async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
|
|
471
|
+
if ((req.method === "POST" || req.method === "PUT" || req.method === "DELETE") && !isLocalOrigin(req)) {
|
|
472
|
+
return jsonResponse({ error: "cross-origin request blocked" }, 403);
|
|
473
|
+
}
|
|
365
474
|
async function refreshCodexCatalogBestEffort(): Promise<void> {
|
|
366
475
|
try {
|
|
367
476
|
const { refreshCodexModelCatalog } = await import("./codex-refresh");
|
|
@@ -373,6 +482,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
|
|
|
373
482
|
|
|
374
483
|
if (url.pathname === "/api/config" && req.method === "GET") {
|
|
375
484
|
const safeConfig = JSON.parse(JSON.stringify(config));
|
|
485
|
+
safeConfig.codexAutoStart = codexAutoStartEnabled(config);
|
|
376
486
|
for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
|
|
377
487
|
if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "...";
|
|
378
488
|
}
|
|
@@ -380,10 +490,57 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
|
|
|
380
490
|
}
|
|
381
491
|
|
|
382
492
|
if (url.pathname === "/api/config" && req.method === "PUT") {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
493
|
+
return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
497
|
+
return jsonResponse({
|
|
498
|
+
codexAutoStart: codexAutoStartEnabled(config),
|
|
499
|
+
port: config.port,
|
|
500
|
+
hostname: config.hostname ?? "127.0.0.1",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (url.pathname === "/api/settings" && req.method === "PUT") {
|
|
505
|
+
let body: { codexAutoStart?: unknown };
|
|
506
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
507
|
+
if (typeof body.codexAutoStart !== "boolean") {
|
|
508
|
+
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
509
|
+
}
|
|
510
|
+
config.codexAutoStart = body.codexAutoStart;
|
|
511
|
+
saveConfig(config);
|
|
512
|
+
return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
|
|
516
|
+
const ws = config.webSearchSidecar ?? {};
|
|
517
|
+
const vs = config.visionSidecar ?? {};
|
|
518
|
+
return jsonResponse({
|
|
519
|
+
webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
|
|
520
|
+
vision: { model: vs.model ?? "gpt-5.4-mini" },
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
|
|
525
|
+
let body: { webSearch?: { model?: string; reasoning?: string }; vision?: { model?: string } };
|
|
526
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
527
|
+
if (body.webSearch) {
|
|
528
|
+
config.webSearchSidecar = { ...config.webSearchSidecar };
|
|
529
|
+
if (typeof body.webSearch.model === "string") config.webSearchSidecar.model = body.webSearch.model;
|
|
530
|
+
if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
|
|
531
|
+
}
|
|
532
|
+
if (body.vision) {
|
|
533
|
+
config.visionSidecar = { ...config.visionSidecar };
|
|
534
|
+
if (typeof body.vision.model === "string") config.visionSidecar.model = body.vision.model;
|
|
535
|
+
}
|
|
536
|
+
saveConfig(config);
|
|
537
|
+
const ws = config.webSearchSidecar ?? {};
|
|
538
|
+
const vs = config.visionSidecar ?? {};
|
|
539
|
+
return jsonResponse({
|
|
540
|
+
ok: true,
|
|
541
|
+
webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
|
|
542
|
+
vision: { model: vs.model ?? "gpt-5.4-mini" },
|
|
543
|
+
});
|
|
387
544
|
}
|
|
388
545
|
|
|
389
546
|
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
@@ -563,9 +720,11 @@ export function startServer(port?: number) {
|
|
|
563
720
|
saveConfig(config);
|
|
564
721
|
}
|
|
565
722
|
const listenPort = port ?? config.port ?? 10100;
|
|
723
|
+
setCorsOrigin(listenPort);
|
|
566
724
|
|
|
567
725
|
const server = Bun.serve<WsData>({
|
|
568
726
|
port: listenPort,
|
|
727
|
+
hostname: config.hostname ?? "127.0.0.1",
|
|
569
728
|
idleTimeout: 255,
|
|
570
729
|
async fetch(req) {
|
|
571
730
|
const url = new URL(req.url);
|
|
@@ -577,6 +736,9 @@ export function startServer(port?: number) {
|
|
|
577
736
|
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
578
737
|
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
579
738
|
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
739
|
+
if (!isLocalOrigin(req)) {
|
|
740
|
+
return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
|
|
741
|
+
}
|
|
580
742
|
if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
|
|
581
743
|
return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
|
|
582
744
|
}
|
|
@@ -612,6 +774,9 @@ export function startServer(port?: number) {
|
|
|
612
774
|
}
|
|
613
775
|
|
|
614
776
|
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
777
|
+
if (!isLocalOrigin(req)) {
|
|
778
|
+
return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
|
|
779
|
+
}
|
|
615
780
|
const start = Date.now();
|
|
616
781
|
const logCtx = { model: "unknown", provider: "unknown" };
|
|
617
782
|
const response = await handleResponses(req, config, logCtx);
|
package/src/service.ts
CHANGED
|
@@ -85,6 +85,12 @@ function systemdEnvironmentAssignment(name: string, value: string | undefined):
|
|
|
85
85
|
return `Environment=${systemdQuote(`${name}=${value}`)}`;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function systemdOutputTarget(value: string): string {
|
|
89
|
+
// StandardOutput/StandardError use output specifiers such as append:/path.
|
|
90
|
+
// Quoting the full specifier makes systemd reject it as an invalid output target.
|
|
91
|
+
return value.replace(/%/g, "%%").replace(/\n/g, "\\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
function sh(cmd: string): string {
|
|
89
95
|
return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
90
96
|
}
|
|
@@ -183,8 +189,8 @@ ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
|
|
|
183
189
|
Restart=on-failure
|
|
184
190
|
RestartSec=5
|
|
185
191
|
${envLines}
|
|
186
|
-
StandardOutput=${
|
|
187
|
-
StandardError=${
|
|
192
|
+
StandardOutput=${systemdOutputTarget(`append:${log}`)}
|
|
193
|
+
StandardError=${systemdOutputTarget(`append:${log}`)}
|
|
188
194
|
|
|
189
195
|
[Install]
|
|
190
196
|
WantedBy=default.target
|
|
@@ -256,6 +262,27 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
256
262
|
return false;
|
|
257
263
|
}
|
|
258
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
|
|
267
|
+
* when no service exists and never exits the process just because the platform has no service
|
|
268
|
+
* manager.
|
|
269
|
+
*/
|
|
270
|
+
export function uninstallServiceIfInstalled(): boolean {
|
|
271
|
+
if (process.platform === "darwin") {
|
|
272
|
+
if (existsSync(plistPath())) {
|
|
273
|
+
try { uninstallLaunchd(); return true; } catch { return false; }
|
|
274
|
+
}
|
|
275
|
+
} else if (process.platform === "win32") {
|
|
276
|
+
try {
|
|
277
|
+
const q = sh(`schtasks /query /tn ${TASK} 2>nul`);
|
|
278
|
+
if (q.includes(TASK)) { uninstallWindows(); return true; }
|
|
279
|
+
} catch { /* task not found */ }
|
|
280
|
+
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
281
|
+
try { uninstallSystemd(); return true; } catch { return false; }
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
export function serviceCommand(sub?: string): void {
|
|
260
287
|
const ops = platformOps();
|
|
261
288
|
if (!ops) {
|
package/src/types.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface OcxToolResultMessage {
|
|
|
54
54
|
role: "toolResult";
|
|
55
55
|
toolCallId: string;
|
|
56
56
|
toolName: string;
|
|
57
|
+
/** MCP namespace from the originating tool call, if any. */
|
|
58
|
+
toolNamespace?: string;
|
|
57
59
|
/** Text, or content parts when a tool (e.g. Codex view_image) returns an image in its output. */
|
|
58
60
|
content: string | OcxContentPart[];
|
|
59
61
|
isError: boolean;
|
|
@@ -175,8 +177,16 @@ export interface OcxConfig {
|
|
|
175
177
|
subagentModels?: string[];
|
|
176
178
|
/** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
|
|
177
179
|
disabledModels?: string[];
|
|
180
|
+
/** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */
|
|
181
|
+
hostname?: string;
|
|
182
|
+
/** Upstream stall timeout (seconds). After this many seconds of no upstream data, emits response.incomplete. Default 90. Min 1. */
|
|
183
|
+
stallTimeoutSec?: number;
|
|
184
|
+
/** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 30000. */
|
|
185
|
+
connectTimeoutMs?: number;
|
|
178
186
|
/** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt in. */
|
|
179
187
|
websockets?: boolean;
|
|
188
|
+
/** Auto-start/sync the proxy from the Codex shim before launching Codex. Default true. */
|
|
189
|
+
codexAutoStart?: boolean;
|
|
180
190
|
/** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
|
|
181
191
|
modelCacheTtlMs?: number;
|
|
182
192
|
/** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
|
package/src/update.ts
CHANGED
|
@@ -32,7 +32,7 @@ function latestVersion(): string | null {
|
|
|
32
32
|
* `ocx update` — self-update opencodex to the latest published version, using the same package
|
|
33
33
|
* manager it was installed with (bun or npm global). A source checkout is told to `git pull` instead.
|
|
34
34
|
*/
|
|
35
|
-
export function runUpdate(): void {
|
|
35
|
+
export async function runUpdate(): Promise<void> {
|
|
36
36
|
const installer = detectInstall();
|
|
37
37
|
const current = currentVersion();
|
|
38
38
|
console.log(`opencodex v${current} (installed via ${installer})`);
|
|
@@ -56,7 +56,17 @@ export function runUpdate(): void {
|
|
|
56
56
|
|
|
57
57
|
const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });
|
|
58
58
|
if (r.status === 0) {
|
|
59
|
-
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}
|
|
59
|
+
console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
|
|
60
|
+
if (process.platform === "win32") {
|
|
61
|
+
try {
|
|
62
|
+
const { installCodexShim } = await import("./codex-shim");
|
|
63
|
+
const result = installCodexShim();
|
|
64
|
+
if (result.installed) console.log(`🔧 ${result.message}`);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.log("Restart the proxy: ocx stop && ocx start");
|
|
60
70
|
} else {
|
|
61
71
|
console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
|
|
62
72
|
process.exit(1);
|
package/src/web-search/loop.ts
CHANGED
|
@@ -95,6 +95,7 @@ export interface WebSearchLoopDeps {
|
|
|
95
95
|
incomingHeaders: Headers;
|
|
96
96
|
settings: SidecarSettings;
|
|
97
97
|
maxSearches: number;
|
|
98
|
+
forceEmptyResponseId?: boolean;
|
|
98
99
|
abortSignal?: AbortSignal;
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -189,6 +190,13 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
|
|
|
189
190
|
if (t.freeform) freeform.add(t.name);
|
|
190
191
|
if (t.toolSearch) toolSearch.add(t.name);
|
|
191
192
|
}
|
|
192
|
-
const sse = bridgeToResponsesSSE(
|
|
193
|
+
const sse = bridgeToResponsesSSE(
|
|
194
|
+
replay(finalEvents), parsed.modelId, toolNsMap, freeform, toolSearch,
|
|
195
|
+
undefined, undefined,
|
|
196
|
+
{
|
|
197
|
+
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
198
|
+
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
199
|
+
},
|
|
200
|
+
);
|
|
193
201
|
return new Response(sse, { headers: SSE_HEADERS });
|
|
194
202
|
}
|
package/src/ws-bridge.ts
CHANGED
|
@@ -223,7 +223,7 @@ export function sendResponsesJsonAsEvents(
|
|
|
223
223
|
? response.status
|
|
224
224
|
: "completed";
|
|
225
225
|
sendJsonFrame(ws, {
|
|
226
|
-
type: finalStatus
|
|
226
|
+
type: `response.${finalStatus}` as "response.completed" | "response.failed" | "response.incomplete",
|
|
227
227
|
response: { ...response, status: finalStatus },
|
|
228
228
|
});
|
|
229
229
|
}
|