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
22 changes: 19 additions & 3 deletions docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions docs-site/src/content/docs/ja/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` ヘッダーより
Expand Down
5 changes: 3 additions & 2 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]` 헤더보다
Expand Down
8 changes: 5 additions & 3 deletions docs-site/src/content/docs/ru/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, **обязаны**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down
19 changes: 11 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -179,17 +179,15 @@ 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.
const guardian = startTokenGuardian();
// 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<typeof startHistoryMigrationGuardian> | undefined;

let cleaned = false;
const syncCleanup = () => {
Expand All @@ -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;
Expand Down Expand Up @@ -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");
Expand All @@ -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.");
Expand Down
35 changes: 33 additions & 2 deletions src/codex/inject.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'.`,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

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");
Expand Down Expand Up @@ -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.` };
}
Comment on lines +572 to +576

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add a restore-path preservation regression test.

The new early return prevents config, catalog, and history restoration for an external provider, but the new integration coverage exercises only injectCodexConfig. Add a focused restoreNativeCodex() test with an external provider and stale journal, asserting the external config/profile/history remain unchanged while the journal is removed.

As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codex/inject.ts` around lines 572 - 576, Extend the existing tests for
restoreNativeCodex with a focused regression case covering an active external
provider and stale journal. Assert that the external config, profile/catalog,
and history remain unchanged, while removeJournal still removes the journal;
keep the test scoped to the early-return behavior in restoreNativeCodex.

Source: Path instructions

const journal = restoreJournalState();
const cfg = journal.configRestored
? { success: true, message: "Codex config restored from opencodex journal." }
Expand Down
19 changes: 17 additions & 2 deletions src/codex/sync.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -19,6 +19,7 @@ export interface CodexSyncResult {
interface CodexSyncDeps {
refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
injectCodexConfig: typeof injectCodexConfig;
currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
}

const defaultDeps: CodexSyncDeps = {
Expand All @@ -32,8 +33,22 @@ export async function syncModelsToCodex(
log: Pick<Console, "log" | "error"> | null = console,
deps: CodexSyncDeps = defaultDeps,
): Promise<CodexSyncResult> {
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;
Expand Down
12 changes: 9 additions & 3 deletions structure/02_config-and-codex-home.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
`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

Expand Down
Loading
Loading