Skip to content
Merged
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
6 changes: 6 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Usage:
ocx service <sub> Run as a background service (install|start|stop|status|uninstall)
ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall)
ocx sync Fetch models from providers and inject into Codex config
ocx sync-cache Refresh Codex's model cache from the active catalog
ocx status Check proxy server status
ocx login <provider> OAuth login (xai) — opens browser, stores token in ~/.opencodex/auth.json
ocx logout <provider> Remove a stored OAuth login
Expand Down Expand Up @@ -201,6 +202,11 @@ switch (command) {
await syncModelsToCodex();
break;
}
case "sync-cache": {
const { invalidateCodexModelsCache } = await import("./codex-catalog");
invalidateCodexModelsCache();
break;
}
case "gui": {
const cfg = await import("./config");
const config = cfg.loadConfig();
Expand Down
86 changes: 86 additions & 0 deletions src/codex-history-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Database } from "bun:sqlite";
import { CODEX_HOME } from "./codex-paths";

const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
const RESUMABLE_SOURCES = ["cli", "vscode"] as const;

type CodexHistoryProvider = "openai" | "opencodex";

interface ThreadRow {
id: string;
rollout_path: string;
}

function updateSessionMetaProvider(path: string, provider: CodexHistoryProvider): boolean {
if (!path || !existsSync(path)) return false;
const stat = statSync(path);
const raw = readFileSync(path, "utf8");
const newline = raw.indexOf("\n");
const firstLine = newline === -1 ? raw : raw.slice(0, newline);
const rest = newline === -1 ? "" : raw.slice(newline);

let parsed: unknown;
try {
parsed = JSON.parse(firstLine);
} catch {
return false;
}

if (!parsed || typeof parsed !== "object") return false;
const record = parsed as { type?: unknown; payload?: { model_provider?: unknown } };
if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return false;
if (record.payload.model_provider === provider) return false;

record.payload.model_provider = provider;
writeFileSync(path, `${JSON.stringify(record)}${rest}`, "utf8");
utimesSync(path, stat.atime, stat.mtime);
return true;
}

export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
const from = provider === "opencodex" ? "openai" : "opencodex";
const db = new Database(stateDbPath);
try {
const placeholders = RESUMABLE_SOURCES.map(() => "?").join(",");
const rows = db
.query<ThreadRow, string[]>(`
SELECT id, rollout_path
FROM threads
WHERE model_provider = ?
AND source IN (${placeholders})
`)
.all(from, ...RESUMABLE_SOURCES);

let files = 0;
for (const row of rows) {
try {
if (updateSessionMetaProvider(row.rollout_path, provider)) files++;
} catch {
/* best-effort; keep DB migration moving even if one old rollout is malformed */
}
}

const update = db.transaction(() => {
db.query(`
UPDATE threads
SET has_user_event = 1
WHERE source IN (${placeholders})
AND trim(coalesce(first_user_message, '')) != ''
`).run(...RESUMABLE_SOURCES);
db.query(`
UPDATE threads
SET model_provider = ?
WHERE model_provider = ?
AND source IN (${placeholders})
`).run(provider, from, ...RESUMABLE_SOURCES);
});
update();

return { rows: rows.length, files };
} finally {
db.close();
}
}
10 changes: 9 additions & 1 deletion src/codex-inject.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { atomicWriteFile, websocketsEnabled } from "./config";
import { restoreCodexCatalog } from "./codex-catalog";
import { syncCodexHistoryProvider } from "./codex-history-provider";
import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./codex-paths";
import type { OcxConfig } from "./types";

Expand Down Expand Up @@ -228,14 +229,19 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option

writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
const history = syncCodexHistoryProvider("opencodex");

