修复 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]