diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 8e81f0034..fd9c1c92a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -66,9 +66,10 @@ env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" } # supports_websockets = true # only when config.websockets is true ``` -In both modes opencodex writes `$CODEX_HOME/opencodex.config.toml` as a reference/fallback config. -On loopback it contains the root keys you can merge manually if automatic injection was removed; -on non-loopback it contains the dedicated provider form. +When OpenCodex owns routing, both modes write `$CODEX_HOME/opencodex.config.toml` as a +reference/fallback config. On loopback it contains the root keys you can merge manually if automatic +injection was removed; on non-loopback it contains the dedicated provider form. External-provider +mode leaves this profile untouched. :::caution Root keys such as `openai_base_url`, `model_provider`, and `model_catalog_json` **must** sit before the @@ -153,6 +154,21 @@ reapplies the configured name. Genuine upstream native names (e.g. `gpt-5.6-sol` "GPT-5.6-Sol") come from the pinned upstream snapshot and are never overridden by a custom display name. +### External provider managers + +If `config.toml` already selects a provider other than `openai` or `opencodex`, OpenCodex leaves the +file unchanged and skips profile writes, catalog/cache refresh, and both immediate and background +Codex history migration. Tools that manage a custom provider often tag existing sessions with that +provider id; replacing the active id can make those intact sessions disappear from Codex's history +view. The same protection applies to an external provider selected by a legacy root profile. + +Keep one tool as the owner of Codex provider configuration. To use OpenCodex behind an existing +provider manager, point that provider at `http://127.0.0.1:10100/v1` with Responses passthrough +(`wire_api = "responses"` in Codex TOML), not Chat Completions translation. When proxy API auth is +enabled, also pass `x-opencodex-api-key` from `OPENCODEX_API_AUTH_TOKEN`, matching the non-loopback +provider form above. To let OpenCodex inject routing directly, first switch Codex back to its +built-in `openai` provider and remove any user-owned root `openai_base_url`, then rerun `ocx start`. + ### Catalog troubleshooting If a model is missing from Codex, or the catalog order/visibility looks wrong, check in order: diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 1d2983fbc..f8d2b8aa2 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -65,8 +65,10 @@ env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" } # supports_websockets = true # config.websockets が true のときのみ ``` -両モードとも `$CODEX_HOME/opencodex.config.toml` を参考用フォールバック設定として書き出します。ループバックモードでは -自動注入が漏れたときに直接統合できるルートキーが、非ループバックモードでは専用プロバイダー設定が含まれます。 +OpenCodex がルーティングを管理する場合、両モードとも `$CODEX_HOME/opencodex.config.toml` を +参考用フォールバック設定として書き出します。ループバックモードでは自動注入が漏れたときに直接統合できる +ルートキーが、非ループバックモードでは専用プロバイダー設定が含まれます。外部プロバイダーモードでは +このプロファイルを変更しません。 :::caution `openai_base_url`、`model_provider`、`model_catalog_json` のようなルートキーは最初の `[table]` ヘッダーより diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 6b64eae07..3ad40ae10 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -65,8 +65,9 @@ env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" } # supports_websockets = true # config.websockets가 true일 때만 ``` -두 모드 모두 `$CODEX_HOME/opencodex.config.toml`을 참고용 폴백 설정으로 작성합니다. loopback 모드에서는 -자동 주입이 빠졌을 때 직접 합칠 수 있는 루트 키가, non-loopback 모드에서는 전용 프로바이더 설정이 담깁니다. +OpenCodex가 라우팅을 소유할 때 두 모드 모두 `$CODEX_HOME/opencodex.config.toml`을 참고용 폴백 설정으로 +작성합니다. loopback 모드에서는 자동 주입이 빠졌을 때 직접 합칠 수 있는 루트 키가, non-loopback 모드에서는 +전용 프로바이더 설정이 담깁니다. 외부 프로바이더 모드에서는 이 프로필을 변경하지 않습니다. :::caution `openai_base_url`, `model_provider`, `model_catalog_json` 같은 루트 키는 첫 번째 `[table]` 헤더보다 diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index 1b1325b60..03306483c 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -71,9 +71,11 @@ env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" } # supports_websockets = true # only when config.websockets is true ``` -В обоих режимах opencodex записывает `$CODEX_HOME/opencodex.config.toml` как справочную и -резервную конфигурацию. На loopback в ней лежат корневые ключи, которые можно объединить -вручную, если автоматическое внедрение было удалено; вне loopback — форма с выделенным провайдером. +Когда маршрутизацией управляет OpenCodex, в обоих режимах он записывает +`$CODEX_HOME/opencodex.config.toml` как справочную и резервную конфигурацию. На loopback в ней +лежат корневые ключи, которые можно объединить вручную, если автоматическое внедрение было удалено; +вне loopback — форма с выделенным провайдером. В режиме внешнего провайдера этот профиль остается +без изменений. :::caution Корневые ключи, такие как `openai_base_url`, `model_provider` и `model_catalog_json`, **обязаны** diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 0ae87eddf..2fa5f1bfe 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -64,8 +64,9 @@ env_http_headers = { "x-opencodex-api-key" = "OPENCODEX_API_AUTH_TOKEN" } # supports_websockets = true # 仅当 config.websockets 为 true ``` -两种模式都会把 `$CODEX_HOME/opencodex.config.toml` 写成参考/回退配置。loopback 模式下,其中包含 -自动注入被移除时可手动合并的根级键;non-loopback 模式下,其中包含专用提供商配置。 +当 OpenCodex 管理路由时,两种模式都会把 `$CODEX_HOME/opencodex.config.toml` 写成参考/回退配置。 +loopback 模式下,其中包含自动注入被移除时可手动合并的根级键;non-loopback 模式下,其中包含 +专用提供商配置。外部提供商模式不会修改此配置文件。 :::caution `openai_base_url`、`model_provider`、`model_catalog_json` 等根级键**必须**位于第一个 `[table]` diff --git a/src/cli/index.ts b/src/cli/index.ts index bf1b76158..d16e2eb19 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { spawn } from "node:child_process"; import { rmSync } from "node:fs"; -import { restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject"; +import { currentExternalCodexModelProvider, restoreNativeCodex, shouldInjectApiAuthHeader } from "../codex/inject"; import { restoreLegacyOpenaiHistory } from "../codex/history-provider"; import { writeJournal, reconcileJournal } from "../codex/journal"; import { @@ -131,7 +131,7 @@ async function handleStart(options: { block?: boolean } = {}) { const serviceToken = loadServiceTokenFromFile(process.env); if (serviceToken) process.env.OPENCODEX_API_AUTH_TOKEN = serviceToken; const requestedPort = parsePortOption(); - reconcileJournal(); + if (!currentExternalCodexModelProvider()) reconcileJournal(); const existingPid = readPid(); if (existingPid) { const live = await findLiveProxy(); @@ -179,7 +179,7 @@ async function handleStart(options: { block?: boolean } = {}) { const config = loadConfig(); writeRuntimePort({ pid: process.pid, port, hostname: config.hostname }); - writeJournal(); + if (!currentExternalCodexModelProvider()) writeJournal(); // Background proactive token refresh. No-op unless config.tokenGuardian.enabled; timer is unref'd // so it never keeps the process alive on its own. Stopped in syncCleanup so no refresh fires mid-drain. @@ -187,9 +187,7 @@ async function handleStart(options: { block?: boolean } = {}) { // Design B upgrade path: keep retrying the one-time opencodex→openai history migration in the // background — the first `ocx start` after an update usually races the Codex app's DB lock. // Loopback-only (legacy mode still forward-tags) and respects syncResumeHistory opt-out. - const historyGuardian = !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false - ? startHistoryMigrationGuardian() - : undefined; + let historyGuardian: ReturnType | undefined; let cleaned = false; const syncCleanup = () => { @@ -200,7 +198,9 @@ async function handleStart(options: { block?: boolean } = {}) { try { revertSystemEnv(); } catch { /* best-effort */ } removePid(process.pid); removeRuntimePort(process.pid); - if (!process.env.OCX_SERVICE) { try { restoreNativeCodex(); } catch { /* best-effort restore */ } } + if (!process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) { + try { restoreNativeCodex(); } catch { /* best-effort restore */ } + } }; let shuttingDown = false; @@ -246,6 +246,9 @@ async function handleStart(options: { block?: boolean } = {}) { await maybeShowStarPrompt(); // once-only [Y/n] GitHub-star prompt on first interactive start await syncModelsToCodex(port).catch(() => {}); + if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) { + historyGuardian = startHistoryMigrationGuardian(); + } // Build Desktop 3P alias registry so inbound claude-opus-4-8-{code} aliases (and legacy claude-opus-4-{code}) decode correctly. try { const { fetchAllModels } = await import("../server/management-api"); @@ -263,7 +266,7 @@ async function handleStart(options: { block?: boolean } = {}) { } async function handleEnsure() { - reconcileJournal(); + if (!currentExternalCodexModelProvider()) reconcileJournal(); const config = loadConfig(); if (!codexAutoStartEnabled(config)) { console.log("Codex autostart is disabled."); diff --git a/src/codex/inject.ts b/src/codex/inject.ts index 07166a386..a849c40f3 100644 --- a/src/codex/inject.ts +++ b/src/codex/inject.ts @@ -1,13 +1,24 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config"; -import { markJournalInjectedState, restoreJournalState, writeJournal } from "./journal"; +import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal"; import { restoreCodexCatalog } from "./catalog"; import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider"; import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths"; +import { resolveEffectiveProjectModelProvider } from "./project-config-warnings"; import type { OcxConfig } from "../types"; const OCX_SECTION_MARKER = "# Auto-injected by opencodex"; +export function externalCodexModelProvider(content: string): string | null { + const provider = resolveEffectiveProjectModelProvider(content).provider; + return provider && provider !== "openai" && provider !== "opencodex" ? provider : null; +} + +export function currentExternalCodexModelProvider(): string | null { + if (!existsSync(CODEX_CONFIG_PATH)) return null; + return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8")); +} + /** * Detect the file's dominant line ending. Every transform in this module is LF-pure * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are @@ -365,8 +376,23 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` }; } - writeJournal(); const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8"); + const activeProvider = externalCodexModelProvider(rawContent); + if (activeProvider) { + // A launcher may have journaled before the provider manager took ownership. Never let shutdown + // replay that stale snapshot over externally managed config. + removeJournal(); + return { + success: true, + message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` + + ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` + + ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` + + `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` + + ` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`, + }; + } + + writeJournal(); // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write. const eol = dominantEol(rawContent); let content = applyEol(rawContent, "\n"); @@ -543,6 +569,11 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}): * handler, and `ocx restore`. Idempotent + atomic. */ export function restoreNativeCodex(): { success: boolean; message: string } { + const activeProvider = currentExternalCodexModelProvider(); + if (activeProvider) { + removeJournal(); + return { success: true, message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.` }; + } const journal = restoreJournalState(); const cfg = journal.configRestored ? { success: true, message: "Codex config restored from opencodex journal." } diff --git a/src/codex/sync.ts b/src/codex/sync.ts index a144aab2e..f704bfc39 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -1,4 +1,4 @@ -import { injectCodexConfig } from "./inject"; +import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject"; import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings"; import { refreshCodexModelCatalog } from "./refresh"; import { applyProxyEnv, loadConfig } from "../config"; @@ -19,6 +19,7 @@ export interface CodexSyncResult { interface CodexSyncDeps { refreshCodexModelCatalog: typeof refreshCodexModelCatalog; injectCodexConfig: typeof injectCodexConfig; + currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider; } const defaultDeps: CodexSyncDeps = { @@ -32,8 +33,22 @@ export async function syncModelsToCodex( log: Pick | null = console, deps: CodexSyncDeps = defaultDeps, ): Promise { - applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process const p = port ?? config.port ?? 10100; + const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)(); + if (externalProvider) { + const result = await deps.injectCodexConfig(p, config, {}); + log?.log(result.message); + return { + ok: result.success, + added: 0, + catalogPath: null, + catalogExists: false, + cacheSynced: false, + message: result.message, + }; + } + + applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process let added = 0; let catalogPath: string | null = null; let catalogPathForInjection: string | null | undefined; diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 1c00b3855..264e9a33e 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -37,13 +37,19 @@ requires_openai_auth = true Root TOML keys must be written before the first `[table]`. Re-injection strips stale opencodex blocks, stale root context-window overrides, and stale opencodex catalog paths before rewriting. +If the root config selects a provider other than `openai` or `opencodex`, injection must leave the +config byte-for-byte unchanged and skip profile creation/updates and history migration. External +provider managers own that routing configuration, and replacing their provider id can hide +otherwise intact Codex sessions. This ownership check must run before catalog/cache refresh, +journal creation, and the background history migration guardian. + `supports_websockets = true` is appended only when `websocketsEnabled(config)` returns true. ## Profile and fast tier -opencodex also writes `$CODEX_HOME/opencodex.config.toml` as an explicit profile target. Codex config -uses `service_tier = "fast"` and `[features].fast_mode = true`; catalog/request tier metadata may use -`priority`. Do not collapse these spellings into one value. +When opencodex owns routing, it also writes `$CODEX_HOME/opencodex.config.toml` as an explicit profile +target. Codex config uses `service_tier = "fast"` and `[features].fast_mode = true`; +catalog/request tier metadata may use `priority`. Do not collapse these spellings into one value. ## Provider output defaults diff --git a/tests/codex-inject-integration.test.ts b/tests/codex-inject-integration.test.ts index 84b4575fd..51a2da347 100644 --- a/tests/codex-inject-integration.test.ts +++ b/tests/codex-inject-integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Database } from "bun:sqlite"; const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url))); @@ -24,6 +25,19 @@ function runInject(codexHome: string, ocxHome: string, configJson = "{}"): { std return { stdout: result.stdout?.trim() ?? "", status: result.status ?? 1 }; } +function runRestore(codexHome: string, ocxHome: string): { stdout: string; status: number } { + const script = ` + const { restoreNativeCodex } = require("./src/codex/inject"); + console.log(JSON.stringify(restoreNativeCodex())); + `; + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoRoot, + env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, + encoding: "utf8", + }); + return { stdout: result.stdout?.trim() ?? "", status: result.status ?? 1 }; +} + describe("injectCodexConfig integration (Design B)", () => { let codexHome: string; let ocxHome: string; @@ -101,6 +115,141 @@ describe("injectCodexConfig integration (Design B)", () => { expect(config).not.toContain("# Auto-injected by opencodex\nopenai_base_url"); }); + test("external model provider stays byte-for-byte unchanged so its session history remains visible", () => { + const original = [ + 'model_provider = "custom"', + 'model = "third-party-model"', + "", + "[model_providers.custom]", + 'name = "Provider Manager"', + 'base_url = "https://gateway.example/v1"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const sessionsDir = join(codexHome, "sessions"); + mkdirSync(sessionsDir); + const profilePath = join(codexHome, "opencodex.config.toml"); + const profile = "sentinel profile\n"; + writeFileSync(profilePath, profile, "utf8"); + const rolloutPath = join(sessionsDir, "rollout-custom.jsonl"); + const rollout = JSON.stringify({ + type: "session_meta", + payload: { id: "thread-custom", model_provider: "custom", source: "cli", cwd: codexHome }, + }) + "\n"; + writeFileSync(rolloutPath, rollout, "utf8"); + const dbPath = join(codexHome, "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 + )`); + db.run(`INSERT INTO threads VALUES ('thread-custom', ?, 'custom', 'cli', 'hello', 1)`, rolloutPath); + db.close(); + const dbBefore = readFileSync(dbPath); + const journalPath = join(codexHome, "opencodex-journal.json"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), + originalProfile: null, + pid: process.pid, + timestamp: new Date().toISOString(), + }), "utf8"); + + const r = runInject(codexHome, ocxHome); + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + expect(result.message).toContain("routing NOT injected"); + expect(result.message).toContain('external model_provider "custom"'); + expect(result.message).toContain("http://127.0.0.1:10100/v1"); + expect(result.message).toContain("Responses passthrough"); + + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + expect(readFileSync(profilePath, "utf8")).toBe(profile); + expect(readFileSync(dbPath).equals(dbBefore)).toBe(true); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + expect(existsSync(journalPath)).toBe(false); + }); + + test("restoreNativeCodex removes a stale journal without changing external provider state", () => { + const configPath = join(codexHome, "config.toml"); + const config = 'model_provider = "custom"\nmodel = "third-party-model"\n'; + writeFileSync(configPath, config, "utf8"); + const profilePath = join(codexHome, "opencodex.config.toml"); + const profile = 'model_provider = "custom"\n'; + writeFileSync(profilePath, profile, "utf8"); + + const sessionsDir = join(codexHome, "sessions"); + mkdirSync(sessionsDir); + const rolloutPath = join(sessionsDir, "rollout-custom.jsonl"); + const rollout = JSON.stringify({ + type: "session_meta", + payload: { id: "thread-custom", model_provider: "custom", source: "cli", cwd: codexHome }, + }) + "\n"; + writeFileSync(rolloutPath, rollout, "utf8"); + const dbPath = join(codexHome, "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 + )`); + db.run(`INSERT INTO threads VALUES ('thread-custom', ?, 'custom', 'cli', 'hello', 1)`, rolloutPath); + db.close(); + const dbBefore = readFileSync(dbPath); + + const journalPath = join(codexHome, "opencodex-journal.json"); + writeFileSync(journalPath, JSON.stringify({ + version: 1, + originalConfig: Buffer.from('model_provider = "openai"\n').toString("base64"), + originalProfile: null, + pid: process.pid, + timestamp: new Date().toISOString(), + }), "utf8"); + + const r = runRestore(codexHome, ocxHome); + expect(r.status).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.success).toBe(true); + expect(result.message).toContain('External Codex provider "custom" preserved'); + expect(readFileSync(configPath, "utf8")).toBe(config); + expect(readFileSync(profilePath, "utf8")).toBe(profile); + expect(readFileSync(dbPath).equals(dbBefore)).toBe(true); + expect(readFileSync(rolloutPath, "utf8")).toBe(rollout); + expect(existsSync(journalPath)).toBe(false); + }); + + test("provider selected through a legacy root profile is also preserved", () => { + const original = [ + 'profile = "work"', + 'model_provider = "openai"', + "", + "[profiles.work]", + 'model_provider = "custom"', + "", + ].join("\n"); + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const r = runInject(codexHome, ocxHome); + expect(r.status).toBe(0); + expect(JSON.parse(r.stdout).message).toContain('external model_provider "custom"'); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + }); + + test("external provider guidance includes the admission header for non-loopback binds", () => { + const original = 'model_provider = "custom"\n'; + writeFileSync(join(codexHome, "config.toml"), original, "utf8"); + + const r = runInject(codexHome, ocxHome, JSON.stringify({ hostname: "192.168.1.20" })); + expect(r.status).toBe(0); + const message = JSON.parse(r.stdout).message; + expect(message).toContain("http://192.168.1.20:10100/v1"); + expect(message).toContain("x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN"); + expect(readFileSync(join(codexHome, "config.toml"), "utf8")).toBe(original); + }); + test("non-loopback hostname still uses the legacy provider-table injection", () => { writeFileSync(join(codexHome, "config.toml"), 'model = "gpt-5.5"\n', "utf8"); diff --git a/tests/codex-sync-api.test.ts b/tests/codex-sync-api.test.ts index 4359fbe32..d7f6d4e3a 100644 --- a/tests/codex-sync-api.test.ts +++ b/tests/codex-sync-api.test.ts @@ -44,6 +44,7 @@ describe("GUI/CLI Codex sync backend", () => { injectedCatalogPath = options.catalogPath; return { success: true, message: "injected" }; }, + currentExternalCodexModelProvider: () => null, }); expect(injectedPort).toBe(12345); @@ -69,6 +70,7 @@ describe("GUI/CLI Codex sync backend", () => { injectedCatalogPath = options.catalogPath; return { success: true, message: "injected fallback" }; }, + currentExternalCodexModelProvider: () => null, }); expect(injectedCatalogPath).toBeUndefined(); @@ -76,4 +78,31 @@ describe("GUI/CLI Codex sync backend", () => { expect(result.catalogPath).toBeNull(); expect(result.warning).toContain("catalog boom"); }); + + test("skips catalog refresh before preserving an external provider", async () => { + let refreshed = false; + let injectedCatalogPath: string | null | undefined = "unset"; + const result = await syncModelsToCodex(10100, config, null, { + refreshCodexModelCatalog: async () => { + refreshed = true; + throw new Error("must not refresh"); + }, + injectCodexConfig: async (_port, _config, options) => { + injectedCatalogPath = options.catalogPath; + return { success: true, message: "external provider preserved" }; + }, + currentExternalCodexModelProvider: () => "custom", + }); + + expect(refreshed).toBe(false); + expect(injectedCatalogPath).toBeUndefined(); + expect(result).toEqual({ + ok: true, + added: 0, + catalogPath: null, + catalogExists: false, + cacheSynced: false, + message: "external provider preserved", + }); + }); });