Lumoswitch Docs

ChatGPT Desktop + Codex CLI setup

Choose this guide when you use both the graphical Codex Agent and the terminal Codex Agent. Configure the URL, Access Key, and client-facing model once; Codex in ChatGPT desktop, plain Codex CLI, and the Codex IDE extension then read the same user-level settings.

Ordinary ChatGPT conversations are unaffected. The shared settings apply to the Codex workspace in ChatGPT desktop, Codex CLI, and the Codex IDE extension.

Connection modes

ModeAvailablePurpose and reason
One-time launchNoShared mode must be readable by graphical apps; use isolated Codex CLI setup when only a temporary CLI run is needed
Persistent useYesDesktop, CLI, and IDE share one user-level provider, model catalog, model, and credential
Remove setupYesRemoves the shared Lumoswitch setup from all three surfaces while preserving other Codex settings

1. Confirm that both surfaces are available

  1. ChatGPT desktop opens normally and can enter Codex.
  2. codex --version prints a version in a new terminal.
  3. The Lumoswitch API configuration enables Responses output.

If codex is unavailable, choose one official installation method. The npm method works on Windows, macOS, and Linux:

# Standalone installer for macOS or Linux
curl -fsSL https://chatgpt.com/codex/install.sh | sh

# Windows, macOS, or Linux with npm
npm install -g @openai/codex

# Or use Homebrew
brew install --cask codex

Installing ChatGPT desktop does not guarantee that Codex CLI is installed.

2. Prepare four configuration values

Replace each placeholder in the settings command with the real value shown on the API configuration result:

PlaceholderValue
{{api_base_url}}Lumoswitch API URL, normally ending in /v1
{{access_key}}The API configuration's Access Key; never share it or commit it to Git
{{model}}The client-facing model name, not an upstream platform display name
{{codex_model_catalog_base64}}Capability-aware Codex catalog, Base64 encoded

3. Persistent use with shared settings

This setup is persistent only. Temporary environment variables cannot be passed reliably to graphical applications opened from Finder, Dock, or an IDE.

Desktop-only and desktop + CLI use must run the exact same settings command on each operating system so the two paths never develop different merge behavior. On macOS, quit ChatGPT completely with Command + Q and end every active Codex CLI session, then replace the four placeholders below and run the complete command in a trusted terminal:

set -e
LUMOSWITCH_CODEX_DIR="$HOME/.codex"
LUMOSWITCH_CODEX_CONFIG="$LUMOSWITCH_CODEX_DIR/config.toml"
LUMOSWITCH_CODEX_ENV="$LUMOSWITCH_CODEX_DIR/.env"
LUMOSWITCH_CODEX_CATALOG="$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json"
LUMOSWITCH_CODEX_BIN="/Applications/ChatGPT.app/Contents/Resources/codex"
LUMOSWITCH_BACKUP_SUFFIX="$(date +%Y%m%d-%H%M%S)-$$"
LUMOSWITCH_CONFIG_TMP=""
LUMOSWITCH_ENV_TMP=""
LUMOSWITCH_CATALOG_TMP=""

lumoswitch_cleanup() {
  [ -z "$LUMOSWITCH_CONFIG_TMP" ] || rm -f "$LUMOSWITCH_CONFIG_TMP"
  [ -z "$LUMOSWITCH_ENV_TMP" ] || rm -f "$LUMOSWITCH_ENV_TMP"
  [ -z "$LUMOSWITCH_CATALOG_TMP" ] || rm -f "$LUMOSWITCH_CATALOG_TMP"
}
trap lumoswitch_cleanup EXIT HUP INT TERM

if pgrep -x ChatGPT >/dev/null 2>&1; then
  echo "Quit ChatGPT completely with Command + Q, then run this command again." >&2
  exit 1
fi

mkdir -p "$LUMOSWITCH_CODEX_DIR"
umask 077

if [ ! -x "$LUMOSWITCH_CODEX_BIN" ]; then
  echo "ChatGPT's bundled Codex executable was not found. Install or update ChatGPT for macOS first." >&2
  exit 1
fi

if [ -L "$LUMOSWITCH_CODEX_CONFIG" ] || [ -L "$LUMOSWITCH_CODEX_ENV" ] || [ -L "$LUMOSWITCH_CODEX_CATALOG" ]; then
  echo "A Codex settings, credential, or Lumoswitch model-catalog file is a symbolic link. Automatic merge stopped; edit its target manually." >&2
  exit 1
fi

if [ -e "$LUMOSWITCH_CODEX_CATALOG" ] && [ ! -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
  echo "The Lumoswitch model-catalog path is occupied by a non-regular file. Automatic merge stopped: $LUMOSWITCH_CODEX_CATALOG" >&2
  exit 1
fi

LUMOSWITCH_MODEL_VALUE="$(/bin/cat <<'LUMOSWITCH_MODEL_VALUE_EOF'
{{model}}
LUMOSWITCH_MODEL_VALUE_EOF
)"
LUMOSWITCH_CODEX_CATALOG_BASE64="$(/bin/cat <<'LUMOSWITCH_CODEX_CATALOG_BASE64_EOF'
{{codex_model_catalog_base64}}
LUMOSWITCH_CODEX_CATALOG_BASE64_EOF
)"
LUMOSWITCH_MODEL_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_MODEL_VALUE" <<'LUMOSWITCH_STRING_JXA'
function run(argv) {
  return JSON.stringify(argv[0]);
}
LUMOSWITCH_STRING_JXA
)"
LUMOSWITCH_CATALOG_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CODEX_CATALOG" <<'LUMOSWITCH_PATH_JXA'
function run(argv) {
  return JSON.stringify(argv[0]);
}
LUMOSWITCH_PATH_JXA
)"

