@bitkyc08/opencodex 2.1.5 → 2.1.6

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-DB2i6w5f.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-CBwrJA6W.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-dCS-lwCM.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.1.5",
3
+ "version": "2.1.6",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,6 +37,7 @@ const MIN_THINKING_BUDGET = 1024;
37
37
  const OUTPUT_HEADROOM = 8192;
38
38
  /** Minimum visible-output room kept below `max_tokens` (so `max_tokens > budget_tokens` always holds). */
39
39
  const OUTPUT_FLOOR = 4096;
40
+ const COMPAT_TOOL_PREFIX = "ocx_";
40
41
 
41
42
  /** Map a Responses reasoning effort to an Anthropic extended-thinking budget (tokens, >= 1024). */
42
43
  function reasoningBudget(effort: string): number {
@@ -61,7 +62,23 @@ function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage
61
62
  };
62
63
  }
63
64
 
64
- function messagesToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean): { system: string | undefined; messages: unknown[] } {
65
+ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: string) => string; fromWire: (name: string) => string } {
66
+ if (provider.authMode === "oauth") {
67
+ return { toWire: applyClaudeToolPrefix, fromWire: stripClaudeToolPrefix };
68
+ }
69
+ if (provider.escapeBuiltinToolNames === true) {
70
+ return {
71
+ toWire: (name) => name.startsWith(COMPAT_TOOL_PREFIX) ? name : COMPAT_TOOL_PREFIX + name,
72
+ fromWire: (name) => name.startsWith(COMPAT_TOOL_PREFIX) ? name.slice(COMPAT_TOOL_PREFIX.length) : name,
73
+ };
74
+ }
75
+ return { toWire: (name) => name, fromWire: (name) => name };
76
+ }
77
+
78
+ function messagesToAnthropicFormat(
79
+ parsed: OcxParsedRequest,
80
+ toolNames: { toWire: (name: string) => string },
81
+ ): { system: string | undefined; messages: unknown[] } {
65
82
  const system = parsed.context.systemPrompt?.join("\n\n") || undefined;
66
83
  const messages: unknown[] = [];
67
84
 
@@ -87,7 +104,7 @@ function messagesToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean):
87
104
  } else if (part.type === "toolCall") {
88
105
  const tc = part as OcxToolCall;
89
106
  const flatName = namespacedToolName(tc.namespace, tc.name);
90
- content.push({ type: "tool_use", id: tc.id, name: isOAuth ? applyClaudeToolPrefix(flatName) : flatName, input: tc.arguments });
107
+ content.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments });
91
108
  }
92
109
  }
93
110
  messages.push({ role: "assistant", content });
@@ -115,10 +132,10 @@ function messagesToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean):
115
132
  return { system, messages };
116
133
  }
117
134
 
118
- function toolsToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean): unknown[] | undefined {
135
+ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }): unknown[] | undefined {
119
136
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
120
137
  return parsed.context.tools.map(t => ({
121
- name: isOAuth ? applyClaudeToolPrefix(namespacedToolName(t.namespace, t.name)) : namespacedToolName(t.namespace, t.name),
138
+ name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
122
139
  description: t.description,
123
140
  input_schema: t.parameters,
124
141
  }));
@@ -126,12 +143,13 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean): unk
126
143
 
