爱分享 发表于 昨天 15:05

修复 CCSwitch + CPA 在CODEX上配置的gpt模型,思考过程、图片、fast模式等都不支持

最近在折腾 Codex 对接 CPA 的账号池,链路大概是:

```plaintext
Codex → CC Switch → CPA 自定义 Responses 地址 → Codex 模型
```

CPA 里的模型是我自己在 CC Switch 里手动添加的。目前添加模型时能填写的内容比较少,基本只有:

```plaintext
模型 ID
上下文大小
```

后来我看了一下 CC Switch 生成的:

```plaintext
%USERPROFILE%\.codex\cc-switch-model-catalog.json
```

发现它和 Codex CLI 自己带的模型目录差别还挺大,让 AI 分析了一下。
官方目录可以用下面的命令查看:

```
codex debug models --bundled
```

## 发现的几个差异

CC Switch 生成的模型基本是套一个通用模板,而官方模型目录里有更多模型自己的配置。

比较明显的有:

* `base_instructions`:CC Switch 里只有一小段通用 Prompt,官方模型里的内容长很多;
* `model_messages`:官方目录里有完整的指令模板和 personality 配置,CC Switch 生成的目录里没有;
* `supported_reasoning_levels`:CC Switch 原来所有模型都是统一的 `none / high`,官方不同模型支持的等级并不一样;
* `input_modalities`:支持图片的官方模型是 `text,image`,CC Switch 原来通常只有 `text`;
* `supports_image_detail_original`:官方部分模型为 `true`,CC Switch 原来是 `false`;
* `supports_parallel_tool_calls`、`support_verbosity`、`default_verbosity` 等工具和输出相关字段也有差异;
* `truncation_policy`、`service_tiers`、`additional_speed_tiers` 等字段也不是完全一致。

这个问题不知道论坛里面有没有其他人发现,我的使用方法不对?CCswitch 是不是没考虑覆盖这种场景。

## 临时让 AI 写了个脚本合并配置文件

思路比较简单:

1. 读取 `codex debug models --bundled` 的官方目录;
2. 从官方目录动态找出 `gpt-*` 模型;
3. 用自定义模型的 `slug` 去包含匹配官方模型 ID;
4. 匹配到以后,用官方配置覆盖当前条目;
5. 但保留自定义模型原来的 `slug`,避免影响 CPA 路由;
6. 修改前自动备份,并支持只验证和预演。

比如:

```plaintext
自定义 slug:<provider-prefix>/gpt-5.6-luna
官方 slug:gpt-5.6-luna
```

脚本会使用官方 `gpt-5.6-luna` 的配置,但最后仍然保留自定义的 slug。

## 脚本

```

param(
   
    $CatalogPath = (Join-Path $env:USERPROFILE '.codex\cc-switch-model-catalog.json'),

   
    $CodexCommand = 'codex.cmd',

   
    $ValidateOnly,

   
    $WhatIf
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Read-JsonFile {
    param(
      
      $Path
    )

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
      throw "Catalog file not found: $Path"
    }

    $text = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
    if (::IsNullOrWhiteSpace($text)) {
      throw "Catalog file is empty: $Path"
    }

    return ($text | ConvertFrom-Json)
}

function Get-OfficialCatalog {
    param(
      
      $Command
    )

    $commandInfo = Get-Command $Command -ErrorAction Stop | Select-Object -First 1
    $commandPath = $commandInfo.Source
    if (::IsNullOrWhiteSpace($commandPath)) {
      $commandPath = $commandInfo.Definition
    }

    if (::IsNullOrWhiteSpace($commandPath)) {
      throw "Unable to resolve Codex command: $Command"
    }

    $utf8 = ::new($false)
    $startInfo = New-Object System.Diagnostics.ProcessStartInfo
    $startInfo.UseShellExecute = $false
    $startInfo.CreateNoWindow = $true
    $startInfo.RedirectStandardOutput = $true
    $startInfo.RedirectStandardError = $true
    $startInfo.StandardOutputEncoding = $utf8
    $startInfo.StandardErrorEncoding = $utf8

    # ProcessStartInfo cannot execute .cmd/.bat files directly when shell
    # execution is disabled, so invoke those through cmd.exe. The encoding is
    # configured on the redirected streams before the child process starts.
    if ($commandPath -match '\.(cmd|bat)$') {
      $startInfo.FileName = $env:ComSpec
      $startInfo.Arguments = '/d /s /c ""' + $commandPath + '" debug models --bundled"'
    }
    else {
      $startInfo.FileName = $commandPath
      $startInfo.Arguments = 'debug models --bundled'
    }

    $process = New-Object System.Diagnostics.Process
    $process.StartInfo = $startInfo
    try {
      if (-not $process.Start()) {
            throw "Failed to start Codex command: $commandPath"
      }

      # Read both streams asynchronously so a full stderr buffer cannot
      # deadlock the child process while stdout is being consumed.
      $stdoutTask = $process.StandardOutput.ReadToEndAsync()
      $stderrTask = $process.StandardError.ReadToEndAsync()
      $process.WaitForExit()

      $exitCode = $process.ExitCode
      $output = $stdoutTask.Result
      $stderr = $stderrTask.Result
    }
    finally {
      $process.Dispose()
    }

    if ($exitCode -ne 0) {
      throw "Failed to read the Codex bundled catalog. Exit code: $exitCode`n$stderr"
    }

    if (::IsNullOrWhiteSpace($output)) {
      throw "Codex bundled catalog command returned empty stdout."
    }

    try {
      return ($output | ConvertFrom-Json)
    }
    catch {
      throw "Codex bundled catalog is not valid JSON: $($_.Exception.ToString())"
    }
}

