@bitkyc08/opencodex 0.1.0

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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +164 -0
  3. package/README.md +165 -0
  4. package/README.zh-CN.md +162 -0
  5. package/gui/README.md +73 -0
  6. package/gui/dist/assets/index-C1wlp1SM.css +1 -0
  7. package/gui/dist/assets/index-C9y3iMF1.js +9 -0
  8. package/gui/dist/favicon.png +0 -0
  9. package/gui/dist/icons.svg +24 -0
  10. package/gui/dist/index.html +15 -0
  11. package/gui/dist/logo.png +0 -0
  12. package/package.json +56 -0
  13. package/scripts/postinstall.mjs +57 -0
  14. package/src/adapters/anthropic.ts +306 -0
  15. package/src/adapters/azure.ts +31 -0
  16. package/src/adapters/base.ts +20 -0
  17. package/src/adapters/google.ts +195 -0
  18. package/src/adapters/image.ts +23 -0
  19. package/src/adapters/openai-chat.ts +265 -0
  20. package/src/adapters/openai-responses.ts +43 -0
  21. package/src/bridge.ts +296 -0
  22. package/src/cli.ts +183 -0
  23. package/src/codex-catalog.ts +318 -0
  24. package/src/codex-inject.ts +186 -0
  25. package/src/config.ts +108 -0
  26. package/src/index.ts +20 -0
  27. package/src/init.ts +163 -0
  28. package/src/model-cache.ts +42 -0
  29. package/src/oauth/anthropic.ts +151 -0
  30. package/src/oauth/callback-server.ts +249 -0
  31. package/src/oauth/index.ts +235 -0
  32. package/src/oauth/key-providers.ts +126 -0
  33. package/src/oauth/kimi.ts +160 -0
  34. package/src/oauth/local-token-detect.ts +71 -0
  35. package/src/oauth/login-cli.ts +90 -0
  36. package/src/oauth/pkce.ts +15 -0
  37. package/src/oauth/store.ts +39 -0
  38. package/src/oauth/types.ts +22 -0
  39. package/src/oauth/xai.ts +234 -0
  40. package/src/responses/parser.ts +402 -0
  41. package/src/responses/schema.ts +145 -0
  42. package/src/router.ts +86 -0
  43. package/src/server.ts +522 -0
  44. package/src/service.ts +130 -0
  45. package/src/star-prompt.ts +50 -0
  46. package/src/types.ts +228 -0
  47. package/src/update.ts +64 -0
  48. package/src/vision/describe.ts +98 -0
  49. package/src/vision/index.ts +141 -0
  50. package/src/web-search/executor.ts +75 -0
  51. package/src/web-search/format-result.ts +45 -0
  52. package/src/web-search/index.ts +62 -0
  53. package/src/web-search/loop.ts +188 -0
  54. package/src/web-search/parse.ts +128 -0
  55. package/src/web-search/synthetic-tool.ts +42 -0