if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
  cp "$LUMOSWITCH_CODEX_CONFIG" "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
  chmod 600 "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"

  if awk '
    BEGIN { root = 1; found = 0 }
    /^[[:space:]]*\[/ { root = 0 }
    root && /^[[:space:]]*model_providers[[:space:]]*=/ { found = 1 }
    END { exit found ? 0 : 1 }
  ' "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "Inline model_providers detected. Automatic merge stopped; merge it manually. The original file is unchanged." >&2
    exit 1
  fi

  if grep -Eq "^[[:space:]]*('features'([.][^[:space:]=]+)?[[:space:]]*=|\\[[^]]*'features')" "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "Single-quoted features settings cannot be merged safely. Automatic merge stopped; the original file is unchanged." >&2
    exit 1
  fi

  if awk '
    BEGIN { root = 1; found = 0 }
    /^[[:space:]]*\[/ { root = 0 }
    root && /^[[:space:]]*(features|"features")([.][^[:space:]=]+)?[[:space:]]*=/ { found = 1 }
    END { exit found ? 0 : 1 }
  ' "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "Top-level inline features settings cannot be merged safely into [features]. Automatic merge stopped; the original file is unchanged." >&2
    exit 1
  fi

  if awk '
    BEGIN { count = 0; invalid = 0 }
    /^[[:space:]]*\[/ {
      value = $0
      gsub(/[[:space:]]/, "", value)
      if (value == "[features]") count++
      else if (value ~ /^\[features[.]/ || value ~ /^\["features"/) invalid = 1
    }
    END { exit count > 1 || invalid ? 0 : 1 }
  ' "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "Duplicate, quoted, or nested features tables cannot be merged safely. Automatic merge stopped; the original file is unchanged." >&2
    exit 1
  fi

  if grep -Eq '^[[:space:]]*\[[[:space:]]*model_providers[.]"lumoswitch"' "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "A quoted Lumoswitch provider table was detected. Automatic merge stopped; merge it manually. The original file is unchanged." >&2
    exit 1
  fi

  if awk -v expected="$LUMOSWITCH_CATALOG_LITERAL" '
    BEGIN { root = 1; mismatch = 0 }
    /^[[:space:]]*\[/ { root = 0 }
    root && /^[[:space:]]*model_catalog_json[[:space:]]*=/ {
      value = $0
      sub(/^[[:space:]]*model_catalog_json[[:space:]]*=[[:space:]]*/, "", value)
      sub(/[[:space:]]*(#.*)?$/, "", value)
      if (value != expected) mismatch = 1
    }
    END { exit mismatch ? 0 : 1 }
  ' "$LUMOSWITCH_CODEX_CONFIG"; then
    echo "Another Codex custom model catalog is already configured. Automatic replacement stopped; merge the model manually instead." >&2
    exit 1
  fi
fi

LUMOSWITCH_CATALOG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json.lumoswitch.XXXXXX")"
printf '%s' "$LUMOSWITCH_CODEX_CATALOG_BASE64" | /usr/bin/base64 -D > "$LUMOSWITCH_CATALOG_TMP"
chmod 600 "$LUMOSWITCH_CATALOG_TMP"
LUMOSWITCH_CATALOG_TMP_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CATALOG_TMP" <<'LUMOSWITCH_TMP_PATH_JXA'
function run(argv) {
  return JSON.stringify(argv[0]);
}
LUMOSWITCH_TMP_PATH_JXA
)"
"$LUMOSWITCH_CODEX_BIN" debug models -c "model_catalog_json=$LUMOSWITCH_CATALOG_TMP_LITERAL" >/dev/null

LUMOSWITCH_CONFIG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/config.toml.lumoswitch.XXXXXX")"
{
  printf 'model = %s\n' "$LUMOSWITCH_MODEL_LITERAL"
  printf 'model_provider = "lumoswitch"\n'
  printf 'model_catalog_json = %s\n' "$LUMOSWITCH_CATALOG_LITERAL"
  printf 'web_search = "disabled"\n'

  if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
    printf '\n'
    awk '
      BEGIN { root = 1; skip = 0; in_features = 0; features_seen = 0 }
      /^[[:space:]]*\[/ {
        if (in_features) {
          print "multi_agent = false"
          in_features = 0
        }
        root = 0
        if ($0 ~ /^[[:space:]]*\[[[:space:]]*model_providers[.]lumoswitch([.][^]]+)?[[:space:]]*\]/) {
          skip = 1
          next
        }
        skip = 0
        if ($0 ~ /^[[:space:]]*\[[[:space:]]*features[[:space:]]*\]/) {
          in_features = 1
          features_seen = 1
        }
      }
      skip { next }
      in_features && /^[[:space:]]*multi_agent[[:space:]]*=/ { next }
      root && /^[[:space:]]*(model|model_provider|model_catalog_json|model_reasoning_effort|web_search)[[:space:]]*=/ { next }
      { print }
      END {
        if (in_features) print "multi_agent = false"
        else if (!features_seen) {
          print ""
          print "[features]"
          print "multi_agent = false"
        }
      }
    ' "$LUMOSWITCH_CODEX_CONFIG"
  else
    printf '\n[features]\n'
    printf 'multi_agent = false\n'
  fi

  printf '\n[model_providers.lumoswitch]\n'
  printf 'name = "Lumoswitch"\n'
  printf 'base_url = "%s"\n' "{{api_base_url}}"
  printf 'env_key = "LUMOSWITCH_API_KEY"\n'
  printf 'wire_api = "responses"\n'
  printf 'http_headers = { "X-Lumoswitch-Agent" = "codex-v1" }\n'
  printf 'requires_openai_auth = false\n'
} > "$LUMOSWITCH_CONFIG_TMP"
chmod 600 "$LUMOSWITCH_CONFIG_TMP"
mv "$LUMOSWITCH_CATALOG_TMP" "$LUMOSWITCH_CODEX_CATALOG"
LUMOSWITCH_CATALOG_TMP=""
mv "$LUMOSWITCH_CONFIG_TMP" "$LUMOSWITCH_CODEX_CONFIG"
LUMOSWITCH_CONFIG_TMP=""

if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
  LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.backup.lumoswitch.XXXXXX")"
  awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
  chmod 600 "$LUMOSWITCH_ENV_TMP"
  mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV.backup.lumoswitch.$LUMOSWITCH_BACKUP_SUFFIX"
  LUMOSWITCH_ENV_TMP=""
fi

LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.lumoswitch.XXXXXX")"
if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
  awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
fi
printf 'LUMOSWITCH_API_KEY=%s\n' "{{access_key}}" >> "$LUMOSWITCH_ENV_TMP"
chmod 600 "$LUMOSWITCH_ENV_TMP"
mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV"
LUMOSWITCH_ENV_TMP=""

printf 'Shared Codex settings updated; backup suffix: %s\n' "$LUMOSWITCH_BACKUP_SUFFIX"
open -a ChatGPT

The command:

  • backs up and merges ~/.codex/config.toml while preserving MCP, approval, plugin, and other provider settings;
  • creates ~/.codex/lumoswitch-model-catalog.json, containing only the client-facing model from the Lumoswitch configuration result;
  • validates that catalog with the Codex executable bundled in ChatGPT before installing it;
  • installs the capability-aware model catalog produced by Lumoswitch, exposing only reasoning levels supported by both the model and its effective route;
  • applies a cross-model minimum-compatibility profile by setting top-level web_search = "disabled" and merging multi_agent = false into an existing or new [features] table; this disables Codex's multi-agent namespace and native web search so downstream models that do not recognize those tools do not reject the entire Responses request, while standard function tools, terminal commands, and file operations remain available;
  • stores the Access Key in ~/.codex/.env with 0600 permissions, while .env backups preserve other values but deliberately exclude LUMOSWITCH_API_KEY; and
  • preserves other feature entries, and stops before changing a symbolic link, a non-regular dedicated catalog path, another configured model catalog, or an inline, duplicate, quoted, nested, or otherwise unsupported TOML form.

Native Windows PowerShell import

When ChatGPT uses its default Windows native Agent environment, the desktop app, native Codex CLI, and IDE extension share the user-level Codex settings in %USERPROFILE%\.codex. If Settings > Agent environment is set to WSL, switch it to Windows native and restart ChatGPT before importing because WSL uses a different Codex home by default. Quit ChatGPT completely and end all running Codex CLI sessions, then run this complete command in a trusted PowerShell terminal. A one-time shared mode is intentionally unavailable because a Start menu app does not reliably inherit process-scoped PowerShell variables.

& {
  $ErrorActionPreference = 'Stop'
  $lumoswitchCodexDir = Join-Path $HOME '.codex'
  $lumoswitchConfig = Join-Path $lumoswitchCodexDir 'config.toml'
  $lumoswitchEnvironment = Join-Path $lumoswitchCodexDir '.env'
  $lumoswitchCatalog = Join-Path $lumoswitchCodexDir 'lumoswitch-model-catalog.json'
  $lumoswitchBackupSuffix = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + $PID

  $lumoswitchChatGptApp = Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.AppID -match 'ChatGPT' } | Select-Object -First 1
  if ($null -eq $lumoswitchChatGptApp) { throw 'ChatGPT was not found in the Start menu. Install or update the Windows desktop app first.' }
  if (Get-Process -Name 'ChatGPT' -ErrorAction SilentlyContinue) { throw 'Quit ChatGPT completely, then run this command again.' }

  function Assert-LumoswitchFile([string] $Path) {
    if (-not (Test-Path -LiteralPath $Path)) { return }
    $lumoswitchItem = Get-Item -LiteralPath $Path -Force
    if ($lumoswitchItem.PSIsContainer -or ($lumoswitchItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
      throw "Lumoswitch refused to replace a directory or link: $Path"
    }
  }

  function Protect-LumoswitchFile([string] $Path) {
    $lumoswitchSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    & icacls.exe $Path '/inheritance:r' '/grant:r' ('*' + $lumoswitchSid + ':(F)') '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "Could not restrict access to $Path" }
  }

  function Write-LumoswitchFile([string] $Path, [string] $Content) {
    Assert-LumoswitchFile $Path
    [IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null
    $lumoswitchTemporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
    try {
      [IO.File]::WriteAllText($lumoswitchTemporary, $Content, [Text.UTF8Encoding]::new($false))
      Move-Item -LiteralPath $lumoswitchTemporary -Destination $Path -Force
      Protect-LumoswitchFile $Path
    } finally {
      Remove-Item -LiteralPath $lumoswitchTemporary -Force -ErrorAction SilentlyContinue
    }
  }

  if (Test-Path -LiteralPath $lumoswitchCodexDir) {
    $lumoswitchRootItem = Get-Item -LiteralPath $lumoswitchCodexDir -Force
    if (-not $lumoswitchRootItem.PSIsContainer -or ($lumoswitchRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
      throw 'The user-level Codex directory is not a regular directory. Automatic merge stopped.'
    }
  } else {
    [IO.Directory]::CreateDirectory($lumoswitchCodexDir) | Out-Null
  }
  foreach ($lumoswitchPath in @($lumoswitchConfig, $lumoswitchEnvironment, $lumoswitchCatalog)) { Assert-LumoswitchFile $lumoswitchPath }

  $lumoswitchCatalogText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{{codex_model_catalog_base64}}'))
  $lumoswitchCatalogObject = $lumoswitchCatalogText | ConvertFrom-Json
  $lumoswitchCatalogModels = @($lumoswitchCatalogObject.models)
  if ($lumoswitchCatalogModels.Count -eq 0 -or @($lumoswitchCatalogModels.slug) -notcontains '{{model}}') {
    throw 'The generated Codex model catalog is empty or does not contain the selected model.'
  }

  $lumoswitchCatalogToml = $lumoswitchCatalog.Replace('\', '/')
  $lumoswitchExpectedCatalog = '"' + $lumoswitchCatalogToml + '"'
  $lumoswitchConfigLines = if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) { @([IO.File]::ReadAllLines($lumoswitchConfig, [Text.Encoding]::UTF8)) } else { @() }
  $lumoswitchRoot = $true
  $lumoswitchFeaturesCount = 0
  foreach ($lumoswitchLine in $lumoswitchConfigLines) {
    if ($lumoswitchLine -match '^\s*\[') {
      $lumoswitchRoot = $false
      $lumoswitchNormalized = $lumoswitchLine -replace '\s', ''
      if ($lumoswitchNormalized -eq '[features]') { $lumoswitchFeaturesCount++ }
      elseif ($lumoswitchNormalized -match '^\[(?:"features"|features\.)') { throw 'Quoted or nested features tables cannot be merged safely.' }
      if ($lumoswitchNormalized -match '^\[model_providers\."lumoswitch"') { throw 'A quoted Lumoswitch provider table cannot be merged safely.' }
    }
    if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_providers\s*=') { throw 'Inline model_providers cannot be merged safely.' }
    if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*features(?:\.|\s*=)') { throw 'Inline features settings cannot be merged safely.' }
    if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') {
      $lumoswitchValue = ($lumoswitchLine -replace '^\s*model_catalog_json\s*=\s*', '') -replace '\s*(#.*)?$', ''
      if ($lumoswitchValue -ne $lumoswitchExpectedCatalog) { throw 'Another Codex custom model catalog is already configured.' }
    }
  }
  if ($lumoswitchFeaturesCount -gt 1) { throw 'Duplicate features tables cannot be merged safely.' }

  if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) {
    $lumoswitchConfigBackup = $lumoswitchConfig + '.backup.' + $lumoswitchBackupSuffix
    Assert-LumoswitchFile $lumoswitchConfigBackup
    Copy-Item -LiteralPath $lumoswitchConfig -Destination $lumoswitchConfigBackup
    Protect-LumoswitchFile $lumoswitchConfigBackup
  }

  $lumoswitchOutput = [Collections.Generic.List[string]]::new()
  foreach ($lumoswitchLine in @(
    'model = "{{model}}"',
    'model_provider = "lumoswitch"',
    ('model_catalog_json = "' + $lumoswitchCatalogToml + '"'),
    'web_search = "disabled"',
    ''
  )) { $lumoswitchOutput.Add($lumoswitchLine) | Out-Null }
  $lumoswitchCopyRoot = $true
  $lumoswitchSkip = $false
  $lumoswitchInFeatures = $false
  $lumoswitchFeaturesSeen = $false
  foreach ($lumoswitchLine in $lumoswitchConfigLines) {
    if ($lumoswitchLine -match '^\s*\[') {
      if ($lumoswitchInFeatures) { $lumoswitchOutput.Add('multi_agent = false') | Out-Null }
      $lumoswitchInFeatures = $false
      $lumoswitchCopyRoot = $false
      if ($lumoswitchLine -match '^\s*\[\s*model_providers\.lumoswitch(?:\.[^]]+)?\s*\]\s*$') { $lumoswitchSkip = $true; continue }
      $lumoswitchSkip = $false
      if (($lumoswitchLine -replace '\s', '') -eq '[features]') { $lumoswitchInFeatures = $true; $lumoswitchFeaturesSeen = $true }
    }
    if ($lumoswitchSkip) { continue }
    if ($lumoswitchInFeatures -and $lumoswitchLine -match '^\s*multi_agent\s*=') { continue }
    if ($lumoswitchCopyRoot -and $lumoswitchLine -match '^\s*(model|model_provider|model_catalog_json|model_reasoning_effort|web_search)\s*=') { continue }
    $lumoswitchOutput.Add($lumoswitchLine) | Out-Null
  }
  if ($lumoswitchInFeatures) { $lumoswitchOutput.Add('multi_agent = false') | Out-Null }
  if (-not $lumoswitchFeaturesSeen) {
    $lumoswitchOutput.Add('') | Out-Null
    $lumoswitchOutput.Add('[features]') | Out-Null
    $lumoswitchOutput.Add('multi_agent = false') | Out-Null
  }
  foreach ($lumoswitchLine in @(
    '',
    '[model_providers.lumoswitch]',
    'name = "Lumoswitch"',
    'base_url = "{{api_base_url}}"',
    'env_key = "LUMOSWITCH_API_KEY"',
    'wire_api = "responses"',
    'http_headers = { "X-Lumoswitch-Agent" = "codex-v1" }',
    'requires_openai_auth = false'
  )) { $lumoswitchOutput.Add($lumoswitchLine) | Out-Null }

  $lumoswitchNewLine = [Environment]::NewLine
  Write-LumoswitchFile $lumoswitchCatalog $lumoswitchCatalogText
  Write-LumoswitchFile $lumoswitchConfig (($lumoswitchOutput -join $lumoswitchNewLine) + $lumoswitchNewLine)

  $lumoswitchEnvironmentLines = if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
    @([IO.File]::ReadAllLines($lumoswitchEnvironment, [Text.Encoding]::UTF8) | Where-Object { $_ -notmatch '^\s*(?:export\s+)?LUMOSWITCH_API_KEY\s*=' })
  } else { @() }
  if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
    $lumoswitchEnvironmentBackup = $lumoswitchEnvironment + '.backup.lumoswitch.' + $lumoswitchBackupSuffix
    $lumoswitchEnvironmentBackupText = if ($lumoswitchEnvironmentLines.Count -gt 0) { ($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
    Write-LumoswitchFile $lumoswitchEnvironmentBackup $lumoswitchEnvironmentBackupText
  }
  $lumoswitchEnvironmentLines += 'LUMOSWITCH_API_KEY={{access_key}}'
  Write-LumoswitchFile $lumoswitchEnvironment (($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine)

  Write-Host ('Shared Codex settings updated; backup suffix: ' + $lumoswitchBackupSuffix)
  Start-Process -FilePath explorer.exe -ArgumentList @('shell:AppsFolder\' + $lumoswitchChatGptApp.AppID)
}

The Windows command discovers the installed ChatGPT Start menu App ID instead of hard-coding a Store package identity. It validates the generated JSON catalog and selected model before writing, uses the same conservative TOML merge rules as macOS, restricts the catalog, configuration, credential, and backup files with Windows ACLs, excludes the Access Key from .env backups, and reopens ChatGPT when configuration finishes. Codex CLI and the IDE extension read those same shared files after their old sessions are restarted.

4. Launch each surface

ChatGPT desktop

Open ChatGPT normally and enter Codex, or run:

open -a ChatGPT

Codex CLI

Open the target project directory and run:

codex

Use /status in Codex CLI to confirm the active model and provider. The model picker should contain the same client-facing Lumoswitch model shown in ChatGPT desktop.

New tasks use the effective default from the generated catalog. /reasoning lists only verified levels; toggle-style models such as DeepSeek expose one equivalent enabled level.

Codex IDE extension

After changing settings, fully restart the IDE or reload the Codex extension, then begin a new task. The extension reads the same ~/.codex settings and does not need a separate command.

SurfaceSettings readAction after a change
Codex in ChatGPT desktop~/.codex/config.toml, ~/.codex/.env, and ~/.codex/lumoswitch-model-catalog.jsonQuit ChatGPT completely and reopen it
Plain Codex CLIThe same three filesExit the old session and run codex
Codex IDE extensionThe same three filesFully restart the IDE and begin a new task
Ordinary ChatGPT chatsNoneUnaffected

After changing the Access Key, URL, or client-facing model name, run this page's persistent-use command again and fully restart every open Codex surface. In ChatGPT desktop and CLI, the generated catalog intentionally replaces the built-in catalog with the single configured downstream model. Send a short text request from each surface and confirm both requests in Lumoswitch logs.

5. Remove the Lumoswitch setup

Removal affects Codex in ChatGPT desktop, plain Codex CLI, and the Codex IDE extension together.

macOS

Quit ChatGPT with Command + Q, end every Codex CLI session, then run:

set -e
LUMOSWITCH_CODEX_DIR="$HOME/.codex"
LUMOSWITCH_CODEX_CONFIG="$LUMOSWITCH_CODEX_DIR/config.toml"
LUMOSWITCH_CODEX_ENV="$LUMOSWITCH_CODEX_DIR/.env"
LUMOSWITCH_CODEX_CATALOG="$LUMOSWITCH_CODEX_DIR/lumoswitch-model-catalog.json"
LUMOSWITCH_BACKUP_SUFFIX="$(date +%Y%m%d-%H%M%S)-remove-$$"
LUMOSWITCH_CONFIG_TMP=""
LUMOSWITCH_ENV_TMP=""

lumoswitch_cleanup() {
  [ -z "$LUMOSWITCH_CONFIG_TMP" ] || rm -f "$LUMOSWITCH_CONFIG_TMP"
  [ -z "$LUMOSWITCH_ENV_TMP" ] || rm -f "$LUMOSWITCH_ENV_TMP"
}
trap lumoswitch_cleanup EXIT HUP INT TERM

if pgrep -x ChatGPT >/dev/null 2>&1; then
  echo "Quit ChatGPT completely with Command + Q, then run this command again." >&2
  exit 1
fi

mkdir -p "$LUMOSWITCH_CODEX_DIR"
umask 077

if [ -L "$LUMOSWITCH_CODEX_CONFIG" ] || [ -L "$LUMOSWITCH_CODEX_ENV" ] || [ -L "$LUMOSWITCH_CODEX_CATALOG" ]; then
  echo "A Codex settings, credential, or Lumoswitch model-catalog file is a symbolic link. Automatic removal stopped; edit its target manually." >&2
  exit 1
fi

if [ -e "$LUMOSWITCH_CODEX_CATALOG" ] && [ ! -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
  echo "The Lumoswitch model-catalog path is occupied by a non-regular file. Automatic removal stopped: $LUMOSWITCH_CODEX_CATALOG" >&2
  exit 1
fi

LUMOSWITCH_CATALOG_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CODEX_CATALOG" <<'LUMOSWITCH_PATH_JXA'
function run(argv) {
  return JSON.stringify(argv[0]);
}
LUMOSWITCH_PATH_JXA
)"

LUMOSWITCH_REMOVE_DEFAULTS=0
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ] && awk '
  BEGIN { root = 1; found = 0 }
  /^[[:space:]]*\[/ { root = 0 }
  root && /^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"lumoswitch"[[:space:]]*(#.*)?$/ { found = 1 }
  END { exit found ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
  LUMOSWITCH_REMOVE_DEFAULTS=1
fi

LUMOSWITCH_REMOVE_CATALOG_SETTING=0
if [ -f "$LUMOSWITCH_CODEX_CONFIG" ] && awk -v expected="$LUMOSWITCH_CATALOG_LITERAL" '
  BEGIN { root = 1; found = 0; mismatch = 0 }
  /^[[:space:]]*\[/ { root = 0 }
  root && /^[[:space:]]*model_catalog_json[[:space:]]*=/ {
    value = $0
    sub(/^[[:space:]]*model_catalog_json[[:space:]]*=[[:space:]]*/, "", value)
    sub(/[[:space:]]*(#.*)?$/, "", value)
    if (value == expected) found = 1
    if (value != expected) mismatch = 1
  }
  END { exit found && !mismatch ? 0 : 1 }
' "$LUMOSWITCH_CODEX_CONFIG"; then
  LUMOSWITCH_REMOVE_CATALOG_SETTING=1
fi

if [ -f "$LUMOSWITCH_CODEX_CONFIG" ]; then
  cp "$LUMOSWITCH_CODEX_CONFIG" "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
  chmod 600 "$LUMOSWITCH_CODEX_CONFIG.backup.$LUMOSWITCH_BACKUP_SUFFIX"
  LUMOSWITCH_CONFIG_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/config.toml.lumoswitch-remove.XXXXXX")"
  awk -v remove_defaults="$LUMOSWITCH_REMOVE_DEFAULTS" -v remove_catalog="$LUMOSWITCH_REMOVE_CATALOG_SETTING" '
    BEGIN { root = 1; skip = 0; in_features = 0 }
    /^[[:space:]]*\[/ {
      root = 0
      in_features = ($0 ~ /^[[:space:]]*\[[[:space:]]*features[[:space:]]*\]/)
      if ($0 ~ /^[[:space:]]*\[[[:space:]]*model_providers[.]lumoswitch([.][^]]+)?[[:space:]]*\]/) {
        skip = 1
        next
      }
      skip = 0
    }
    skip { next }
    in_features && remove_defaults == "1" && /^[[:space:]]*multi_agent[[:space:]]*=[[:space:]]*false[[:space:]]*(#.*)?$/ { next }
    root && remove_defaults == "1" && /^[[:space:]]*(model|model_provider|model_reasoning_effort)[[:space:]]*=/ { next }
    root && remove_defaults == "1" && /^[[:space:]]*web_search[[:space:]]*=[[:space:]]*"disabled"[[:space:]]*(#.*)?$/ { next }
    root && remove_catalog == "1" && /^[[:space:]]*model_catalog_json[[:space:]]*=/ { next }
    { print }
  ' "$LUMOSWITCH_CODEX_CONFIG" > "$LUMOSWITCH_CONFIG_TMP"
  chmod 600 "$LUMOSWITCH_CONFIG_TMP"
  mv "$LUMOSWITCH_CONFIG_TMP" "$LUMOSWITCH_CODEX_CONFIG"
  LUMOSWITCH_CONFIG_TMP=""
fi

if [ -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
  rm -f "$LUMOSWITCH_CODEX_CATALOG"
fi

if [ -f "$LUMOSWITCH_CODEX_ENV" ]; then
  LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.backup.lumoswitch.XXXXXX")"
  awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
  chmod 600 "$LUMOSWITCH_ENV_TMP"
  mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV.backup.lumoswitch.$LUMOSWITCH_BACKUP_SUFFIX"
  LUMOSWITCH_ENV_TMP=""
  LUMOSWITCH_ENV_TMP="$(mktemp "$LUMOSWITCH_CODEX_DIR/.env.lumoswitch-remove.XXXXXX")"
  awk '!/^[[:space:]]*(export[[:space:]]+)?LUMOSWITCH_API_KEY[[:space:]]*=/' "$LUMOSWITCH_CODEX_ENV" > "$LUMOSWITCH_ENV_TMP"
  chmod 600 "$LUMOSWITCH_ENV_TMP"
  mv "$LUMOSWITCH_ENV_TMP" "$LUMOSWITCH_CODEX_ENV"
  LUMOSWITCH_ENV_TMP=""
fi

printf 'Lumoswitch Codex setup removed; pre-removal backup suffix: %s\n' "$LUMOSWITCH_BACKUP_SUFFIX"
open -a ChatGPT

The command first creates a safe backup that omits the Lumoswitch Key, then removes only Lumoswitch-related settings. If top-level model_provider still points to lumoswitch, it also removes top-level model, model_provider, and any model_reasoning_effort left by an earlier version of this guide. It removes web_search only while it still equals "disabled", and removes [features].multi_agent only while it still equals false; later user changes and every other feature entry remain intact, and an empty [features] table may remain. It removes top-level model_catalog_json only when its value exactly matches the dedicated absolute path, and deletes only the regular ~/.codex/lumoswitch-model-catalog.json file. Other catalog paths remain unchanged. Symbolic links and non-regular dedicated catalog paths stop automatic removal and are never followed. Automatic removal cannot know the defaults overwritten before setup. Exact restoration therefore requires manually selecting the matching config.toml installation backup and is safe only when no later configuration changed. The Access Key is deliberately excluded from backups and must be entered again when reconnecting.

Windows PowerShell

Quit ChatGPT completely and end every running Codex CLI session, then run:

& {
  $ErrorActionPreference = 'Stop'
  $lumoswitchCodexDir = Join-Path $HOME '.codex'
  $lumoswitchConfig = Join-Path $lumoswitchCodexDir 'config.toml'
  $lumoswitchEnvironment = Join-Path $lumoswitchCodexDir '.env'
  $lumoswitchCatalog = Join-Path $lumoswitchCodexDir 'lumoswitch-model-catalog.json'
  $lumoswitchBackupSuffix = (Get-Date -Format 'yyyyMMdd-HHmmss') + '-remove-' + $PID

  if (Get-Process -Name 'ChatGPT' -ErrorAction SilentlyContinue) { throw 'Quit ChatGPT completely, then run this command again.' }
  $lumoswitchChatGptApp = Get-StartApps | Where-Object { $_.Name -eq 'ChatGPT' -or $_.AppID -match 'ChatGPT' } | Select-Object -First 1

  function Assert-LumoswitchFile([string] $Path) {
    if (-not (Test-Path -LiteralPath $Path)) { return }
    $lumoswitchItem = Get-Item -LiteralPath $Path -Force
    if ($lumoswitchItem.PSIsContainer -or ($lumoswitchItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
      throw "Lumoswitch refused to modify a directory or link: $Path"
    }
  }

  function Protect-LumoswitchFile([string] $Path) {
    $lumoswitchSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
    & icacls.exe $Path '/inheritance:r' '/grant:r' ('*' + $lumoswitchSid + ':(F)') '*S-1-5-18:(F)' '*S-1-5-32-544:(F)' | Out-Null
    if ($LASTEXITCODE -ne 0) { throw "Could not restrict access to $Path" }
  }

  function Write-LumoswitchFile([string] $Path, [string] $Content) {
    Assert-LumoswitchFile $Path
    [IO.Directory]::CreateDirectory((Split-Path -Parent $Path)) | Out-Null
    $lumoswitchTemporary = $Path + '.' + [Guid]::NewGuid().ToString('N') + '.tmp'
    try {
      [IO.File]::WriteAllText($lumoswitchTemporary, $Content, [Text.UTF8Encoding]::new($false))
      Move-Item -LiteralPath $lumoswitchTemporary -Destination $Path -Force
      Protect-LumoswitchFile $Path
    } finally {
      Remove-Item -LiteralPath $lumoswitchTemporary -Force -ErrorAction SilentlyContinue
    }
  }

  if (Test-Path -LiteralPath $lumoswitchCodexDir) {
    $lumoswitchRootItem = Get-Item -LiteralPath $lumoswitchCodexDir -Force
    if (-not $lumoswitchRootItem.PSIsContainer -or ($lumoswitchRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
      throw 'The user-level Codex directory is not a regular directory. Automatic removal stopped.'
    }
  } else {
    [IO.Directory]::CreateDirectory($lumoswitchCodexDir) | Out-Null
  }
  foreach ($lumoswitchPath in @($lumoswitchConfig, $lumoswitchEnvironment, $lumoswitchCatalog)) { Assert-LumoswitchFile $lumoswitchPath }

  $lumoswitchNewLine = [Environment]::NewLine
  $lumoswitchConfigLines = if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) { @([IO.File]::ReadAllLines($lumoswitchConfig, [Text.Encoding]::UTF8)) } else { @() }
  $lumoswitchExpectedCatalog = '"' + $lumoswitchCatalog.Replace('\', '/') + '"'
  $lumoswitchRemoveDefaults = $false
  $lumoswitchCatalogSettings = 0
  $lumoswitchCatalogMatches = 0
  $lumoswitchRoot = $true
  foreach ($lumoswitchLine in $lumoswitchConfigLines) {
    if ($lumoswitchLine -match '^\s*\[') { $lumoswitchRoot = $false }
    if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_provider\s*=\s*"lumoswitch"\s*(?:#.*)?$') { $lumoswitchRemoveDefaults = $true }
    if ($lumoswitchRoot -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') {
      $lumoswitchCatalogSettings++
      $lumoswitchValue = ($lumoswitchLine -replace '^\s*model_catalog_json\s*=\s*', '') -replace '\s*(#.*)?$', ''
      if ($lumoswitchValue -eq $lumoswitchExpectedCatalog) { $lumoswitchCatalogMatches++ }
    }
  }
  $lumoswitchRemoveCatalog = ($lumoswitchCatalogSettings -eq 1 -and $lumoswitchCatalogMatches -eq 1)

  if (Test-Path -LiteralPath $lumoswitchConfig -PathType Leaf) {
    $lumoswitchConfigBackup = $lumoswitchConfig + '.backup.' + $lumoswitchBackupSuffix
    Assert-LumoswitchFile $lumoswitchConfigBackup
    Copy-Item -LiteralPath $lumoswitchConfig -Destination $lumoswitchConfigBackup
    Protect-LumoswitchFile $lumoswitchConfigBackup

    $lumoswitchOutput = [Collections.Generic.List[string]]::new()
    $lumoswitchCopyRoot = $true
    $lumoswitchSkip = $false
    $lumoswitchInFeatures = $false
    foreach ($lumoswitchLine in $lumoswitchConfigLines) {
      if ($lumoswitchLine -match '^\s*\[') {
        $lumoswitchCopyRoot = $false
        $lumoswitchInFeatures = (($lumoswitchLine -replace '\s', '') -eq '[features]')
        if ($lumoswitchLine -match '^\s*\[\s*model_providers\.lumoswitch(?:\.[^]]+)?\s*\]\s*$') { $lumoswitchSkip = $true; continue }
        $lumoswitchSkip = $false
      }
      if ($lumoswitchSkip) { continue }
      if ($lumoswitchInFeatures -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*multi_agent\s*=\s*false\s*(?:#.*)?$') { continue }
      if ($lumoswitchCopyRoot -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*(?:model|model_provider|model_reasoning_effort)\s*=') { continue }
      if ($lumoswitchCopyRoot -and $lumoswitchRemoveDefaults -and $lumoswitchLine -match '^\s*web_search\s*=\s*"disabled"\s*(?:#.*)?$') { continue }
      if ($lumoswitchCopyRoot -and $lumoswitchRemoveCatalog -and $lumoswitchLine -match '^\s*model_catalog_json\s*=') { continue }
      $lumoswitchOutput.Add($lumoswitchLine) | Out-Null
    }
    $lumoswitchConfigText = if ($lumoswitchOutput.Count -gt 0) { ($lumoswitchOutput -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
    Write-LumoswitchFile $lumoswitchConfig $lumoswitchConfigText
  }

  if ($lumoswitchRemoveCatalog -and (Test-Path -LiteralPath $lumoswitchCatalog -PathType Leaf)) {
    Remove-Item -LiteralPath $lumoswitchCatalog -Force
  }

  if (Test-Path -LiteralPath $lumoswitchEnvironment -PathType Leaf) {
    $lumoswitchEnvironmentLines = @([IO.File]::ReadAllLines($lumoswitchEnvironment, [Text.Encoding]::UTF8) | Where-Object { $_ -notmatch '^\s*(?:export\s+)?LUMOSWITCH_API_KEY\s*=' })
    $lumoswitchEnvironmentText = if ($lumoswitchEnvironmentLines.Count -gt 0) { ($lumoswitchEnvironmentLines -join $lumoswitchNewLine) + $lumoswitchNewLine } else { '' }
    $lumoswitchEnvironmentBackup = $lumoswitchEnvironment + '.backup.lumoswitch.' + $lumoswitchBackupSuffix
    Write-LumoswitchFile $lumoswitchEnvironmentBackup $lumoswitchEnvironmentText
    Write-LumoswitchFile $lumoswitchEnvironment $lumoswitchEnvironmentText
  }

  Write-Host ('Lumoswitch Codex setup removed; pre-removal backup suffix: ' + $lumoswitchBackupSuffix)
  if ($null -ne $lumoswitchChatGptApp) {
    Start-Process -FilePath explorer.exe -ArgumentList @('shell:AppsFolder\' + $lumoswitchChatGptApp.AppID)
  }
}

The Windows command applies the same conservative cleanup rules to %USERPROFILE%\.codex, creates ACL-restricted backups that omit the Access Key, and reopens ChatGPT through its discovered Start menu App ID when it is still installed.

6. When not to share settings

If ChatGPT desktop should keep its original provider while only terminal Codex uses Lumoswitch, use Codex CLI setup. Its native CODEX_HOME keeps the terminal configuration separate without changing shared files.

If you currently use only ChatGPT desktop and have no CLI installed, use the simpler ChatGPT Desktop (Codex) setup. Both scenarios use the same persistent settings template, but the desktop-only guide does not ask a new user to install or verify CLI.

Publication fields
  • Platform ID: codex-desktop-cli
  • Display name: ChatGPT Desktop + Codex CLI
  • Downstream protocol: responses
  • macOS/Linux temporary mode: disabled; leave commandTemplate empty
  • macOS persistence strategy: native
  • persistentCommandTemplate: copy the complete code block from this page's Persistent use with shared settings section; confirm that it is byte-for-byte identical to the chatgpt-desktop record before publishing
  • macOS futureCommand: codex
  • Windows temporary mode: disabled; leave windowsCommandTemplate empty
  • Windows persistence strategy: native
  • windowsPersistentCommandTemplate: copy the complete Native Windows PowerShell import code block; confirm that it is byte-for-byte identical to the chatgpt-desktop Windows record before publishing
  • Windows windowsFutureCommand: codex
  • Sort order: 16
  • Publication limit: publish the macOS and Windows templates only for their marked operating systems; verify both desktop and Codex CLI on each system
  • Verification status: the official configuration contract was revalidated on 2026-08-27; shared model-catalog parsing was last validated with the Codex executable bundled in ChatGPT on 2026-08-03; Windows PowerShell is covered by structural and bilingual-template contract tests, while a real Windows dual-surface Lumoswitch traffic check is still pending

Troubleshooting

  • Desktop works but CLI does not: Exit the old CLI session and run plain codex in a new terminal. Do not keep testing shared mode with an old isolated Lumoswitch launcher.
  • CLI works but desktop does not: Quit ChatGPT completely (Command + Q on macOS, or the app menu on Windows), then reopen it.
  • The picker still shows only GPT models: Rerun the latest persistent-use command, then fully restart both surfaces. Confirm that root model_catalog_json points to the dedicated absolute path and that the JSON contains the exact client-facing model name.
  • Installation reports another custom model catalog: The command stops to protect that catalog. Add the Lumoswitch model entry to the existing JSON manually and keep its current model_catalog_json path.
  • The surfaces show different models: Check whether a project-level .codex/config.toml or CLI argument overrides the user-level model and catalog.
  • The runtime says the inference request is invalid: Confirm the selected model and Responses output first, then rerun one-click import to refresh the capability-aware catalog. Use the matching Lumoswitch log to identify any remaining rejected field.
  • You need to restore the old settings: Use the backup suffix printed by the settings command and copy the matching .backup.<suffix> files back to their original paths.

References: ChatGPT desktop for Windows, Codex CLI, Codex custom model providers, Codex configuration reference, Codex model listing, and desktop and IDE environment variables.

On this page