function Copy-JsonValue {
    param(
      
      $Value
    )

    return ($Value | ConvertTo-Json -Depth 100 | ConvertFrom-Json)
}

function Find-OfficialModel {
    param(
      
      $CustomSlug,

      
      ]$OfficialModels
    )

    # Only use gpt-* entries and prefer longer, more specific slugs.
    $candidates = @($OfficialModels | Where-Object { $_.slug -like 'gpt-*' })
    $candidates = @($candidates | Sort-Object -Property @{ Expression = { $_.slug.Length }; Descending = $true })

    foreach ($officialModel in $candidates) {
      if ($CustomSlug.IndexOf(
                $officialModel.slug,
                ::OrdinalIgnoreCase
            ) -ge 0) {
            return $officialModel
      }
    }

    return $null
}

function Get-MergedCatalog {
    param(
      
      $CustomCatalog,

      
      $OfficialCatalog
    )

    if ($null -eq $CustomCatalog.models -or $CustomCatalog.models.Count -eq 0) {
      throw 'Custom catalog has no models.'
    }

    if ($null -eq $OfficialCatalog.models -or $OfficialCatalog.models.Count -eq 0) {
      throw 'Official catalog has no models.'
    }

    $results = @()
    $report = @()

    foreach ($customModel in $CustomCatalog.models) {
      $customSlug = $customModel.slug
      if (::IsNullOrWhiteSpace($customSlug)) {
            throw 'A custom model is missing slug.'
      }

      $officialModel = Find-OfficialModel `
            -CustomSlug $customSlug `
            -OfficialModels @($OfficialCatalog.models)

      if ($null -eq $officialModel) {
            $results += $customModel
            $report += @{
                Model = $customSlug
                Status = 'Skipped'
                MatchedOfficialModel = $null
            }
            continue
      }

      # Use the complete official model configuration.
      $result = Copy-JsonValue -Value $officialModel

      # Match by the official slug but preserve the custom routing ID.
      $result.slug = $customSlug
      if ($customSlug -ne $officialModel.slug) {
            $result.display_name = $customSlug
      }

      $results += $result
      $report += @{
            Model = $customSlug
            Status = 'Merged'
            MatchedOfficialModel = $officialModel.slug
      }
    }

    return @{
      Catalog = @{ models = @($results) }
      Report = @($report)
    }
}

function Write-CatalogAtomically {
    param(
      
      $Path,

      
      $Catalog
    )

    $backupPath = "$Path.bak-official-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
    $tempPath = "$Path.tmp"
    $json = $Catalog | ConvertTo-Json -Depth 100

    Copy-Item -LiteralPath $Path -Destination $backupPath -Force
    ::WriteAllText(
      $tempPath,
      $json + ::NewLine,
      ::new($false)
    )

    # Parse the temporary file before replacing the original.
    Get-Content -LiteralPath $tempPath -Raw -Encoding UTF8 |
      ConvertFrom-Json |
      Out-Null

    Move-Item -LiteralPath $tempPath -Destination $Path -Force
    return $backupPath
}

$customCatalog = Read-JsonFile -Path $CatalogPath
$officialCatalog = Get-OfficialCatalog -Command $CodexCommand
$mergeResult = Get-MergedCatalog `
    -CustomCatalog $customCatalog `
    -OfficialCatalog $officialCatalog

$mergeResult.Report | Format-Table Model, Status, MatchedOfficialModel -AutoSize

$skipped = @($mergeResult.Report | Where-Object Status -eq 'Skipped')
if ($skipped.Count -gt 0) {
    Write-Warning "The following models did not match an official gpt-* entry and were kept unchanged:"
    $skipped | Format-Table Model -AutoSize
}

if ($ValidateOnly) {
    Write-Host "Validation completed without modifying: $CatalogPath"
    exit 0
}

if ($WhatIf) {
    Write-Host "WhatIf: would merge the Codex bundled catalog into: $CatalogPath"
    exit 0
}

$backupPath = Write-CatalogAtomically `
    -Path $CatalogPath `
    -Catalog $mergeResult.Catalog

Write-Host "Merge completed: $CatalogPath"
Write-Host "Backup: $backupPath"


```

## 使用方法

先只验证,不改文件:

```
.\tools\merge-codex-model-catalog.ps1 -ValidateOnly
```

确认匹配结果没问题后,可以预演一次:

```
.\tools\merge-codex-model-catalog.ps1 -WhatIf
```

最后再实际执行:

```
.\tools\merge-codex-model-catalog.ps1
```

脚本会自动备份原来的模型目录。如果 CPA 不支持官方目录里声明的某些工具或能力,可以用备份恢复,再单独调整相关字段。

效果,这是直接配置的 CPA 地址

[!(https://cdn3.ldstatic.com/optimized/4X/e/5/3/e53b126483d7e7b55f0db42e104f02c75d79b439_2_690x63.jpeg)image**1782×165 21.3 KB**](https://cdn3.ldstatic.com/original/4X/e/5/3/e53b126483d7e7b55f0db42e104f02c75d79b439.jpeg "image")

[!(https://cdn3.ldstatic.com/optimized/4X/3/d/6/3d69f298b7f67f195811c3fc12e920ad55a9fcaa_2_690x158.png)image**1216×279 28.6 KB**](https://cdn3.ldstatic.com/original/4X/3/d/6/3d69f298b7f67f195811c3fc12e920ad55a9fcaa.png "image")
页: [1]
查看完整版本: 修复 CCSwitch + CPA 在CODEX上配置的gpt模型,思考过程、图片、fast模式等都不支持