127
144
  export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAdapter {
128
145
  const isOAuth = provider.authMode === "oauth";
146
+ const toolNames = buildToolNameTransforms(provider);
129
147
  return {
130
148
  name: "anthropic",
131
149
 
132
150
  buildRequest(parsed: OcxParsedRequest) {
133
- const { system, messages } = messagesToAnthropicFormat(parsed, isOAuth);
134
- const tools = toolsToAnthropicFormat(parsed, isOAuth);
151
+ const { system, messages } = messagesToAnthropicFormat(parsed, toolNames);
152
+ const tools = toolsToAnthropicFormat(parsed, toolNames);
135
153
 
136
154
  const body: Record<string, unknown> = {
137
155
  model: parsed.modelId,
@@ -174,7 +192,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
174
192
  if (tc === "auto") body.tool_choice = { type: "auto" };
175
193
  else if (tc === "none") body.tool_choice = { type: "none" };
176
194
  else if (tc === "required") body.tool_choice = { type: "any" };
177
- else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: isOAuth ? applyClaudeToolPrefix(tc.name) : tc.name };
195
+ else if (typeof tc === "object" && "name" in tc) body.tool_choice = { type: "tool", name: toolNames.toWire(tc.name) };
178
196
  }
179
197
 
180
198
  const base = provider.baseUrl.replace(/\/v1\/?$/, "");
@@ -241,7 +259,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
241
259
  currentBlockType = block.type;
242
260
  if (block.type === "tool_use") {
243
261
  currentToolCallId = block.id ?? "";
244
- currentToolCallName = isOAuth ? stripClaudeToolPrefix(block.name ?? "") : (block.name ?? "");
262
+ currentToolCallName = toolNames.fromWire(block.name ?? "");
245
263
  yield { type: "tool_call_start", id: currentToolCallId, name: currentToolCallName };
246
264
  }
247
265
  break;
@@ -302,7 +320,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
302
320
  if (block.type === "text" && block.text) {
303
321
  events.push({ type: "text_delta", text: block.text });
304
322
  } else if (block.type === "tool_use") {
305
- events.push({ type: "tool_call_start", id: block.id ?? "", name: isOAuth ? stripClaudeToolPrefix(block.name ?? "") : (block.name ?? "") });
323
+ events.push({ type: "tool_call_start", id: block.id ?? "", name: toolNames.fromWire(block.name ?? "") });
306
324
  events.push({ type: "tool_call_delta", arguments: JSON.stringify(block.input ?? {}) });
307
325
  events.push({ type: "tool_call_end" });
308
326
  }
package/src/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { execFileSync, spawn } from "node:child_process";
3
3
  import { rmSync } from "node:fs";
4
4
  import { restoreNativeCodex } from "./codex-inject";
5
+ import { restoreLegacyOpenaiHistory } from "./codex-history-provider";
5
6
  import { codexAutoStartEnabled, getConfigDir, loadConfig, readPid, removePid, saveConfig, writePid } from "./config";
6
7
  import { findAvailablePort } from "./ports";
7
8
  import { serviceCommand, stopServiceIfInstalled, uninstallServiceIfInstalled } from "./service";
@@ -19,6 +20,8 @@ Usage:
19
20
  ocx start [--port <port>] Start the proxy server (auto-syncs models to Codex)
20
21
  ocx stop Stop the proxy AND restore native Codex (plain codex works again)
21
22
  ocx restore Restore native Codex without stopping (alias: eject)
23
+ ocx recover-history --legacy-openai
24
+ Explicitly recover pre-backup syncResumeHistory rows
22
25
  ocx uninstall Remove service/shim/config and restore native Codex
23
26
  ocx service <sub> Run as a background service (install|start|stop|status|uninstall)
24
27
  ocx codex-shim <sub> Auto-start proxy when \`codex\` launches (install|status|uninstall)
@@ -38,6 +41,72 @@ Examples:
38
41
  ocx sync Sync available models to Codex`);
39
42
  }
40
43
 
