Lumoswitch 文档

ChatGPT 桌面端 + Codex CLI 接入

此方式适合同时使用图形化 Codex Agent 和终端 Codex Agent 的用户。地址、Access Key 和下游模型只配置一次,桌面端 Codex、普通 Codex CLI 和 Codex IDE 扩展读取同一份用户级配置。

普通 ChatGPT 对话仍不会使用 Lumoswitch。共享的是 ChatGPT 桌面端中的 Codex 工作区、Codex CLI 和 Codex IDE 扩展。

接入方式一览

方式是否提供作用与原因
仅本次启动不提供共用模式必须让图形应用读取用户级配置;需要临时 CLI 时应使用 Codex CLI 隔离配置
永久使用提供桌面端、CLI 和 IDE 共用同一份提供方、专属模型目录和凭证
清除接入提供一次清除三端共用的 Lumoswitch 配置,同时保留其他 Codex 设置

1. 确认两个入口都可用

  1. ChatGPT 桌面端可以正常打开并进入 Codex。
  2. 新终端中运行 codex --version 能显示版本。
  3. Lumoswitch 配置 API 已启用 Responses 输出。

codex 命令不存在,先选择一种官方安装方式。npm 方式适用于 Windows、macOS 与 Linux:

# macOS / Linux 独立安装器
curl -fsSL https://chatgpt.com/codex/install.sh | sh

# Windows、macOS 或 Linux 使用 npm
npm install -g @openai/codex

# 或使用 Homebrew
brew install --cask codex

安装 ChatGPT 桌面端并不代表已经安装 Codex CLI。

2. 准备四个配置值

配置命令中的占位符需要替换为配置 API 完成页所显示的真实值:

占位符填写内容
{{api_base_url}}Lumoswitch API 地址,通常以 /v1 结尾
{{access_key}}配置 API 的 Access Key,请勿分享或提交到 Git
{{model}}完成页显示的下游模型名称,不是上游平台的展示名称
{{codex_model_catalog_base64}}按有效能力生成并经 Base64 编码的 Codex 模型目录

3. 永久使用(共用配置)

此方式只提供永久配置。临时环境变量无法稳定传给从 Finder、Dock 或 IDE 启动的图形应用。

桌面端单独使用与桌面端 + CLI 共用在各操作系统上必须使用完全相同的配置命令,这样两种方式不会在后续维护中产生不同的合并行为。macOS 中用 Command + Q 完全退出 ChatGPT,结束所有正在运行的 Codex CLI 会话,再替换下面四个占位符并在可信终端中运行完整命令:

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

该命令会备份并合并 ~/.codex/config.toml,安装由 Lumoswitch 生成且经 ChatGPT 内置 Codex 校验的能力感知目录。目录只开放模型与实际路由共同支持的推理档位,没有可信请求侧控制能力的模型默认使用 none。命令还会设置顶层 web_search = "disabled",并在已有或新建的 [features] 中设置 multi_agent = false,关闭 Codex 多智能体 namespace 与原生网页搜索;普通函数工具、终端命令和文件操作仍可使用。Access Key 会写入权限为 0600~/.codex/.env,其他现有配置会保留;遇到无法安全合并的配置时,命令会停止而不会静默覆盖。

Windows 原生 PowerShell 导入

当 ChatGPT 使用默认的 Windows 原生 Agent 环境时,Windows 桌面端、原生 Codex CLI 和 IDE 扩展会共享 %USERPROFILE%\.codex 中的用户级 Codex 配置。如果 设置 > Agent 环境 已切换为 WSL,请先改回 Windows 原生 并重启 ChatGPT,因为 WSL 默认使用另一套 Codex 主目录。完全退出 ChatGPT 并结束所有 Codex CLI 会话后,在可信的 PowerShell 终端中运行以下完整命令。

& {
  $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)
}

命令会动态发现 ChatGPT 的开始菜单 App ID,不依赖固定的 Microsoft Store 包名;写入前验证模型目录和目标模型,按与 macOS 相同的保守规则合并 TOML,并用 Windows ACL 限制模型目录、配置、凭证和备份文件的访问权限。

4. 日常启动

ChatGPT 桌面端

像平时一样打开 ChatGPT,再进入 Codex;也可以运行:

open -a ChatGPT

Codex CLI

进入目标项目目录,直接运行:

codex

在 CLI 中运行 /status 可以确认当前模型和提供方。

新任务使用生成目录中的有效默认档位。/reasoning 只列出经过校验的档位;DeepSeek 这类开关式推理模型只显示一个等效的开启档位。

Codex IDE 扩展

修改配置后完全重启 IDE 或重新加载 Codex 扩展,再新建任务。IDE 扩展直接读取相同的 ~/.codex 配置,不需要维护另一份命令。

使用入口读取的配置配置变化后怎么做
桌面端 Codex配置、凭证与 Lumoswitch 模型目录完全退出 ChatGPT 后重新打开
普通 Codex CLI相同的三个文件退出旧会话,在新终端运行 codex
Codex IDE 扩展相同的三个文件完全重启 IDE,再新建任务
普通 ChatGPT 对话不读取不受影响

更换 Access Key、地址或下游模型名称后,重新运行本页的永久使用命令,并完全重启正在使用的 Codex 客户端。桌面端和 CLI 的模型选择器都应只显示完成页给出的下游模型;发送请求后,再确认 Lumoswitch 日志中的模型名称完全一致。

5. 清除接入

清除会同时影响 ChatGPT 桌面端中的 Codex、普通 Codex CLI 和 Codex IDE 扩展。

macOS

