Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#!/usr/bin/env bun
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { execFileSync, spawn } from "node:child_process";
import { restoreNativeCodex } from "./codex-inject";
import { loadConfig, readPid, removePid, writePid } from "./config";
import { serviceCommand, stopServiceIfInstalled } from "./service";
Expand Down Expand Up @@ -38,11 +37,10 @@ async function syncModelsToCodex(port?: number) {
const p = port ?? config.port ?? 10100;
let catalogPath: string | null | undefined;
try {
const { invalidateCodexModelsCache, syncCatalogModels } = await import("./codex-catalog");
const cat = await syncCatalogModels(config);
catalogPath = existsSync(cat.path) ? cat.path : null;
const { refreshCodexModelCatalog } = await import("./codex-refresh");
const cat = await refreshCodexModelCatalog(config);
catalogPath = cat.catalogExists ? cat.path : null;
if (cat.added > 0) {
invalidateCodexModelsCache();
console.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
} else if (catalogPath === null) {
console.error("catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.");
Expand All @@ -56,7 +54,7 @@ async function syncModelsToCodex(port?: number) {
return result;
}

async function handleStart() {
async function handleStart(options: { block?: boolean } = {}) {
const existingPid = readPid();
if (existingPid) {
console.error(`⚠️ Proxy already running (PID ${existingPid}). Use 'ocx stop' first.`);
Expand Down Expand Up @@ -91,6 +89,10 @@ async function handleStart() {

await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start
await syncModelsToCodex(port).catch(() => {});
if (options.block ?? true) {
setInterval(() => {}, 60_000);
await new Promise<void>(() => {});
}
}

function killProxy(pid: number): void {
Expand Down Expand Up @@ -204,7 +206,12 @@ switch (command) {
const guiUrl = `http://localhost:${config.port}`;
if (!cfg.readPid()) {
console.log("Proxy not running. Starting...");
await handleStart();
const child = spawn(process.execPath, [process.argv[1], "start"], {
detached: true,
stdio: "ignore",
env: process.env,
});
child.unref();
await new Promise(r => setTimeout(r, 1000));
}
console.log(`Opening ${guiUrl}`);
Expand Down
49 changes: 49 additions & 0 deletions src/codex-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { existsSync, readFileSync } from "node:fs";
import { invalidateCodexModelsCache, syncCatalogModels } from "./codex-catalog";
import { CODEX_MODELS_CACHE_PATH } from "./codex-paths";
import { atomicWriteFile } from "./config";
import type { OcxConfig } from "./types";

export interface CodexCatalogRefreshResult {
added: number;
path: string;
catalogExists: boolean;
cacheSynced: boolean;
}

interface RefreshDeps {
syncCatalogModels: typeof syncCatalogModels;
invalidateCodexModelsCache: typeof invalidateCodexModelsCache;
syncCodexModelsCacheFromCatalog: typeof syncCodexModelsCacheFromCatalog;
existsSync: typeof existsSync;
}

const defaultDeps: RefreshDeps = {
syncCatalogModels,
invalidateCodexModelsCache,
syncCodexModelsCacheFromCatalog,
existsSync,
};

export function syncCodexModelsCacheFromCatalog(catalogPath: string): void {
const content = readFileSync(catalogPath, "utf8");
atomicWriteFile(CODEX_MODELS_CACHE_PATH, content);
}

/**
* Rebuild Codex's on-disk model catalog and keep Codex's models cache aligned
* when a catalog file exists. Codex Desktop can read models_cache.json directly,
* so deleting a stale cache is not enough: the cache must be replaced with the
* same catalog content the CLI debug path reads.
*/
export async function refreshCodexModelCatalog(
config: OcxConfig,
deps: RefreshDeps = defaultDeps,
): Promise<CodexCatalogRefreshResult> {
const result = await deps.syncCatalogModels(config);
const catalogExists = deps.existsSync(result.path);
if (!catalogExists) return { ...result, catalogExists, cacheSynced: false };
deps.invalidateCodexModelsCache();
deps.syncCodexModelsCacheFromCatalog(result.path);
return { ...result, catalogExists, cacheSynced: true };
}
28 changes: 13 additions & 15 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,15 @@ function jsonResponse(data: unknown, status = 200): Response {
}

async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
async function refreshCodexCatalogBestEffort(): Promise<void> {
try {
const { refreshCodexModelCatalog } = await import("./codex-refresh");
await refreshCodexModelCatalog(config);
} catch {
/* catalog absent */
}
}

if (url.pathname === "/api/config" && req.method === "GET") {
const safeConfig = JSON.parse(JSON.stringify(config));
for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
Expand Down Expand Up @@ -406,6 +415,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
config.providers[name] = prov;
if (body.setDefault) config.defaultProvider = name;
save(config);
await refreshCodexCatalogBestEffort();
return jsonResponse({ success: true, name });
}

Expand All @@ -416,11 +426,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
delete config.providers[name];
save(config);
// Drop its models from Codex's catalog immediately (re-sync + cache bust) so removal is live.
try {
const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
await syncCatalogModels(config);
invalidateCodexModelsCache();
} catch { /* catalog absent */ }
await refreshCodexCatalogBestEffort();
return jsonResponse({ success: true });
}

Expand All @@ -442,11 +448,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
config.disabledModels = disabled;
const { saveConfig: save } = await import("./config");
save(config);
try {
const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
await syncCatalogModels(config);
invalidateCodexModelsCache();
} catch { /* catalog absent */ }
await refreshCodexCatalogBestEffort();
return jsonResponse({ ok: true, disabled });
}

Expand Down Expand Up @@ -487,11 +489,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
config.subagentModels = chosen;
const { saveConfig: save } = await import("./config");
save(config);
try {
const { syncCatalogModels, invalidateCodexModelsCache } = await import("./codex-catalog");
await syncCatalogModels(config);
invalidateCodexModelsCache();
} catch { /* catalog absent */ }
await refreshCodexCatalogBestEffort();
return jsonResponse({ ok: true, applied: chosen });
}

Expand Down
47 changes: 47 additions & 0 deletions tests/codex-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { refreshCodexModelCatalog } from "../src/codex-refresh";
import type { OcxConfig } from "../src/types";

const config = {
port: 10100,
defaultProvider: "openai",
providers: {},
} as OcxConfig;

describe("Codex catalog refresh", () => {
test("replaces Codex's models cache whenever the materialized catalog exists", async () => {
let invalidated = 0;
let syncedFrom: string | null = null;
const result = await refreshCodexModelCatalog(config, {
syncCatalogModels: async () => ({ added: 0, path: "/tmp/opencodex-catalog.json" }),
invalidateCodexModelsCache: () => { invalidated += 1; },
syncCodexModelsCacheFromCatalog: (path) => { syncedFrom = path; },
existsSync: () => true,
});

expect(result).toEqual({
added: 0,
path: "/tmp/opencodex-catalog.json",
catalogExists: true,
cacheSynced: true,
});
expect(invalidated).toBe(1);
expect(syncedFrom).toBe("/tmp/opencodex-catalog.json");
});

test("does not touch the cache when no Codex catalog can be materialized", async () => {
let invalidated = 0;
let synced = false;
const result = await refreshCodexModelCatalog(config, {
syncCatalogModels: async () => ({ added: 0, path: "/tmp/missing-catalog.json" }),
invalidateCodexModelsCache: () => { invalidated += 1; },
syncCodexModelsCacheFromCatalog: () => { synced = true; },
existsSync: () => false,
});

expect(result.catalogExists).toBe(false);
expect(result.cacheSynced).toBe(false);
expect(invalidated).toBe(0);
expect(synced).toBe(false);
});
});
Loading