44
+ function hasHelpFlag(values: string[]): boolean {
45
+ return values.some(value => value === "--help" || value === "-h" || value === "help");
46
+ }
47
+
48
+ function printSubcommandUsage(name: string | undefined): void {
49
+ switch (name) {
50
+ case "init":
51
+ console.log("Usage: ocx init\n\nInteractive setup for providers and Codex config injection.");
52
+ break;
53
+ case "start":
54
+ console.log("Usage: ocx start [--port <port>]\n\nStart the proxy server and sync models to Codex.");
55
+ break;
56
+ case "stop":
57
+ console.log("Usage: ocx stop\n\nStop the proxy and restore native Codex config.");
58
+ break;
59
+ case "restore":
60
+ case "eject":
61
+ console.log(`Usage: ocx ${name}\n\nRestore native Codex config without stopping the proxy.`);
62
+ break;
63
+ case "recover-history":
64
+ console.log("Usage: ocx recover-history --legacy-openai\n\nExplicitly recover pre-backup syncResumeHistory rows.");
65
+ break;
66
+ case "uninstall":
67
+ case "remove":
68
+ console.log(`Usage: ocx ${name}\n\nRemove service/shim/config and restore native Codex.`);
69
+ break;
70
+ case "service":
71
+ console.log("Usage: ocx service <install|start|stop|status|uninstall>");
72
+ break;
73
+ case "codex-shim":
74
+ console.log("Usage: ocx codex-shim <install|status|uninstall>");
75
+ break;
76
+ case "ensure":
77
+ console.log("Usage: ocx ensure\n\nEnsure the proxy is running and Codex config/cache are current.");
78
+ break;
79
+ case "sync":
80
+ console.log("Usage: ocx sync\n\nFetch provider models and inject them into Codex config.");
81
+ break;
82
+ case "sync-cache":
83
+ console.log("Usage: ocx sync-cache\n\nRefresh Codex's model cache from the active catalog.");
84
+ break;
85
+ case "status":
86
+ console.log("Usage: ocx status\n\nCheck proxy server status.");
87
+ break;
88
+ case "login":
89
+ console.log("Usage: ocx login <provider>\n\nOAuth or API-key login for a provider.");
90
+ break;
91
+ case "logout":
92
+ console.log("Usage: ocx logout <provider>\n\nRemove a stored provider login.");
93
+ break;
94
+ case "gui":
95
+ console.log("Usage: ocx gui\n\nOpen the opencodex dashboard.");
96
+ break;
97
+ case "update":
98
+ console.log("Usage: ocx update\n\nUpdate opencodex to the latest published version.");
99
+ break;
100
+ default:
101
+ printUsage();
102
+ }
103
+ }
104
+
105
+ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
106
+ printSubcommandUsage(command);
107
+ process.exit(0);
108
+ }
109
+
41
110
  async function syncModelsToCodex(port?: number) {
42
111
  const config = loadConfig();
43
112
  const p = port ?? config.port ?? 10100;
@@ -304,6 +373,16 @@ function handleStatus() {
304
373
  }
305
374
  }
306
375
 