先用 Command + Q 完全退出 ChatGPT,并结束所有 Codex CLI 会话,再运行:

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 "请先用 Command + Q 完全退出 ChatGPT,再重新运行此命令。" >&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 "检测到 Codex 配置、凭证或 Lumoswitch 模型目录是符号链接,已停止自动清除。请手动修改链接目标。" >&2
  exit 1
fi

if [ -e "$LUMOSWITCH_CODEX_CATALOG" ] && [ ! -f "$LUMOSWITCH_CODEX_CATALOG" ]; then
  echo "Lumoswitch 模型目录路径已被非普通文件占用,已停止自动清除:$LUMOSWITCH_CODEX_CATALOG" >&2
  exit 1
fi

LUMOSWITCH_CATALOG_LITERAL="$(/usr/bin/osascript -l JavaScript - "$LUMOSWITCH_CODEX_CATALOG" <<'LUMOSWITCH_REMOVE_PATH_JXA'
function run(argv) {
  return JSON.stringify(argv[0]);
}
LUMOSWITCH_REMOVE_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 }
  /^[[: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
  }
  END { exit found ? 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 [ "$LUMOSWITCH_REMOVE_CATALOG_SETTING" = "1" ] && [ -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 接入已清除;清除前备份后缀:%s\n' "$LUMOSWITCH_BACKUP_SUFFIX"
open -a ChatGPT

命令会先生成不含 Lumoswitch Key 的安全备份,再只移除 Lumoswitch 相关配置。如果顶层 model_provider 仍指向 lumoswitch,它会同时移除顶层模型、提供方和旧版接入留下的 model_reasoning_effort;只有 web_search 仍精确等于 "disabled"[features] 中的 multi_agent 仍精确等于 false 时才移除这两个兼容值,用户后来改过的值和其他 feature 项都会保留,空的 [features] 表也可以保留。只有 model_catalog_json 精确指向 Lumoswitch 专属文件时,才会移除该键并删除该普通文件;其他自定义模型目录不会被碰触。符号链接不会被跟随。自动清除无法知道接入前被覆盖的默认值;需要精确恢复时,只能在确认接入后没有其他改动的前提下,手动恢复安装命令生成的同一 config.toml 后缀备份。Access Key 被有意排除在备份之外,重新接入时需要重新填写。

Windows PowerShell

清理会同时影响三个共用入口。完全退出 ChatGPT 并结束所有 Codex CLI 会话,然后在 PowerShell 中运行:

& {
  $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)
  }
}

Windows 命令会对 %USERPROFILE%\\.codex 应用同样的保守清理规则,生成不含 Access Key 且受 ACL 保护的备份;如果系统中仍安装 ChatGPT,则通过动态发现的开始菜单 App ID 重新打开。

6. 什么时候不要共用

如果你希望桌面端继续使用原来的提供方,而只有 CLI 使用 Lumoswitch,请使用 Codex CLI 原生接入。它通过专属 CODEX_HOME 隔离终端配置,不修改共享配置。

如果你目前只使用桌面端,也没有安装 CLI,请使用更简单的 ChatGPT 桌面端(Codex)接入。两种场景底层使用同一个永久配置模板,但桌面端专页不会要求安装或验证 CLI。

发布维护字段
  • 平台 ID:codex-desktop-cli
  • 展示名称:ChatGPT Desktop + Codex CLI
  • 下游协议:responses
  • macOS/Linux 临时模式:关闭;commandTemplate 留空
  • macOS 永久策略:native
  • persistentCommandTemplate:复制本页「永久使用(共用配置)」代码框的完整内容;发布前确认它与 chatgpt-desktop 记录逐字一致
  • macOS futureCommandcodex
  • Windows 临时模式:关闭;windowsCommandTemplate 留空
  • Windows 永久策略:native
  • windowsPersistentCommandTemplate:复制本页「Windows 原生 PowerShell 导入」代码框的完整内容;发布前确认它与 chatgpt-desktop 的 Windows 记录逐字一致
  • Windows windowsFutureCommandcodex
  • 展示顺序:16
  • 发布限制:macOS 与 Windows 模板只发布到各自标记的操作系统,并在每个平台分别验证桌面端和 CLI
  • 核验状态:官方配置契约已于 2026-08-27 重新核验;最近一次使用 ChatGPT 内置 Codex 验证专属目录只返回指定模型是在 2026-08-03;Windows PowerShell 已有结构与双语模板契约测试,真实 Windows 双端 Lumoswitch 流量仍待复核

常见问题

  • 桌面端生效但 CLI 没生效:完全退出旧 CLI 会话,在新终端运行普通 codex;不要继续使用旧的 Lumoswitch 专属启动器测试共用模式。
  • CLI 生效但桌面端没生效:完全退出 ChatGPT(macOS 使用 Command + Q,Windows 从应用菜单退出)后重新打开。
  • 模型选择器仍只有 GPT 模型:重新运行永久命令,确认没有“其他自定义模型目录”提示,再完全重启两端。
  • 两端显示不同模型:确认项目级 .codex/config.toml 或 CLI 启动参数没有覆盖用户级模型目录和默认模型。
  • 仍提示 The inference request is invalid:先确认模型名称和 Responses 输出,再重新执行一键导入刷新能力目录;仍失败时使用对应的 Lumoswitch 请求日志定位被拒绝的字段。
  • 需要恢复原配置:使用配置命令输出的备份后缀,把对应 .backup.<后缀> 文件复制回原路径。

参考:ChatGPT Windows 桌面端Codex CLICodex 自定义模型提供方Codex 模型目录桌面端与 IDE 环境变量

On this page