package/src/server.ts ADDED
@@ -0,0 +1,522 @@
1
+ import { existsSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+ import { createAnthropicAdapter } from "./adapters/anthropic";
4
+ import { createAzureAdapter } from "./adapters/azure";
5
+ import { createGoogleAdapter } from "./adapters/google";
6
+ import { createOpenAIChatAdapter } from "./adapters/openai-chat";
7
+ import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
8
+ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
9
+ import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig } from "./config";
10
+ import { parseRequest } from "./responses/parser";
11
+ import { routeModel } from "./router";
12
+ import { namespacedToolName } from "./types";
13
+ import {
14
+ clearLoginState, getLoginStatus, getValidAccessToken, isOAuthProvider,
15
+ listOAuthProviders, reconcileOAuthProviders, startLoginFlow, upsertOAuthProvider,
16
+ } from "./oauth/index";
17
+ import type { CatalogModel } from "./codex-catalog";
18
+ import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-search";
19
+ import { describeImagesInPlace, planVisionSidecar } from "./vision";
20
+ import { removeCredential } from "./oauth/store";
21
+ import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
22
+ import type { OcxConfig, OcxProviderConfig } from "./types";
23
+
24
+ const VERSION = "0.0.1";
25
+
26
+ const MIME_TYPES: Record<string, string> = {
27
+ ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
28
+ ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
29
+ ".ico": "image/x-icon",
30
+ };
31
+
32
+ function findGuiDist(): string | null {
33
+ const candidates = [
34
+ join(import.meta.dir, "..", "gui", "dist"),
35
+ join(import.meta.dir, "..", "..", "gui", "dist"),
36
+ ];
37
+ for (const c of candidates) {
38
+ if (existsSync(join(c, "index.html"))) return c;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ const GUI_DIST = findGuiDist();
44
+
45
+ function serveGuiFile(pathname: string): Response | null {
46
+ if (!GUI_DIST) return null;
47
+ const filePath = pathname === "/" || pathname === ""
48
+ ? join(GUI_DIST, "index.html")
49
+ : join(GUI_DIST, pathname);
50
+
51
+ if (!existsSync(filePath)) {
52
+ if (!extname(pathname)) {
53
+ const indexPath = join(GUI_DIST, "index.html");
54
+ if (existsSync(indexPath)) {
55
+ return new Response(Bun.file(indexPath), {
56
+ headers: { "Content-Type": "text/html" },
57
+ });
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+
63
+ const ext = extname(filePath);
64
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
65
+ return new Response(Bun.file(filePath), {
66
+ headers: { "Content-Type": contentType },
67
+ });
68
+ }
69
+
70
+ function resolveAdapter(providerConfig: OcxProviderConfig) {
71
+ switch (providerConfig.adapter) {
72
+ case "openai-chat":
73
+ return createOpenAIChatAdapter(providerConfig);
74
+ case "anthropic":
75
+ return createAnthropicAdapter(providerConfig);
76
+ case "openai-responses":
77
+ return createResponsesPassthroughAdapter(providerConfig);
78
+ case "google":
79
+ return createGoogleAdapter(providerConfig);
80
+ case "azure-openai":
81
+ return createAzureAdapter(providerConfig);
82
+ default:
83
+ throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
84
+ }
85
+ }
86
+
87
+ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model: string; provider: string }): Promise<Response> {
88
+ let body: unknown;
89
+ try {
90
+ body = await req.json();
91
+ } catch {
92
+ return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
93
+ }
94
+
95
+ let parsed;
96
+ try {
97
+ parsed = parseRequest(body);
98
+ } catch (err) {
99
+ return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
100
+ }
101
+
102
+ let route;
103
+ try {
104
+ route = routeModel(config, parsed.modelId);
105
+ } catch (err) {
106
+ return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
107
+ }
108
+
109
+ // Apply the routed model id upstream: routing may strip a "<provider>/" namespace
110
+ // (e.g. "opencode-go/deepseek-v4-pro" → "deepseek-v4-pro"). Adapters read parsed.modelId,
111
+ // and the passthrough adapter serializes _rawBody, so rewrite both.
112
+ if (route.modelId !== parsed.modelId) {
113
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
114
+ (parsed._rawBody as { model?: string }).model = route.modelId;
115
+ }
116
+ parsed.modelId = route.modelId;
117
+ }
118
+ logCtx.model = route.modelId;
119
+ logCtx.provider = route.providerName;
120
+
121
+ // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
122
+ // existing openai-chat / anthropic adapters authenticate with no change.
123
+ if (route.provider.authMode === "oauth") {
124
+ try {
125
+ route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
126
+ } catch (err) {
127
+ return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
128
+ }
129
+ }
130
+
131
+ // Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
132
+ // describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
133
+ // with text BEFORE the main call, so the text-only model can reason about it.
134
+ const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
135
+ if (visionPlan) {
136
+ await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
137
+ }
138
+
139
+ const adapter = resolveAdapter(route.provider);
140
+
141
+ if ("passthrough" in adapter && adapter.passthrough) {
142
+ const request = adapter.buildRequest(parsed, { headers: req.headers });
143
+ let upstreamResponse: Response;
144
+ try {
145
+ upstreamResponse = await fetch(request.url, {
146
+ method: request.method,
147
+ headers: request.headers,
148
+ body: request.body,
149
+ });
150
+ } catch (err) {
151
+ return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
152
+ }
153
+ return new Response(upstreamResponse.body, {
154
+ status: upstreamResponse.status,
155
+ headers: sanitizePassthroughHeaders(upstreamResponse.headers),
156
+ });
157
+ }
158
+
159
+ // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
160
+ // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
161
+ // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
162
+ const wsPlan = planWebSearch(config, parsed, false, req.headers, route.provider, route.modelId);
163
+ if (wsPlan) {
164
+ parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
165
+ return runWithWebSearch({
166
+ parsed, adapter,
167
+ forwardProvider: wsPlan.forwardProvider,
168
+ hostedTool: wsPlan.hostedTool,
169
+ incomingHeaders: req.headers,
170
+ settings: wsPlan.settings,
171
+ maxSearches: wsPlan.maxSearches,
172
+ });
173
+ }
174
+
175
+ const request = adapter.buildRequest(parsed, { headers: req.headers });
176
+
177
+ let upstreamResponse: Response;
178
+ try {
179
+ upstreamResponse = await fetch(request.url, {
180
+ method: request.method,
181
+ headers: request.headers,
182
+ body: request.body,
183
+ });
184
+ } catch (err) {
185
+ return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
186
+ }
187
+
188
+ if (!upstreamResponse.ok) {
189
+ const errorText = await upstreamResponse.text().catch(() => "unknown error");
190
+ return formatErrorResponse(upstreamResponse.status, "upstream_error", `Provider error ${upstreamResponse.status}: ${errorText.slice(0, 500)}`);
191
+ }
192
+
193
+ if (parsed.stream) {
194
+ const eventStream = adapter.parseStream(upstreamResponse);
195
+ // Map flattened MCP tool names back to {namespace, name} so the bridge can restore the
196
+ // namespace field Codex needs to route the call to the right MCP server.
197
+ const toolNsMap = new Map<string, { namespace: string; name: string }>();
198
+ const freeformToolNames = new Set<string>();
199
+ const toolSearchToolNames = new Set<string>();
200
+ for (const t of parsed.context.tools ?? []) {
201
+ if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
202
+ if (t.freeform) freeformToolNames.add(t.name);
203
+ if (t.toolSearch) toolSearchToolNames.add(t.name);
204
+ }
205
+ const sseStream = bridgeToResponsesSSE(eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames);
206
+ return new Response(sseStream, {
207
+ headers: {
208
+ "Content-Type": "text/event-stream",
209
+ "Cache-Control": "no-cache",
210
+ "Connection": "keep-alive",
211
+ "X-Accel-Buffering": "no",
212
+ },
213
+ });
214
+ }
215
+
216
+ if (adapter.parseResponse) {
217
+ const events = await adapter.parseResponse(upstreamResponse);
218
+ const json = buildResponseJSON(events, parsed.modelId);
219
+ return new Response(JSON.stringify(json), {
220
+ headers: { "Content-Type": "application/json" },
221
+ });
222
+ }
223
+
224
+ return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
225
+ }
226
+
227
+ const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
228
+ const MAX_LOG_SIZE = 200;
229
+
230
+ function addRequestLog(entry: typeof requestLog[number]) {
231
+ requestLog.push(entry);
232
+ if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
233
+ }
234
+
235
+ /**
236
+ * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
237
+ * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
238
+ * body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough.
239
+ * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
240
+ */
241
+ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
242
+ const DROP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
243
+ const out = new Headers();
244
+ upstream.forEach((value, key) => {
245
+ if (!DROP.has(key.toLowerCase())) out.set(key, value);
246
+ });
247
+ return out;
248
+ }
249
+
250
+ function corsHeaders(): Record<string, string> {
251
+ return {
252
+ "Access-Control-Allow-Origin": "*",
253
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
254
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
255
+ };
256
+ }
257
+
258
+ function jsonResponse(data: unknown, status = 200): Response {
259
+ return new Response(JSON.stringify(data), {
260
+ status,
261
+ headers: { "Content-Type": "application/json", ...corsHeaders() },
262
+ });
263
+ }
264
+
265
+ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
266
+ if (url.pathname === "/api/config" && req.method === "GET") {
267
+ const safeConfig = JSON.parse(JSON.stringify(config));
268
+ for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
269
+ if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "...";
270
+ }
271
+ return jsonResponse(safeConfig);
272
+ }
273
+
274
+ if (url.pathname === "/api/config" && req.method === "PUT") {
275
+ const body = await req.json() as OcxConfig;
276
+ const { saveConfig: save } = await import("./config");
277
+ save(body);
278
+ return jsonResponse({ success: true });
279
+ }
280
+
281
+ if (url.pathname === "/api/logs" && req.method === "GET") {
282
+ return jsonResponse(requestLog);
283
+ }
284
+
285
+ if (url.pathname === "/api/providers" && req.method === "GET") {
286
+ return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
287
+ name, adapter: p.adapter, baseUrl: p.baseUrl, defaultModel: p.defaultModel,
288
+ hasApiKey: !!p.apiKey,
289
+ })));
290
+ }
291
+
292
+ // Add (or overwrite) a single provider. Merges into the live in-memory config and
293
+ // persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
294
+ // which would re-save the masked keys from GET). Live routing picks it up immediately.
295
+ if (url.pathname === "/api/providers" && req.method === "POST") {
296
+ let body: { name?: string; provider?: OcxProviderConfig; setDefault?: boolean };
297
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
298
+ const name = body.name?.trim();
299
+ const prov = body.provider;
300
+ if (!name || !prov?.adapter || !prov?.baseUrl) {
301
+ return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
302
+ }
303
+ // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
304
+ // doesn't send — merge it in so the sidecars are gated correctly.
305
+ enrichProviderFromCatalog(name, prov);
306
+ const { saveConfig: save } = await import("./config");
307
+ config.providers[name] = prov;
308
+ if (body.setDefault) config.defaultProvider = name;
309
+ save(config);
310
+ return jsonResponse({ success: true, name });
311
+ }
312
+
313
+ if (url.pathname === "/api/providers" && req.method === "DELETE") {
314
+ const name = url.searchParams.get("name")?.trim();
315
+ if (!name || !config.providers[name]) return jsonResponse({ error: "unknown provider" }, 404);
316
+ const { saveConfig: save } = await import("./config");
317
+ delete config.providers[name];
318
+ save(config);
319
+ // Drop its models from Codex's catalog immediately (re-sync + cache bust) so removal is live.
320
+ try {
321
+ const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
322
+ await syncCatalogModels(config);
323
+ invalidateCodexModelsCache();
324
+ } catch { /* catalog absent */ }
325
+ return jsonResponse({ success: true });
326
+ }
327
+
328
+ if (url.pathname === "/api/models" && req.method === "GET") {
329
+ const models = await fetchAllModels(config);
330
+ const disabled = new Set(config.disabledModels ?? []);
331
+ return jsonResponse(models.map(m => {
332
+ const namespaced = `${m.provider}/${m.id}`;
333
+ return { ...m, namespaced, disabled: disabled.has(namespaced) };
334
+ }));
335
+ }
336
+
337
+ // Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
338
+ // /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
339
+ if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
340
+ let body: { models?: unknown };
341
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
342
+ const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
343
+ config.disabledModels = disabled;
344
+ const { saveConfig: save } = await import("./config");
345
+ save(config);
346
+ try {
347
+ const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
348
+ await syncCatalogModels(config);
349
+ invalidateCodexModelsCache();
350
+ } catch { /* catalog absent */ }
351
+ return jsonResponse({ ok: true, disabled });
352
+ }
353
+
354
+ // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
355
+ if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
356
+ return jsonResponse({ providers: listOAuthProviders() });
357
+ }
358
+
359
+ // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
360
+ if (url.pathname === "/api/key-providers" && req.method === "GET") {
361
+ return jsonResponse({ providers: listKeyLoginProviders() });
362
+ }
363
+
364
+ // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
365
+ // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
366
+ if (url.pathname === "/api/subagent-models" && req.method === "GET") {
367
+ const models = await fetchAllModels(config);
368
+ const disabled = new Set(config.disabledModels ?? []);
369
+ // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
370
+ // catalog, just buried by priority. List them first so the user can feature them over routed.
371
+ const { listCatalogNativeSlugs } = await import("./codex-catalog");
372
+ const available = [
373
+ ...listCatalogNativeSlugs(),
374
+ ...models.map(m => `${m.provider}/${m.id}`),
375
+ ].filter(ns => !disabled.has(ns));
376
+ return jsonResponse({ chosen: config.subagentModels ?? [], available });
377
+ }
378
+ if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
379
+ let body: { models?: unknown };
380
+ try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
381
+ const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
382
+ config.subagentModels = chosen;
383
+ const { saveConfig: save } = await import("./config");
384
+ save(config);
385
+ try {
386
+ const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
387
+ await syncCatalogModels(config);
388
+ invalidateCodexModelsCache();
389
+ } catch { /* catalog absent */ }
390
+ return jsonResponse({ ok: true, applied: chosen });
391
+ }
392
+
393
+ // OAuth login (xai now; anthropic/kimi in cycle 2). Starts the flow and returns the auth URL;
394
+ // the provider's loopback callback server (inside this process) captures the redirect in the
395
+ // background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
396
+ if (url.pathname === "/api/oauth/login" && req.method === "POST") {
397
+ const body = await req.json().catch(() => ({})) as { provider?: string };
398
+ const provider = (body.provider ?? "").trim().toLowerCase();
399
+ if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
400
+ try {
401
+ const { url: authUrl, instructions } = await startLoginFlow(provider);
402
+ upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
403
+ if (authUrl) {
404
+ // Open the browser server-side (the proxy runs on the user's machine) — the GUI's
405
+ // window.open is popup-blocked because it runs after an await, not a direct click.
406
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? 'start ""' : "xdg-open";
407
+ (await import("node:child_process")).exec(`${cmd} "${authUrl}"`, () => {});
408
+ }
409
+ return jsonResponse({ url: authUrl, instructions });
410
+ } catch (err) {
411
+ return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
412
+ }
413
+ }
414
+
415
+ if (url.pathname === "/api/oauth/status" && req.method === "GET") {
416
+ const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
417
+ return jsonResponse(getLoginStatus(provider));
418
+ }
419
+
420
+ if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
421
+ const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
422
+ if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
423
+ removeCredential(provider);
424
+ clearLoginState(provider);
425
+ return jsonResponse({ success: true });
426
+ }
427
+
428
+ return null;
429
+ }
430
+
431
+ /**
432
+ * Live routed-provider models for the proxy's /api/* and /v1/models endpoints. Delegates to the
433
+ * canonical, TTL-cached `gatherRoutedModels` (single source of truth) — so the GUI/codex endpoints
434
+ * share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling),
435
+ * and the same stale fallback when a provider blips, instead of a parallel uncached copy.
436
+ */
437
+ async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
438
+ const { gatherRoutedModels } = await import("./codex-catalog");
439
+ return gatherRoutedModels(config);
440
+ }
441
+
442
+ export function startServer(port?: number) {
443
+ const config = loadConfig();
444
+ // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
445
+ // adding/dropping models reaches existing configs on start — not just fresh installs.
446
+ reconcileOAuthProviders(config);
447
+ // Seed default featured subagent models on first run only (UNSET → defaults). A user-set list,
448
+ // even [], is left alone so GUI removals persist.
449
+ if (config.subagentModels === undefined) {
450
+ config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
451
+ saveConfig(config);
452
+ }
453
+ const listenPort = port ?? config.port ?? 10100;
454
+
455
+ const server = Bun.serve({
456
+ port: listenPort,
457
+ async fetch(req) {
458
+ const url = new URL(req.url);
459
+
460
+ if (req.method === "OPTIONS") {
461
+ return new Response(null, { status: 204, headers: corsHeaders() });
462
+ }
463
+
464
+ if (url.pathname === "/healthz" && req.method === "GET") {
465
+ return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
466
+ }
467
+
468
+ if (url.pathname.startsWith("/api/")) {
469
+ const mgmtResponse = await handleManagementAPI(req, url, config);
470
+ if (mgmtResponse) return mgmtResponse;
471
+ }
472
+
473
+ if (url.pathname === "/v1/models" && req.method === "GET") {
474
+ const goModels = await fetchAllModels(config);
475
+ const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents } = await import("./codex-catalog");
476
+ const nativeSlugs = nativeOpenAiSlugs();
477
+ const disabledSet = new Set(config.disabledModels ?? []);
478
+ const goEnabled = goModels.filter(m => !disabledSet.has(`${m.provider}/${m.id}`));
479
+ const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
480
+ if (url.searchParams.has("client_version")) {
481
+ // Codex client → Codex catalog shape: native gpt + namespaced routed models,
482
+ // cloned from a native template so required fields (base_instructions, etc.) are present.
483
+ // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
484
+ return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels) });
485
+ }
486
+ // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
487
+ const data = [
488
+ ...nativeSlugs.map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
489
+ ...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
490
+ ];
491
+ return jsonResponse({ object: "list", data });
492
+ }
493
+
494
+ if (url.pathname === "/v1/responses" && req.method === "POST") {
495
+ const start = Date.now();
496
+ const logCtx = { model: "unknown", provider: "unknown" };
497
+ const response = await handleResponses(req, config, logCtx);
498
+ addRequestLog({
499
+ timestamp: start,
500
+ model: logCtx.model,
501
+ provider: logCtx.provider,
502
+ status: response.status,
503
+ durationMs: Date.now() - start,
504
+ });
505
+ return response;
506
+ }
507
+
508
+ const guiFile = serveGuiFile(url.pathname);
509
+ if (guiFile) return guiFile;
510
+
511
+ return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
512
+ },
513
+ });
514
+
515
+ console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
516
+ console.log(` POST /v1/responses → provider translation`);
517
+ console.log(` GET /healthz → health check`);
518
+ console.log(` GET /api/* → management API`);
519
+ console.log(` GET / → GUI dashboard`);
520
+
521
+ return server;
522
+ }
package/src/service.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * `ocx service` — run the proxy as a background service that auto-starts on login and
3
+ * auto-restarts on crash. macOS → launchd LaunchAgent; Windows → Task Scheduler.
4
+ * The plist/task sets OCX_SERVICE=1 so the proxy's shutdown handler does NOT restore native
5
+ * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall
6
+ * restore it via the command.
7
+ */
8
+ import { execSync } from "node:child_process";
9
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { getConfigDir } from "./config";
13
+ import { restoreNativeCodex } from "./codex-inject";
14
+
15
+ const LABEL = "com.opencodex.proxy";
16
+ const TASK = "opencodex-proxy";
17
+
18
+ function cliEntry(): { bun: string; cli: string } {
19
+ // process.execPath = the bun binary; cli.ts sits next to this module.
20
+ return { bun: process.execPath, cli: join(import.meta.dir, "cli.ts") };
21
+ }
22
+
23
+ function plistPath(): string {
24
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
25
+ }
26
+
27
+ function logPath(): string {
28
+ return join(getConfigDir(), "service.log");
29
+ }
30
+
31
+ export function buildPlist(): string {
32
+ const { bun, cli } = cliEntry();
33
+ const log = logPath();
34
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
35
+ return `<?xml version="1.0" encoding="UTF-8"?>
36
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
+ <plist version="1.0">
38
+ <dict>
39
+ <key>Label</key><string>${LABEL}</string>
40
+ <key>ProgramArguments</key>
41
+ <array>
42
+ <string>${bun}</string>
43
+ <string>${cli}</string>
44
+ <string>start</string>
45
+ </array>
46
+ <key>RunAtLoad</key><true/>
47
+ <key>KeepAlive</key><true/>
48
+ <key>EnvironmentVariables</key>
49
+ <dict>
50
+ <key>OCX_SERVICE</key><string>1</string>
51
+ <key>PATH</key><string>${path}</string>
52
+ </dict>
53
+ <key>StandardOutPath</key><string>${log}</string>
54
+ <key>StandardErrorPath</key><string>${log}</string>
55
+ </dict>
56
+ </plist>
57
+ `;
58
+ }
59
+
60
+ function sh(cmd: string): string {
61
+ return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
62
+ }
63
+
64
+ // ── macOS (launchd) ──
65
+ function installLaunchd(): void {
66
+ const dir = join(homedir(), "Library", "LaunchAgents");
67
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
68
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
69
+ const p = plistPath();
70
+ writeFileSync(p, buildPlist(), "utf8");
71
+ try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
72
+ sh(`launchctl load -w "${p}"`);
73
+ }
74
+ function startLaunchd(): void { sh(`launchctl load -w "${plistPath()}"`); }
75
+ function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
76
+ function statusLaunchd(): string { try { return sh(`launchctl list | grep ${LABEL} || true`); } catch { return ""; } }
77
+ function uninstallLaunchd(): void {
78
+ const p = plistPath();
79
+ try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
80
+ if (existsSync(p)) unlinkSync(p);
81
+ }
82
+
83
+ // ── Windows (Task Scheduler) ──
84
+ function installWindows(): void {
85
+ const { bun, cli } = cliEntry();
86
+ sh(`schtasks /create /tn ${TASK} /tr "\\"${bun}\\" \\"${cli}\\" start" /sc onlogon /rl highest /f`);
87
+ sh(`schtasks /run /tn ${TASK}`);
88
+ }
89
+ function startWindows(): void { sh(`schtasks /run /tn ${TASK}`); }
90
+ function stopWindows(): void { try { sh(`schtasks /end /tn ${TASK}`); } catch { /* not running */ } }
91
+ function statusWindows(): string { try { return sh(`schtasks /query /tn ${TASK}`); } catch { return ""; } }
92
+ function uninstallWindows(): void { try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ } }
93
+
94
+ export function serviceCommand(sub?: string): void {
95
+ const mac = process.platform === "darwin";
96
+ const win = process.platform === "win32";
97
+ if (!mac && !win) {
98
+ console.error("ocx service supports macOS (launchd) and Windows (Task Scheduler). On Linux, run 'ocx start' under systemd or your process supervisor.");
99
+ process.exit(1);
100
+ }
101
+ switch (sub) {
102
+ case "install":
103
+ mac ? installLaunchd() : installWindows();
104
+ console.log("✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
105
+ break;
106
+ case "start":
107
+ mac ? startLaunchd() : startWindows();
108
+ console.log("✅ service started.");
109
+ break;
110
+ case "stop":
111
+ mac ? stopLaunchd() : stopWindows();
112
+ restoreNativeCodex();
113
+ console.log("✅ service stopped + native Codex restored.");
114
+ break;
115
+ case "status": {
116
+ const s = mac ? statusLaunchd() : statusWindows();
117
+ console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
118
+ break;
119
+ }
120
+ case "uninstall":
121
+ case "remove":
122
+ mac ? uninstallLaunchd() : uninstallWindows();
123
+ restoreNativeCodex();
124
+ console.log("✅ service uninstalled + native Codex restored.");
125
+ break;
126
+ default:
127
+ console.error("Usage: ocx service <install|start|stop|status|uninstall>");
128
+ process.exit(1);
129
+ }
130
+ }