const catalogMessage = catalogPath
? ` Codex model catalog: ${catalogPath}\n`
: ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
const historyMessage = history.rows > 0
? ` Codex resume history: ${history.rows} thread(s) mapped to opencodex.\n`
: "";
return {
success: true,
message: `Injected opencodex as default provider into Codex config.\n` +
catalogMessage +
historyMessage +
` All models now route through opencodex proxy (like OpenRouter).\n` +
` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
` Custom models route to their configured providers.\n` +
Expand Down Expand Up @@ -311,10 +317,12 @@ export function removeCodexConfig(): { success: boolean; message: string } {
export function restoreNativeCodex(): { success: boolean; message: string } {
const cfg = removeCodexConfig();
const cat = restoreCodexCatalog();
const history = syncCodexHistoryProvider("openai");
const msg = cat.removed > 0
? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
: cfg.message;
return { success: cfg.success, message: msg };
const historyMsg = history.rows > 0 ? ` Resume history restored to openai (${history.rows} thread(s)).` : "";
return { success: cfg.success, message: `${msg}${historyMsg}` };
}

export function getCodexConfigPath(): string {
Expand Down
4 changes: 3 additions & 1 deletion src/codex-shim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ if [ -z "$OCX_SHIM_BYPASS" ]; then
i=$((i + 1))
done
fi
"${bunPath}" "${cliPath}" sync-cache >/dev/null 2>&1 || true
fi
exec "${realCodexPath}" "$@"
`;
Expand All @@ -85,6 +86,7 @@ for /l %%i in (1,1,50) do (\r
powershell -NoProfile -Command "Start-Sleep -Milliseconds 100" >nul 2>nul\r
)\r
:run_codex\r
"${bunPath}" "${cliPath}" sync-cache >nul 2>nul\r
"${realCodexPath}" %*\r
`;
}
Expand Down Expand Up @@ -127,7 +129,7 @@ export function installCodexShim(): { installed: boolean; message: string } {
return { installed: false, message: `Codex autostart shim already installed at ${existing.wrapperPath}.` };
}
if (existing && existsSync(existing.backupPath) && (!existsSync(existing.wrapperPath) || !isShim(existing.wrapperPath))) {
if (existsSync(existing.wrapperPath)) renameSync(existing.wrapperPath, existing.backupPath);
if (existsSync(existing.wrapperPath)) unlinkSync(existing.wrapperPath);
writeShim(existing.wrapperPath, existing.backupPath);
writeState({ ...existing, platform: process.platform });
return {
Expand Down
71 changes: 71 additions & 0 deletions tests/codex-history-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { syncCodexHistoryProvider } from "../src/codex-history-provider";

function makeFixture() {
const dir = join(tmpdir(), `ocx-history-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
mkdirSync(dir, { recursive: true });
const rollout = join(dir, "rollout.jsonl");
writeFileSync(rollout, [
JSON.stringify({
type: "session_meta",
payload: { id: "thread-1", model_provider: "openai", cwd: dir },
}),
JSON.stringify({ type: "event_msg", timestamp: "2026-01-01T00:00:00.000Z", payload: { message: "x" } }),
].join("\n") + "\n");
const mtime = new Date("2026-01-02T03:04:05.000Z");
utimesSync(rollout, mtime, mtime);

const dbPath = join(dir, "state_5.sqlite");
const db = new Database(dbPath);
db.run(`
CREATE TABLE threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
model_provider TEXT NOT NULL,
source TEXT NOT NULL,
first_user_message TEXT NOT NULL,
has_user_event INTEGER NOT NULL DEFAULT 0
)
`);
db.run(`
INSERT INTO threads (id, rollout_path, model_provider, source, first_user_message, has_user_event)
VALUES ('thread-1', ?, 'openai', 'vscode', 'hello', 0)
`, rollout);
db.close();
return { dbPath, rollout, mtime };
}

describe("Codex history provider sync", () => {
test("maps resumable Codex threads to opencodex without touching file mtime", () => {
const { dbPath, rollout, mtime } = makeFixture();

const result = syncCodexHistoryProvider("opencodex", dbPath);

expect(result).toEqual({ rows: 1, files: 1 });
const db = new Database(dbPath);
expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "opencodex" });
expect(db.query("SELECT has_user_event FROM threads WHERE id = 'thread-1'").get()).toEqual({ has_user_event: 1 });
db.close();
const firstLine = readFileSync(rollout, "utf8").split("\n")[0];
expect(JSON.parse(firstLine).payload.model_provider).toBe("opencodex");
expect(statSync(rollout).mtime.getTime()).toBe(mtime.getTime());
});

test("maps resumable Codex threads back to openai", () => {
const { dbPath, rollout } = makeFixture();
syncCodexHistoryProvider("opencodex", dbPath);

const result = syncCodexHistoryProvider("openai", dbPath);

expect(result).toEqual({ rows: 1, files: 1 });
const db = new Database(dbPath);
expect(db.query("SELECT model_provider FROM threads WHERE id = 'thread-1'").get()).toEqual({ model_provider: "openai" });
db.close();
const firstLine = readFileSync(rollout, "utf8").split("\n")[0];
expect(JSON.parse(firstLine).payload.model_provider).toBe("openai");
});
});
2 changes: 2 additions & 0 deletions tests/codex-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ describe("Codex autostart shim", () => {
expect(script).toContain("status");
expect(script).toContain("OCX_SERVICE=1");
expect(script).toContain("start");
expect(script).toContain("sync-cache");
expect(script).toContain('exec "/usr/local/bin/codex-real" "$@"');
});

Expand All @@ -23,6 +24,7 @@ describe("Codex autostart shim", () => {
expect(script).toContain(SHIM_MARKER);
expect(script).toContain("findstr");
expect(script).toContain("OCX_SERVICE=1");
expect(script).toContain("sync-cache");
expect(script).toContain('"C:\\Tools\\codex-real.exe" %*');
});

Expand Down
Loading