376
+ function handleRecoverHistory() {
377
+ if (args[1] !== "--legacy-openai") {
378
+ console.error("Usage: ocx recover-history --legacy-openai");
379
+ console.error("Only use this if an older syncResumeHistory build already remapped OpenAI Codex App history to opencodex before backup support existed.");
380
+ process.exit(1);
381
+ }
382
+ const r = restoreLegacyOpenaiHistory();
383
+ console.log(`Recovered ${r.rows} legacy thread(s) to openai (${r.files} rollout file(s) updated).`);
384
+ }
385
+
307
386
  switch (command) {
308
387
  case "init": {
309
388
  const { runInit } = await import("./init");
@@ -323,6 +402,9 @@ switch (command) {
323
402
  console.log("Plain `codex` now runs natively (no proxy).");
324
403
  break;
325
404
  }
405
+ case "recover-history":
406
+ handleRecoverHistory();
407
+ break;
326
408
  case "uninstall":
327
409
  case "remove":
328
410
  await handleUninstall();
@@ -1,19 +1,82 @@
1
- import { existsSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { Database } from "bun:sqlite";
4
4
  import { CODEX_HOME } from "./codex-paths";
5
+ import { atomicWriteFile, getConfigDir } from "./config";
5
6
 
6
7
  const STATE_DB_PATH = join(CODEX_HOME, "state_5.sqlite");
8
+ const HISTORY_BACKUP_PATH = join(getConfigDir(), "codex-history-backup.json");
7
9
  const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
8
10
 
9
11
  type CodexHistoryProvider = "openai" | "opencodex";
10
12
 
13
+ export interface CodexHistorySyncResult {
14
+ rows: number;
15
+ files: number;
16
+ ejectedRows?: number;
17
+ }
18
+
11
19
  interface ThreadRow {
12
20
  id: string;
13
21
  rollout_path: string;
22
+ model_provider: string;
23
+ source: string;
24
+ has_user_event: number;
25
+ }
26
+
27
+ interface BackupEntry {
28
+ id: string;
29
+ rolloutPath: string;
30
+ modelProvider: string;
31
+ source: string;
32
+ hasUserEvent: number;
33
+ }
34
+
35
+ interface BackupManifest {
36
+ version: 1;
37
+ entries: Record<string, BackupEntry>;
38
+ }
39
+
40
+ interface NativeRestoreTarget {
41
+ modelProvider: string;
42
+ source: string;
43
+ hasUserEvent: number;
44
+ }
45
+
46
+ function readBackup(path: string): BackupManifest {
47
+ if (!existsSync(path)) return { version: 1, entries: {} };
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<BackupManifest>;
50
+ if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
51
+ return { version: 1, entries: {} };
52
+ }
53
+ return { version: 1, entries: parsed.entries };
54
+ } catch {
55
+ return { version: 1, entries: {} };
56
+ }
57
+ }
58
+
59
+ function writeBackup(path: string, manifest: BackupManifest): void {
60
+ if (Object.keys(manifest.entries).length === 0) {
61
+ if (existsSync(path)) unlinkSync(path);
62
+ return;
63
+ }
64
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
65
+ atomicWriteFile(path, JSON.stringify(manifest, null, 2) + "\n");
66
+ }
67
+
68
+ function rememberOriginal(manifest: BackupManifest, row: ThreadRow): void {
69
+ if (manifest.entries[row.id]) return;
70
+ manifest.entries[row.id] = {
71
+ id: row.id,
72
+ rolloutPath: row.rollout_path,
73
+ modelProvider: row.model_provider,
74
+ source: row.source,
75
+ hasUserEvent: Number(row.has_user_event) || 0,
76
+ };
14
77
  }
15
78
 
16
- function updateSessionMetaProvider(path: string, provider: CodexHistoryProvider): boolean {
79
+ function updateSessionMeta(path: string, patch: { provider?: string; source?: string }): boolean {
17
80
  if (!path || !existsSync(path)) return false;
18
81
  const stat = statSync(path);
19
82
  const raw = readFileSync(path, "utf8");
@@ -29,57 +92,201 @@ function updateSessionMetaProvider(path: string, provider: CodexHistoryProvider)
29
92
  }
30
93
 
31
94
  if (!parsed || typeof parsed !== "object") return false;
32
- const record = parsed as { type?: unknown; payload?: { model_provider?: unknown } };
95
+ const record = parsed as { type?: unknown; payload?: { model_provider?: unknown; source?: unknown } };
33
96
  if (record.type !== "session_meta" || !record.payload || typeof record.payload !== "object") return false;
34
- if (record.payload.model_provider === provider) return false;
35
97
 
36
- record.payload.model_provider = provider;
98
+ let changed = false;
99
+ if (patch.provider !== undefined && record.payload.model_provider !== patch.provider) {
100
+ record.payload.model_provider = patch.provider;
101
+ changed = true;
102
+ }
103
+ if (patch.source !== undefined && record.payload.source !== patch.source) {
104
+ record.payload.source = patch.source;
105
+ changed = true;
106
+ }
107
+ if (!changed) return false;
108
+
37
109
  writeFileSync(path, `${JSON.stringify(record)}${rest}`, "utf8");
38
110
  utimesSync(path, stat.atime, stat.mtime);
39
111
  return true;
40
112
  }
41
113
 
42
- export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
114
+ function toNativeRestoreTarget(entry: BackupEntry): NativeRestoreTarget {
115
+ if (entry.modelProvider !== "opencodex") {
116
+ return {
117
+ modelProvider: entry.modelProvider,
118
+ source: entry.source,
119
+ hasUserEvent: entry.hasUserEvent,
120
+ };
121
+ }
122
+ return {
123
+ modelProvider: "openai",
124
+ source: entry.source === "exec" ? "cli" : entry.source,
125
+ hasUserEvent: 1,
126
+ };
127
+ }
128
+
129
+ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: number } {
130
+ const rows = db
131
+ .query<ThreadRow, []>(`
132
+ SELECT id, rollout_path, model_provider, source, has_user_event
133
+ FROM threads
134
+ WHERE model_provider = 'opencodex'
135
+ AND trim(coalesce(first_user_message, '')) != ''
136
+ `)
137
+ .all();
138
+
139
+ let files = 0;
140
+ for (const row of rows) {
141
+ try {
142
+ if (updateSessionMeta(row.rollout_path, {
143
+ provider: "openai",
144
+ source: row.source === "exec" ? "cli" : undefined,
145
+ })) files++;
146
+ } catch {
147
+ /* native restore should continue even if an old rollout is missing */
148
+ }
149
+ }
150
+
151
+ const restore = db.transaction(() => {
152
+ const update = db.query(`
153
+ UPDATE threads
154
+ SET model_provider = 'openai',
155
+ source = CASE WHEN source = 'exec' THEN 'cli' ELSE source END,
156
+ has_user_event = 1
157
+ WHERE id = ?
158
+ `);
159
+ for (const row of rows) update.run(row.id);
160
+ });
161
+ restore();
162
+ return { rows: rows.length, files };
163
+ }
164
+
165
+ export function syncCodexHistoryProvider(provider: CodexHistoryProvider, stateDbPath = STATE_DB_PATH, backupPath = HISTORY_BACKUP_PATH): CodexHistorySyncResult {
43
166
  if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
44
- const from = provider === "opencodex" ? "openai" : "opencodex";
167
+ if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
168
+
45
169
  const db = new Database(stateDbPath);
46
170
  try {
47
171
  const placeholders = RESUMABLE_SOURCES.map(() => "?").join(",");
48
- const rows = db
172
+ const openaiRows = db
49
173
  .query<ThreadRow, string[]>(`
50
- SELECT id, rollout_path
174
+ SELECT id, rollout_path, model_provider, source, has_user_event
51
175
  FROM threads
52
- WHERE model_provider = ?
176
+ WHERE model_provider = 'openai'
53
177
  AND source IN (${placeholders})
54
178
  `)
55
- .all(from, ...RESUMABLE_SOURCES);
179
+ .all(...RESUMABLE_SOURCES);
180
+ const execRows = db
181
+ .query<ThreadRow, []>(`
182
+ SELECT id, rollout_path, model_provider, source, has_user_event
183
+ FROM threads
184
+ WHERE model_provider = 'opencodex'
185
+ AND source = 'exec'
186
+ AND trim(coalesce(first_user_message, '')) != ''
187
+ `)
188
+ .all();
189
+
190
+ const manifest = readBackup(backupPath);
191
+ for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row);
192
+ writeBackup(backupPath, manifest);
56
193
 
57
194
  let files = 0;
58
- for (const row of rows) {
195
+ for (const row of openaiRows) {
59
196
  try {
60
- if (updateSessionMetaProvider(row.rollout_path, provider)) files++;
197
+ if (updateSessionMeta(row.rollout_path, { provider: "opencodex" })) files++;
198
+ } catch {
199
+ /* best-effort; keep DB migration moving even if one old rollout is malformed */
200
+ }
201
+ }
202
+ for (const row of execRows) {
203
+ try {
204
+ if (updateSessionMeta(row.rollout_path, { source: "cli" })) files++;
61
205
  } catch {
62
206
  /* best-effort; keep DB migration moving even if one old rollout is malformed */
63
207
  }
64
208
  }
65
209
 
66
210
  const update = db.transaction(() => {
67
- db.query(`
211
+ const markUserEvent = db.query(`
68
212
  UPDATE threads
69
213
  SET has_user_event = 1
70
- WHERE source IN (${placeholders})
214
+ WHERE id = ?
71
215
  AND trim(coalesce(first_user_message, '')) != ''
72
- `).run(...RESUMABLE_SOURCES);
216
+ `);
217
+ for (const row of [...openaiRows, ...execRows]) markUserEvent.run(row.id);
73
218
  db.query(`
74
219
  UPDATE threads
75
- SET model_provider = ?
76
- WHERE model_provider = ?
220
+ SET model_provider = 'opencodex'
221
+ WHERE model_provider = 'openai'
77
222
  AND source IN (${placeholders})
78
- `).run(provider, from, ...RESUMABLE_SOURCES);
223
+ `).run(...RESUMABLE_SOURCES);
224
+ db.query(`
225
+ UPDATE threads
226
+ SET source = 'cli'
227
+ WHERE model_provider = 'opencodex'
228
+ AND source = 'exec'
229
+ AND trim(coalesce(first_user_message, '')) != ''
230
+ `).run();
79
231
  });
80
232
  update();
81
233
 
82
- return { rows: rows.length, files };
234
+ return { rows: openaiRows.length + execRows.length, files };
235
+ } finally {
236
+ db.close();
237
+ }
238
+ }
239
+
240
+ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult {
241
+ const manifest = readBackup(backupPath);
242
+ const entries = Object.values(manifest.entries);
243
+
244
+ const db = new Database(stateDbPath);
245
+ try {
246
+ if (entries.length === 0) {
247
+ const ejected = ejectRemainingOpencodexHistory(db);
248
+ return ejected.rows > 0 ? { rows: 0, files: ejected.files, ejectedRows: ejected.rows } : { rows: 0, files: 0 };
249
+ }
250
+
251
+ let files = 0;
252
+ for (const entry of entries) {
253
+ const target = toNativeRestoreTarget(entry);
254
+ try {
255
+ if (updateSessionMeta(entry.rolloutPath, { provider: target.modelProvider, source: target.source })) files++;
256
+ } catch {
257
+ /* best-effort; keep DB restore moving even if one rollout disappeared */
258
+ }
259
+ }
260
+
261
+ const restore = db.transaction(() => {
262
+ const update = db.query(`
263
+ UPDATE threads
264
+ SET model_provider = ?,
265
+ source = ?,
266
+ has_user_event = ?
267
+ WHERE id = ?
268
+ `);
269
+ for (const entry of entries) {
270
+ const target = toNativeRestoreTarget(entry);
271
+ update.run(target.modelProvider, target.source, target.hasUserEvent, entry.id);
272
+ }
273
+ });
274
+ restore();
275
+ writeBackup(backupPath, { version: 1, entries: {} });
276
+ const ejected = ejectRemainingOpencodexHistory(db);
277
+ return ejected.rows > 0
278
+ ? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows }
279
+ : { rows: entries.length, files };
280
+ } finally {
281
+ db.close();
282
+ }
283
+ }
284
+
285
+ export function restoreLegacyOpenaiHistory(stateDbPath = STATE_DB_PATH): { rows: number; files: number } {
286
+ if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
287
+ const db = new Database(stateDbPath);
288
+ try {
289
+ return ejectRemainingOpencodexHistory(db);
83
290
  } finally {
84
291
  db.close();
85
292
  }
@@ -71,6 +71,21 @@ function stripRootContextWindowOverrides(content: string): string {
71
71
  .join("\n");
72
72
  }
73
73
 
74
+ function stripRootRoutedModel(content: string): string {
75
+ const lines = content.split("\n");
76
+ const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
77
+ return lines
78
+ .filter((line, i) => {
79
+ const isRoot = firstTable === -1 || i < firstTable;
80
+ if (!isRoot) return true;
81
+ const m = line.match(/^\s*model\s*=\s*("(?:\\.|[^"])*"|'[^']*')\s*$/);
82
+ if (!m) return true;
83
+ const model = parseTomlString(m[1]);
84
+ return !model?.includes("/");
85
+ })
86
+ .join("\n");
87
+ }
88
+
74
89
  /**
75
90
  * Insert `model_provider = "opencodex"` at the document ROOT — immediately before the first table
76
91
  * header (TOML root keys must precede all tables). If there are no tables, append it to the root body.
@@ -229,14 +244,16 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
229
244
 
230
245
  writeFileSync(CODEX_CONFIG_PATH, content, "utf-8");
231
246
  writeFileSync(CODEX_PROFILE_PATH, buildProfileFile(port, catalogPath), "utf-8");
232
- const history = syncCodexHistoryProvider("opencodex");
247
+ const history = config?.syncResumeHistory === true
248
+ ? syncCodexHistoryProvider("opencodex")
249
+ : { rows: 0, files: 0 };
233
250
 
234
251
  const catalogMessage = catalogPath
235
252
  ? ` Codex model catalog: ${catalogPath}\n`
236
253
  : ` Codex model catalog not injected because no opencodex catalog file exists yet.\n`;
237
- const historyMessage = history.rows > 0
238
- ? ` Codex resume history: ${history.rows} thread(s) mapped to opencodex.\n`
239
- : "";
254
+ const historyMessage = config?.syncResumeHistory === true
255
+ ? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
256
+ : ` Codex resume history: left unchanged. Existing OpenAI and opencodex exec project chats may be hidden while opencodex is active; set syncResumeHistory=true to enable the reversible compatibility remap.\n`;
240
257
  return {
241
258
  success: true,
242
259
  message: `Injected opencodex as default provider into Codex config.\n` +
@@ -283,6 +300,7 @@ export function stripOpencodexConfig(content: string): string {
283
300
  // must match the detection regex above, or a detected line could survive un-removed.
284
301
  out = out.split("\n").filter(l => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l)).join("\n");
285
302
  out = stripRootContextWindowOverrides(out);
303
+ out = stripRootRoutedModel(out);
286
304
  out = stripOpencodexCatalogPath(out);
287
305
  return out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
288
306
  }
@@ -299,7 +317,7 @@ export function removeCodexConfig(): { success: boolean; message: string } {
299
317
  const had = hasOpencodexRouting(content);
300
318
  if (had) {
301
319
  atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
302
- } else if (stripRootContextWindowOverrides(content) !== content) {
320
+ } else if (stripOpencodexConfig(content) !== content) {
303
321
  atomicWriteFile(CODEX_CONFIG_PATH, stripOpencodexConfig(content));
304
322
  }
305
323
  if (existsSync(CODEX_PROFILE_PATH)) unlinkSync(CODEX_PROFILE_PATH);
@@ -321,7 +339,11 @@ export function restoreNativeCodex(): { success: boolean; message: string } {
321
339
  const msg = cat.removed > 0
322
340
  ? `${cfg.message} Catalog restored to ${cat.kept} native model(s) (dropped ${cat.removed} proxy-routed).`
323
341
  : cfg.message;
324
- const historyMsg = history.rows > 0 ? ` Resume history restored to openai (${history.rows} thread(s)).` : "";
342
+ const historyMsg = history.rows > 0
343
+ ? ` Resume history restored from opencodex backup (${history.rows} thread(s)).`
344
+ : history.ejectedRows
345
+ ? ` ${history.ejectedRows} opencodex history thread(s) were ejected to openai so native Codex can resume them.`
346
+ : "";
325
347
  return { success: cfg.success, message: `${msg}${historyMsg}` };
326
348
  }
327
349
 
@@ -31,6 +31,7 @@ export interface KeyLoginProvider {
31
31
  noPenaltyModels?: string[];
32
32
  autoToolChoiceOnlyModels?: string[];
33
33
  preserveReasoningContentModels?: string[];
34
+ escapeBuiltinToolNames?: boolean;
34
35
  }
35
36
 
36
37
  export const KEY_LOGIN_PROVIDERS: Record<string, KeyLoginProvider> = deriveKeyLoginMap();
@@ -57,6 +58,7 @@ export function enrichProviderFromCatalog(name: string, prov: OcxProviderConfig)
57
58
  if (!prov.noPenaltyModels && e.noPenaltyModels) prov.noPenaltyModels = [...e.noPenaltyModels];
58
59
  if (!prov.autoToolChoiceOnlyModels && e.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...e.autoToolChoiceOnlyModels];
59
60
  if (!prov.preserveReasoningContentModels && e.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...e.preserveReasoningContentModels];
61
+ if (prov.escapeBuiltinToolNames === undefined && e.escapeBuiltinToolNames !== undefined) prov.escapeBuiltinToolNames = e.escapeBuiltinToolNames;
60
62
  }
61
63
 
62
64
 
@@ -76,10 +78,31 @@ export function listKeyLoginProviders(): Array<{ id: string } & KeyLoginProvider
76
78
  return Object.entries(KEY_LOGIN_PROVIDERS).map(([id, p]) => ({ id, ...p }));
77
79
  }
78
80
 
79
- /** Best-effort key validation: GET {baseUrl}/models with the key. Returns true/false/unknown. */
80
- export async function validateApiKey(baseUrl: string, key: string): Promise<boolean | "unknown"> {
81
+ /** Best-effort key validation. Returns true/false/unknown; never persists the key itself. */
82
+ export async function validateApiKey(provider: KeyLoginProvider, key: string): Promise<boolean | "unknown"> {
81
83
  try {
82
- const res = await fetch(`${baseUrl}/models`, {
84
+ if (provider.adapter === "anthropic") {
85
+ const base = provider.baseUrl.replace(/\/v1\/?$/, "");
86
+ const res = await fetch(`${base}/v1/messages`, {
87
+ method: "POST",
88
+ headers: {
89
+ "Content-Type": "application/json",
90
+ "anthropic-version": "2023-06-01",
91
+ "x-api-key": key,
92
+ },
93
+ body: JSON.stringify({
94
+ model: provider.defaultModel ?? "claude-sonnet-4-6",
95
+ max_tokens: 1,
96
+ messages: [{ role: "user", content: "ping" }],
97
+ }),
98
+ signal: AbortSignal.timeout(8000),
99
+ });
100
+ if (res.ok) return true;
101
+ if (res.status === 401 || res.status === 403) return false;
102
+ return "unknown";
103
+ }
104
+
105
+ const res = await fetch(`${provider.baseUrl}/models`, {
83
106
  headers: { Authorization: `Bearer ${key}` },
84
107
  signal: AbortSignal.timeout(8000),
85
108
  });