Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e50fc0eb28 | |||
| f9547da930 | |||
| 844bbcf8c4 | |||
| 16922917fb | |||
| 88f1a78e3a | |||
| 90bc37449c | |||
| de386b297f | |||
| e6d40d0a93 | |||
| dd6a951c34 | |||
| 430ddf92bd | |||
| 8a91428b71 | |||
| 6180c4ba12 | |||
| f8768c5885 | |||
| 7d74d2449a | |||
| 273cc48c57 | |||
| f1328d89fc | |||
| a4eb5f3778 | |||
| 213f55a6d2 | |||
| fdf538e52d | |||
| 2e547c98b1 | |||
| 1a0e8c7a44 | |||
| 524b9d9825 | |||
| 6fd4e6f462 | |||
| edeee25fae | |||
| 55abd1822c | |||
| 9171ad752c | |||
| 2db3163cf2 | |||
| 169d58ac6f | |||
| 2781d40e82 | |||
| 2d478876fa | |||
| 895d7af3eb | |||
| 92930461b7 | |||
| c797b2a3fb | |||
| c4630b5c26 | |||
| a3b3a06f5e | |||
| e648b7425e | |||
| c26d867f79 | |||
| 26be9324dd | |||
| 142c8c1da7 | |||
| 6ec86cff46 | |||
| 455556380b | |||
| 510aa250c5 | |||
| d1b5df9a7b | |||
| dfa03b746a | |||
| 3d30dc03e8 | |||
| 95495ad19e | |||
| b724f5dde9 | |||
| 69341db284 | |||
| 0bb3b7515a | |||
| 81d6002ace | |||
| caa912e3a3 | |||
| 1042fb2a32 | |||
| de0a149df5 | |||
| 36b557b2e6 | |||
| 3e38889ccc | |||
| 12567b3229 | |||
| c548e7fcba | |||
| 21e695f68a | |||
| c678374c59 | |||
| d307df92e9 | |||
| 5092fe51ce | |||
| 34001aaffc | |||
| 780acfa7de | |||
| 6bda3b0131 | |||
| 89ff2d58d1 | |||
| 9a3d6a4e84 | |||
| fee354d09e | |||
| a5903d8600 | |||
| b7ad1c4bf8 | |||
| 0c46efe1be | |||
| 11572b1337 | |||
| 3bb17434fb | |||
| efbb52a3fc | |||
| b37a9e8f60 | |||
| 1992d6be72 | |||
| 276b2572a3 | |||
| cd8252c7d9 | |||
| 526bb60f5c | |||
| 4f8a79c273 | |||
| 6e519a7496 | |||
| a95b22dd79 | |||
| eab6bfd20c | |||
| 948d762792 | |||
| 4f355970e1 | |||
| 22ecd2d611 | |||
| a75f479576 | |||
| c701068f0c | |||
| c7efa77ad9 | |||
| 2d64d8c619 | |||
| d70e3828fe | |||
| 7bb59fa5a6 | |||
| 4f3e85fcd1 | |||
| a3598fd976 | |||
| c1aa6c3917 | |||
| daa5595f36 | |||
| 819c2e5dfe | |||
| a6d718d0fc | |||
| e776160912 | |||
| 596019505f | |||
| 8b925c2f2f | |||
| 25983f3420 | |||
| ff706e9e96 | |||
| e8b07cb2a5 | |||
| feea81e5bb | |||
| 4282856683 | |||
| 65d6929993 | |||
| 1ca01bcd10 | |||
| bc01f44514 | |||
| 3390fe7ea0 | |||
| cb01593f7d | |||
| 36a103bbe4 | |||
| 05bc14e82b | |||
| 0396cd5491 | |||
| f97347fe6e | |||
| 9ea303b224 | |||
| 4f911727d2 | |||
| edc597ab23 |
@@ -16,9 +16,12 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- os: windows-2022
|
- os: windows-2022
|
||||||
|
- os: windows-11-arm
|
||||||
|
arch: arm64
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
- os: ubuntu-22.04-arm
|
- os: ubuntu-22.04-arm
|
||||||
arch: arm64
|
arch: arm64
|
||||||
@@ -36,6 +39,11 @@ jobs:
|
|||||||
- name: Setup Rust
|
- name: Setup Rust
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Add Windows ARM64 target
|
||||||
|
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||||
|
shell: pwsh
|
||||||
|
run: rustup target add aarch64-pc-windows-msvc
|
||||||
|
|
||||||
- name: Add macOS targets
|
- name: Add macOS targets
|
||||||
if: runner.os == 'macOS'
|
if: runner.os == 'macOS'
|
||||||
run: |
|
run: |
|
||||||
@@ -74,23 +82,50 @@ jobs:
|
|||||||
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
|| sudo apt-get install -y --no-install-recommends libsoup2.4-dev
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
|
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
with:
|
with:
|
||||||
version: 10.12.3
|
version: 10.12.3
|
||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
|
- name: Setup pnpm (Windows ARM64)
|
||||||
|
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
corepack enable
|
||||||
|
corepack prepare pnpm@10.12.3 --activate
|
||||||
|
node --version
|
||||||
|
pnpm --version
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
- name: Get pnpm store directory
|
||||||
|
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||||
id: pnpm-store
|
id: pnpm-store
|
||||||
shell: bash
|
shell: bash
|
||||||
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
run: echo "path=$(pnpm store path --silent)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Setup pnpm cache
|
- name: Setup pnpm cache
|
||||||
|
if: runner.os != 'Windows' || matrix.arch != 'arm64'
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
restore-keys: ${{ runner.os }}-${{ runner.arch }}-pnpm-store-
|
||||||
|
|
||||||
|
- name: Setup LLVM for Windows ARM64
|
||||||
|
if: runner.os == 'Windows' && matrix.arch == 'arm64'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$llvmRoot = 'C:\Program Files\LLVM'
|
||||||
|
if (-not (Test-Path $llvmRoot)) {
|
||||||
|
throw "LLVM not found at $llvmRoot"
|
||||||
|
}
|
||||||
|
$llvmBin = Join-Path $llvmRoot 'bin'
|
||||||
|
"LIBCLANG_PATH=$llvmBin" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
"CLANG_PATH=$(Join-Path $llvmBin 'clang.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||||
|
$llvmBin | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||||
|
|
||||||
- name: Install frontend deps
|
- name: Install frontend deps
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -223,7 +258,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Tauri App (Windows)
|
- name: Build Tauri App (Windows)
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
run: pnpm tauri build
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if ($env:WINDOWS_RELEASE_ARCH -eq 'arm64') {
|
||||||
|
pnpm tauri build --target aarch64-pc-windows-msvc --bundles msi
|
||||||
|
} else {
|
||||||
|
pnpm tauri build
|
||||||
|
}
|
||||||
|
|
||||||
- name: Build Tauri App (Linux)
|
- name: Build Tauri App (Linux)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
@@ -394,50 +438,75 @@ jobs:
|
|||||||
- name: Prepare Windows Assets
|
- name: Prepare Windows Assets
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
WINDOWS_RELEASE_ARCH: ${{ matrix.arch || 'x86_64' }}
|
||||||
run: |
|
run: |
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
New-Item -ItemType Directory -Force -Path release-assets | Out-Null
|
||||||
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
$VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0
|
||||||
|
$isArm64 = $env:WINDOWS_RELEASE_ARCH -eq 'arm64'
|
||||||
|
$targetRoot = if ($isArm64) { 'src-tauri/target/aarch64-pc-windows-msvc/release' } else { 'src-tauri/target/release' }
|
||||||
|
$assetSuffix = if ($isArm64) { '-arm64' } else { '' }
|
||||||
|
|
||||||
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
# 仅打包 MSI 安装器 + .sig(用于 Updater)
|
||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle/msi' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle/msi') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
if ($null -eq $msi) {
|
if ($null -eq $msi) {
|
||||||
# 兜底:全局搜索 .msi
|
# 兜底:全局搜索 .msi
|
||||||
$msi = Get-ChildItem -Path 'src-tauri/target/release/bundle' -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
$msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||||
}
|
}
|
||||||
if ($null -ne $msi) {
|
if ($null -ne $msi) {
|
||||||
$dest = "CC-Switch-$VERSION-Windows.msi"
|
$dest = "CC-Switch-$VERSION-Windows$assetSuffix.msi"
|
||||||
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
Copy-Item $msi.FullName (Join-Path release-assets $dest)
|
||||||
Write-Host "Installer copied: $dest"
|
Write-Host "Installer copied: $dest"
|
||||||
$sigPath = "$($msi.FullName).sig"
|
$sigPath = "$($msi.FullName).sig"
|
||||||
if (Test-Path $sigPath) {
|
if (Test-Path $sigPath) {
|
||||||
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
Copy-Item $sigPath (Join-Path release-assets ("$dest.sig"))
|
||||||
Write-Host "Signature copied: $dest.sig"
|
Write-Host "Signature copied: $dest.sig"
|
||||||
|
} elseif ($isArm64) {
|
||||||
|
throw "Signature not found for $($msi.Name)"
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "Signature not found for $($msi.Name)"
|
Write-Warning "Signature not found for $($msi.Name)"
|
||||||
}
|
}
|
||||||
|
} elseif ($isArm64) {
|
||||||
|
throw 'No Windows ARM64 MSI installer found'
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'No Windows MSI installer found'
|
Write-Warning 'No Windows MSI installer found'
|
||||||
}
|
}
|
||||||
|
|
||||||
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
# 绿色版(portable):仅可执行文件打 zip(不参与 Updater)
|
||||||
$exeCandidates = @(
|
$exeCandidates = if ($isArm64) {
|
||||||
'src-tauri/target/release/cc-switch.exe',
|
@('src-tauri/target/aarch64-pc-windows-msvc/release/cc-switch.exe')
|
||||||
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
} else {
|
||||||
)
|
@(
|
||||||
|
'src-tauri/target/release/cc-switch.exe',
|
||||||
|
'src-tauri/target/x86_64-pc-windows-msvc/release/cc-switch.exe'
|
||||||
|
)
|
||||||
|
}
|
||||||
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||||
if ($null -ne $exePath) {
|
if ($null -ne $exePath) {
|
||||||
$portableDir = 'release-assets/CC-Switch-Portable'
|
$portableDir = 'release-assets/CC-Switch-Portable'
|
||||||
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $portableDir | Out-Null
|
||||||
Copy-Item $exePath $portableDir
|
Copy-Item $exePath $portableDir
|
||||||
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
$portableIniPath = Join-Path $portableDir 'portable.ini'
|
||||||
$portableContent = @(
|
$portableContent = if ($isArm64) {
|
||||||
'# CC Switch portable build marker',
|
@(
|
||||||
'portable=true'
|
'# CC Switch portable ARM64 build marker',
|
||||||
)
|
'portable=true',
|
||||||
|
'arch=arm64'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@(
|
||||||
|
'# CC Switch portable build marker',
|
||||||
|
'portable=true'
|
||||||
|
)
|
||||||
|
}
|
||||||
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
$portableContent | Set-Content -Path $portableIniPath -Encoding UTF8
|
||||||
$portableZip = "release-assets/CC-Switch-$VERSION-Windows-Portable.zip"
|
$portableZip = "release-assets/CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
|
||||||
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
Compress-Archive -Path "$portableDir/*" -DestinationPath $portableZip -Force
|
||||||
Remove-Item -Recurse -Force $portableDir
|
Remove-Item -Recurse -Force $portableDir
|
||||||
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows-Portable.zip"
|
Write-Host "Windows portable zip created: CC-Switch-$VERSION-Windows$assetSuffix-Portable.zip"
|
||||||
|
} elseif ($isArm64) {
|
||||||
|
throw 'Portable ARM64 exe not found'
|
||||||
} else {
|
} else {
|
||||||
Write-Warning 'Portable exe not found'
|
Write-Warning 'Portable exe not found'
|
||||||
}
|
}
|
||||||
@@ -550,7 +619,8 @@ jobs:
|
|||||||
### 下载
|
### 下载
|
||||||
|
|
||||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)
|
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.dmg`(推荐)或 `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)
|
||||||
- **Windows**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
- **Windows (x86_64)**: `CC-Switch-${{ github.ref_name }}-Windows.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-Portable.zip`(绿色版)
|
||||||
|
- **Windows (ARM64)**: `CC-Switch-${{ github.ref_name }}-Windows-arm64.msi`(安装版)或 `CC-Switch-${{ github.ref_name }}-Windows-arm64-Portable.zip`(绿色版)
|
||||||
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
- **Linux (x86_64)**: `CC-Switch-${{ github.ref_name }}-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
- **Linux (ARM64)**: `CC-Switch-${{ github.ref_name }}-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
@@ -594,7 +664,8 @@ jobs:
|
|||||||
base_url="https://github.com/$REPO/releases/download/$TAG"
|
base_url="https://github.com/$REPO/releases/download/$TAG"
|
||||||
# 初始化空平台映射
|
# 初始化空平台映射
|
||||||
mac_url=""; mac_sig=""
|
mac_url=""; mac_sig=""
|
||||||
win_url=""; win_sig=""
|
win_x64_url=""; win_x64_sig=""
|
||||||
|
win_arm64_url=""; win_arm64_sig=""
|
||||||
linux_x64_url=""; linux_x64_sig=""
|
linux_x64_url=""; linux_x64_sig=""
|
||||||
linux_arm64_url=""; linux_arm64_sig=""
|
linux_arm64_url=""; linux_arm64_sig=""
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
@@ -607,12 +678,14 @@ jobs:
|
|||||||
*.tar.gz)
|
*.tar.gz)
|
||||||
# 视为 macOS updater artifact
|
# 视为 macOS updater artifact
|
||||||
mac_url="$url"; mac_sig="$sig_content";;
|
mac_url="$url"; mac_sig="$sig_content";;
|
||||||
|
*-Windows-arm64.msi)
|
||||||
|
win_arm64_url="$url"; win_arm64_sig="$sig_content";;
|
||||||
|
*-Windows.msi)
|
||||||
|
win_x64_url="$url"; win_x64_sig="$sig_content";;
|
||||||
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
|
*-Linux-arm64.AppImage|*-Linux-arm64.appimage)
|
||||||
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
|
linux_arm64_url="$url"; linux_arm64_sig="$sig_content";;
|
||||||
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
|
*-Linux-x86_64.AppImage|*-Linux-x86_64.appimage)
|
||||||
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
|
linux_x64_url="$url"; linux_x64_sig="$sig_content";;
|
||||||
*.msi|*.exe)
|
|
||||||
win_url="$url"; win_sig="$sig_content";;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
# 构造 JSON(仅包含存在的目标)
|
# 构造 JSON(仅包含存在的目标)
|
||||||
@@ -632,9 +705,14 @@ jobs:
|
|||||||
first=0
|
first=0
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if [ -n "$win_url" ] && [ -n "$win_sig" ]; then
|
if [ -n "$win_x64_url" ] && [ -n "$win_x64_sig" ]; then
|
||||||
[ $first -eq 0 ] && echo ','
|
[ $first -eq 0 ] && echo ','
|
||||||
echo " \"windows-x86_64\": {\"signature\": \"$win_sig\", \"url\": \"$win_url\"}"
|
echo " \"windows-x86_64\": {\"signature\": \"$win_x64_sig\", \"url\": \"$win_x64_url\"}"
|
||||||
|
first=0
|
||||||
|
fi
|
||||||
|
if [ -n "$win_arm64_url" ] && [ -n "$win_arm64_sig" ]; then
|
||||||
|
[ $first -eq 0 ] && echo ','
|
||||||
|
echo " \"windows-aarch64\": {\"signature\": \"$win_arm64_sig\", \"url\": \"$win_arm64_url\"}"
|
||||||
first=0
|
first=0
|
||||||
fi
|
fi
|
||||||
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
|
if [ -n "$linux_x64_url" ] && [ -n "$linux_x64_sig" ]; then
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ release/
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.npmrc
|
.npmrc
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
# AGENTS.md
|
AGENTS.md
|
||||||
GEMINI.md
|
GEMINI.md
|
||||||
/.claude
|
/.claude
|
||||||
/.codex
|
/.codex
|
||||||
|
|||||||
@@ -5,6 +5,122 @@ All notable changes to CC Switch will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.16.4] - 2026-06-27
|
||||||
|
|
||||||
|
Development since v3.16.3 focuses on tightening the Codex proxy path — native OpenAI Responses migration for the major Chinese providers, a decoupled upstream-format selector, zstd request/error-body decompression, and a run of tool-call and OAuth-over-proxy fixes — alongside richer usage and pricing tooling (models.dev pricing import, Volcengine Ark coding/agent-plan quotas, live-tracking date ranges, GLM-5.2/Doubao Seed 2.1 pricing), new proxy and resilience capabilities (custom request header/body overrides, an in-app recovery screen for too-new databases, native Windows ARM64 builds), and a broad wave of preset and branding updates (the SubRouter and OpenCode Go subscriptions, the CTok→ETok rename, Kimi rebranding and prime-partner badges, and a Kimi K2.7 Code sponsor banner).
|
||||||
|
|
||||||
|
**Stats**: 53 commits | 126 files changed | +8,149 insertions | -1,016 deletions
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **In-App Recovery Screen for a Too-New Database**: When the SQLite `user_version` is newer than the app supports (`SCHEMA_VERSION`) — e.g. after a downgrade or because a third-party client wrote the file — startup used to dead-end in a native Retry/Exit dialog where Retry just failed again. The app now boots a dedicated recovery screen offering an in-place "Upgrade app" button (download + install + restart with a progress bar) when an update is available, or a warning that even the latest build can't read the database when none is. The too-new check runs before any schema writes so the app never runs DDL against a database it can't understand, and native-close quits cleanly in recovery mode where no tray exists. (#4575)
|
||||||
|
- **Local Proxy Request Overrides (Custom Headers and Body)**: Provider configs can now define custom request headers and request-body overrides that the local proxy applies when forwarding, exposed via a new field in the Claude and Codex provider forms. Inputs are validated, including a protected-header-name list that blocks overriding security-sensitive headers. (#4589)
|
||||||
|
- **Volcengine Ark Coding/Agent Plan Usage Query**: Usage panels can now query coding-plan and agent-plan quota for Volcengine Ark. Because the Ark control-plane OpenAPI (`open.volcengineapi.com`) requires account-level AccessKey signing rather than the inference API key, the usage script gains a dedicated AK/SK input block, with a clickable link straight to the Volcengine IAM key-management console (`https://console.volcengine.com/iam/keymanage`), and the proxy implements Volcengine Signature V4 (an AWS SigV4 variant with fixed canonical header order, `HMAC-SHA256` algorithm, and `ark` service scope). It auto-detects the plan by probing `GetAFPUsage` (Agent Plan five-hour/weekly/monthly quotas) before falling back to `GetCodingPlanUsage`, parses the window label from the `Level` field (guarding `ResetTimestamp <= 0`), and adds a `monthly` tier label across the footer, tray menu, and all four locales.
|
||||||
|
- **Import Model Pricing from models.dev**: The Add Pricing panel gains an "Import from models.dev" button that fetches `https://models.dev/api.json`, lets users full-text search the catalog, and imports the selected entry through the same `update_model_pricing` path as manual entry. Imported model IDs are normalized to match the backend's `clean_model_id_for_pricing` rules (strip vendor prefix, lowercase, drop `:` suffix, map `@` to `-`, drop the `[1m]` marker) so stored rows actually match cost-attribution lookups. A companion fix makes the scoped zero-cost backfill match raw model aliases (route prefixes, `:free` variants, date suffixes) in Rust instead of by exact SQL string, so newly priced alias rows get costed immediately rather than waiting for the next startup backfill (Fixes #4017). (#4079)
|
||||||
|
- **Windows ARM64 Release Builds**: Releases now include native Windows ARM64 artifacts so ARM-based Windows devices get a matching build instead of relying on x64 emulation. The release matrix also runs each platform independently (fail-fast disabled) so a missing-secret failure on one job — e.g. macOS signing in forks — no longer cancels its siblings before they finish. (#3950)
|
||||||
|
- **Live End Time for Custom Date Ranges**: The custom date-range picker gains an "End time follows current time" checkbox; when enabled the end time becomes read-only and tracks the current moment, so usage data always reflects up-to-the-second consumption from the chosen start. This is especially useful for watching real-time token use within a Coding Plan 5-hour quota window. `liveEndTime` is included in the React Query cache keys so a live range and a fixed range with the same stored endpoints no longer collide on a stale cache entry. (#4438)
|
||||||
|
- **Source File Name in Session Detail Header**: The session detail header now shows the session log's file name (with the full path on hover and click-to-copy) alongside the project directory, so users can locate and open the underlying JSONL file directly from the UI. Long, space-less basenames such as ~70-char Codex rollout files are truncated at `max-w-[200px]` to keep them from overflowing into the action-button area on narrow windows. (#4113)
|
||||||
|
- **Unmanaged-Skill Indicator on Import Button**: The top-bar Skills Import button now shows a green dot with a tooltip when local unmanaged skills are available to import, so you can tell at a glance that on-disk skills aren't tracked yet. The scan runs once on mount and is shared across navigations (30s `staleTime` + `keepPreviousData`) to avoid repeated disk IO.
|
||||||
|
- **OpenCode Go Subscription Presets**: New OpenCode Go (`opencode.ai/zen/go`) presets for Claude, Codex, and OpenCode, authenticated with a plain pasteable API key (no OAuth). The Codex preset uses `openai_chat` conversion with a GLM/Kimi/DeepSeek/MiMo model catalog (and no static `codexChatReasoning`, so per-model capability is inferred), while OpenCode targets `/zen/go/v1` via `@ai-sdk/openai-compatible`. All four OpenCode Go presets — Claude, Claude Desktop, Codex, and OpenCode — carry a referral link and an in-app promo phrase; the promo banner is now gated on `partnerPromotionKey` alone rather than `isPartner`, so a preset can show a referral promo without earning the gold paid-partner star (this also re-surfaces the existing MiniMax promos).
|
||||||
|
- **SubRouter Partner Provider**: Added SubRouter (`subrouter.ai`), an AI relay aggregator that exposes many models and providers behind a single key, as a preset across all seven managed apps — the Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, the OpenAI-compatible `/v1` endpoint with `gpt-5.5` for Codex and OpenCode, and the Gemini-compatible `/v1beta` endpoint with `gemini-3.5-flash` for Gemini CLI — carrying its own brand icon, the gold partner star, four-locale promotion copy, and the affiliate registration link (`?aff=l3ri`) prefilled as the API-key signup URL. (#4522)
|
||||||
|
- **Prime-Partner Preset Badge and Ordering**: First-party Moonshot Kimi presets (Kimi / Kimi For Coding / Kimi K2.7 Code) are now flagged as prime partners: instead of the gold star, they render a solid gold heart with no badge frame, and in the default (Original) sort they float to the top right after official-category presets, ahead of the rest. Grouping is a three-way partition so each group keeps its internal order and an official preset also flagged prime-partner stays only in the official group.
|
||||||
|
- **Pricing for GLM-5.2 and Doubao Seed 2.1**: Seed model pricing now includes GLM-5.2 (#4385) and Doubao Seed 2.1 Pro/Turbo, so usage from these models is cost-attributed correctly instead of recording zero cost. Doubao prices use Volcengine's official list price (CNY converted at ~7.14); `cache_creation` is kept at 0 because Doubao bills cache storage by time rather than per-token writes, and the existing 2.0 rows are retained for historical accounting.
|
||||||
|
- **Kimi For Coding Auto-Compact Window**: The Kimi For Coding preset now sets `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to a default of 262144 to match the official Kimi docs, exposed via `templateValues` so users can customize the value for future models or performance tuning. (#4401)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Native Responses API for CN Codex Providers**: Several Chinese providers (Qwen/DashScope Bailian, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax CN/intl) now expose a native OpenAI Responses endpoint, so their Codex presets switch to `apiFormat: "openai_responses"` and reach the upstream directly instead of going through the Responses->Chat route-takeover conversion. Dropping the now-unused `codexChatReasoning` and `modelCatalog` also keeps the "local route mapping" toggle unchecked by default. SiliconFlow-hosted MiniMax stays `openai_chat` since it is a third-party endpoint rather than MiniMax's own base_url. Stale model ids on the remaining chat-only providers were refreshed as well (GLM 5.1->5.2, StepFun 3.5-flash-2603->3.7-flash, Ling 2.5-1T->2.6-1T).
|
||||||
|
- **Decoupled Upstream Format Selector from Model-Mapping Toggle**: The Codex provider form used to tie Chat-format conversion and route takeover (model mapping) to a single toggle, so a provider serving a native Responses API could not use model mapping without forcing Chat Completions conversion. The upstream format (Chat Completions / Responses) is now an independent, always-visible selector, while the local-routing toggle solely gates the advanced sub-sections (model mapping catalog, plus reasoning capability when the format is Chat). Its initial state is derived from saved catalog presence with no new persisted field, and the `codexConfig` i18n strings were reworded across all four locales (zh/en/ja/zh-TW).
|
||||||
|
- **Doubao Seed 2.1 Pro Preset**: The DouBaoSeed preset now targets `doubao-seed-2-1-pro` (replacing `doubao-seed-2-0-code-preview-latest`) across all six clients (claude, claude-desktop, codex, opencode, openclaw, hermes), with display names updated to "Doubao Seed 2.1 Pro" and the OpenClaw cost field corrected from 0.002/0.006 to 0.84/4.2 USD per 1M tokens to match the new model.
|
||||||
|
- **CTok Rebranded to ETok**: Following the vendor's domain, endpoint, and trademark rename, all user-facing branding moves from CTok to ETok (`ctok.ai` -> `etok.ai`, `api.ctok.ai` -> `api.etok.ai`, internal id, display name, icons, and README partner banners) across every client preset. The Codex history-migration whitelist keeps `ctok` as a legacy id alongside the new `etok` so existing users' local session history stays correctly bucketed after the rename.
|
||||||
|
- **Consistent Kimi Preset Naming**: The OpenCode and OpenClaw Kimi presets, previously labeled "Kimi K2.7 Code", are renamed to plain "Kimi" (along with the OpenCode provider display name) to match the other apps; the model label stays "Kimi K2.7 Code" since it describes the actual model.
|
||||||
|
- **Dark Mode for JSON Editors**: The CodeMirror `JsonEditor` in the usage-script modal, provider form, and universal provider form now follows the app theme via `useDarkMode()`, switching to the `oneDark` editor theme instead of staying light while the rest of the app is dark. (#4556)
|
||||||
|
- **Tighter Add Provider Header With Footer Hint**: The Add Provider dialog reduces the title-to-tabs and tabs-to-card vertical gaps from 24px to 12px and adds an always-visible pinned footer hint guiding users to fill in the fields below after choosing a preset. `FullScreenPanel` gains an optional `contentClassName` prop so the padding override is scoped to this panel without affecting others that share it.
|
||||||
|
- **Theme-Adaptive Kimi Logo**: The inline Kimi placeholder mark is replaced with the vendor's refreshed logo. The K glyph uses `currentColor` so it follows the theme text color (dark in light mode, white in dark mode), while the brand accent dot is pinned to the new `#1783FF`, with the metadata fallback color aligned accordingly.
|
||||||
|
- **Fable 5 Verified Banner Removed**: The Settings About page no longer shows the Fable 5 Verified commemorative banner that 3.16.3 added beside the app name to mark the special build; the banner image and its markup are dropped, returning the About panel to its standard version-badge layout.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Copilot/Codex OAuth Requests Now Honor the Global Proxy**: `CopilotAuthManager` and `CodexOAuthManager` hardcoded `Client::new()` at construction, so their auth flows (token exchange, `/models` listing, model-vendor checks, device-code and OAuth-refresh requests) ignored the configured global proxy and connected directly. With Copilot the direct connection returned zero Claude models, breaking live model resolution and causing the upstream to reject requests with `400 model_not_supported`. Both managers now fetch the shared client per request via `crate::proxy::http_client::get()`, so they follow the global proxy URL and pick up runtime proxy changes. Fixes #2016, #2931. (#4583)
|
||||||
|
- **Compressed Request and Error Body Decompression**: Codex Desktop sends zstd-compressed request bodies when authenticated against the Codex backend, which broke local proxy routing because the handlers parsed the raw compressed bytes with `serde_json` directly. The proxy now decompresses request bodies (gzip/br/deflate plus new zstd support, including stacked codings like `gzip, zstd`) before JSON parsing across the three Codex handlers and strips the stale `content-encoding`/`content-length`/`transfer-encoding` headers so the forwarder regenerates them. Upstream non-2xx error bodies are decompressed the same way, so compressed rate-limit and auth details are no longer dropped and hidden from the client. Fixes #3764, #3696. (#3817)
|
||||||
|
- **DeepSeek Endpoint `thinking: disabled` 400 Errors**: DeepSeek's Anthropic-compatible endpoint rejects requests where `thinking.type=disabled` coexists with effort parameters, returning HTTP 400, which broke Claude Code 2.1.166+ sub-agents (Workflow/Dynamic Workflow) that hardcode `thinking: disabled`. Rather than overriding the client's intent, the proxy now strips the conflicting `output_config.effort` / `reasoning_effort` parameters for the official DeepSeek endpoint, since sub-agents don't need to display reasoning. (#4239)
|
||||||
|
- **Reverted Anthropic System-Message Hoisting**: Reverts #3775's hoisting of `role=system` messages out of `messages[]` into the top-level `system` field for Anthropic-compatible providers. DeepSeek's endpoint accepts inline system messages natively, and the rewrite altered the request prefix; leaving the messages in place preserves the prompt prefix and avoids a suspected cache hit-rate regression (refs #4297). The unrelated Windows test fixes and the tool-thinking-history normalization from #3775 are kept. (#3775)
|
||||||
|
- **Chat Tool Calls with Missing Function Names**: Some upstreams send empty or absent function names in streaming tool-call deltas, which previously produced invalid Codex Chat output items (or an `unknown_tool` fallback). Accumulated tool-call state is no longer overwritten by empty deltas, and tool calls that never receive both a `call_id` and a valid name are skipped at finalization across the streaming, non-streaming, and legacy `function_call` paths. (#4159)
|
||||||
|
- **Restored Cached Codex Tool Call Fields**: When Codex sends a follow-up Chat request referencing `previous_response_id`, its `function_call` items can arrive carrying only `call_id`. The history enrichment previously refilled only `reasoning`/`reasoning_content`, leaving the function `name`, `arguments`, `status`, and related fields empty; it now restores all cached tool-call fields from history so the call is reconstructed correctly for the Chat upstream. (#4160)
|
||||||
|
- **Duplicate Codex base_url Entries in config.toml**: Writing the Codex `base_url` into `config.toml` only replaced or removed a single matching assignment per section, so a section that already contained multiple `base_url` lines kept the extras and accumulated duplicates. `setCodexBaseUrl` now collapses all matches in the target section or top level (replacing the first and removing the rest), and the TOML `base_url` regex handles escaped quotes. (#4316)
|
||||||
|
- **CODEX_SQLITE_HOME State DB Probing for History Migration**: The Codex session-history migration only scanned `~/.codex/state_5.sqlite` and the `config.toml` `sqlite_home` location, so when Codex's SQLite state was relocated via the `CODEX_SQLITE_HOME` env var the state DB was never scanned and its threads kept their old provider bucket. The shared `codex_state_db_paths` helper used by both the third-party and unified-session migrations now falls back to `CODEX_SQLITE_HOME` (config `sqlite_home` still takes precedence).
|
||||||
|
- **Provider Terminals Respect the User's Shell**: Launching a provider terminal on macOS/Linux hardcoded `bash`, so zsh/fish users' rc files never loaded. The launchers now detect the user's default shell from `$SHELL` (falling back to `/bin/zsh` on macOS, `/bin/bash` on Linux) and exec into it with clean-start flags, while the launch scripts themselves run through POSIX `sh` for portability (e.g. fish, NixOS where `/bin/sh` may not exist). (#4140, fixes #1546)
|
||||||
|
- **Claude MCP Path Honors Custom Config Dir**: When a custom Claude config directory is configured, MCP server reads and writes now resolve to that directory's MCP file instead of the default location, keeping MCP state isolated per profile. The previous copy-on-access migration of the legacy file was removed in favor of resolving the override path directly. (#3431)
|
||||||
|
- **Preset Search Results Clickable After Searching**: After searching in the Add Provider preset selector, results could no longer be clicked or selected. The `requestAnimationFrame` `select()` that raced with typing (and ate the first character, e.g. "gateway" -> "ateway") is removed, input autofocus is restored for the open-by-click path, and refocus is wired up for the Ctrl/Cmd+F shortcut while the box is open. The provider-list typing guard is also scoped to the Ctrl/Cmd+F branch so Escape still closes the search panel. (#4315)
|
||||||
|
- **Skills Browser and Provider Card Display Fixes**: Fixed several display and interaction issues: the repo-manager action stays available while browsing skills.sh and Refresh stays available even when a repo returns no results; long provider names and website URLs on the provider card now truncate instead of overflowing; the OMO model-variant dropdown truncates its selected label with a full-text tooltip; and Select menu items show a checkmark on the active option. (#4323)
|
||||||
|
- **Settings Scroll Resets on Tab Switch**: Switching tabs in the Settings dialog kept the previous tab's scroll position, sometimes landing partway down the new tab; the scroll container now resets to the top whenever the active tab changes. (#4165)
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Kimi Pinned Sponsor Banner**: The pinned sponsor banner at the top of all four README locales (en/zh/ja/de) now features Kimi K2.7 Code in place of the previous MiniMax M2.7 banner. The copy reflects the K2.7 Code release (a coding-focused agentic model that reduces thinking-token usage roughly 30% versus K2.6), the banner is served from in-repo assets (`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`) instead of the Moonshot CDN, and a clickable call-to-action links to the `aff=cc-switch` Moonshot console.
|
||||||
|
- **Codex Unified Session-History Guide**: New trilingual (zh/en/ja) guide for the unified Codex session-history toggle, explaining what opt-in migration (on enable) and ledger-based restore (on disable) actually do, why session data is never truly deleted (tag-only rewrite plus automatic backups), and how to verify files on disk versus merely being filed under another provider drawer. It includes a symptom reference table for the common "my sessions are gone" misunderstanding plus on-disk verification commands for macOS/Linux/Windows, and is linked as the lead item in the v3.16.3 "Usage Guides" release notes.
|
||||||
|
- **Simplified Homebrew Install Instructions**: The installation guide no longer instructs users to run `brew tap farion1231/ccswitch` before `brew install --cask cc-switch`; the deprecated tap step was removed from the en/ja/zh user manuals so the cask installs directly. (#4319)
|
||||||
|
- **Star-History Global Rank Badge**: Added a star-history global rank badge next to the existing Trendshift badge in all four README locales, with light/dark theme variants.
|
||||||
|
- **Volcengine Coding Plan Campaign Link**: The "中国大陆地区的开发者请点击这里" link in the ByteDance/Volcengine sponsor entry now points to the Volcengine `ai618` campaign page instead of the previous `codingplan` referral URL, updated across all four README locales.
|
||||||
|
- **CCSub Sponsor Banner Vector Asset**: Replaced the low-res `ccsub.jpg` sponsor logo with a vector `ccsub.svg`, letterboxed from 2046x648 to 2046x850 (~2.406:1) so it matches the other sponsor-table banners and renders at the same 62px height. All four README locales point at the new asset.
|
||||||
|
|
||||||
|
## [3.16.3] - 2026-06-14
|
||||||
|
|
||||||
|
Development since v3.16.2 focuses on getting usage accounting right end-to-end — billing route-takeover and format-conversion traffic by the real upstream model and pricing basis (schema v11), counting Claude Code Workflow sub-agent sessions, folding Claude Desktop into the Claude view, refreshing the model pricing seed, and reworking the usage dashboard with global provider/model filters, brand-icon toolbars, and far more resilient quota queries — while hardening the proxy (mislabeled SSE bodies, Codex image rectification, OAuth token and takeover-residue recovery, Hermes duplicate YAML keys), reworking provider configuration (a custom User-Agent override, a unified Codex advanced section, searchable preset selection, a Fable 5 tier, and refreshed Kimi/Unity2/Volcengine/MiniMax presets), and smoothing the update, About-panel, and provider-health experiences.
|
||||||
|
|
||||||
|
**Stats**: 59 commits | 130 files changed | +10,223 insertions | -4,232 deletions
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Custom User-Agent Override**: Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
|
||||||
|
- **Unified Codex Session History**: Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
|
||||||
|
- **Dashboard-Wide Provider/Model Filters**: The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
|
||||||
|
- **Refreshed Model Pricing Seed**: Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
|
||||||
|
- **Claude Fable 5 Model Tier**: Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
|
||||||
|
- **Unity2.ai Partner Provider**: Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
|
||||||
|
- **Kimi K2.7 Code Model**: Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
|
||||||
|
- **Codex "Kimi For Coding" Preset Restored**: Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
|
||||||
|
- **Pricing-Model Audit in Request Detail**: The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
|
||||||
|
- **Preset Provider Search & Sorting**: The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
|
||||||
|
- **Claude Mythos 5 Pricing**: Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
|
||||||
|
- **Fable 5 Verified Banner**: The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Claude Desktop Usage Folded Into Claude**: The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
|
||||||
|
- **Lightweight Provider Health Check**: The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
|
||||||
|
- **Codex Advanced Options Section**: The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
|
||||||
|
- **Usage Toolbar Refresh and Layout**: The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
|
||||||
|
- **Faster About Panel Loading**: The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
|
||||||
|
- **Volcengine Ark Coding Plan Promo**: Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
|
||||||
|
- **MiniMax Demoted to Regular Provider**: Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
|
||||||
|
- **LemonData Removed, SudoCode Demoted**: Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
|
||||||
|
- **AtlasCloud Codex GLM 5.1 Context Window**: Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Route-Takeover Traffic Billed by the Real Upstream Model**: When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 5–25×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
|
||||||
|
- **Usage Metering on Format-Conversion Proxy Paths**: Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
|
||||||
|
- **In-App Update No Longer Hangs on Restart**: Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
|
||||||
|
- **Codex Upgrade No Longer Breaks the Install**: Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
|
||||||
|
- **Codex OAuth Auth Token Preserved on Proxy Takeover**: Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
|
||||||
|
- **Takeover-Residue Recovery Across Config-Dir Switches**: Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
|
||||||
|
- **Mislabeled SSE Bodies in Format-Transform Fallback**: Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
|
||||||
|
- **Duplicate YAML Keys in Hermes Config**: Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
|
||||||
|
- **Usage Query Resilience and Error Clarity**: Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
|
||||||
|
- **Usage Script Provider Credential Resolution**: Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
|
||||||
|
- **Claude Code Workflow Sub-Agent Usage Counted**: Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
|
||||||
|
- **Codex Image Rectifier for /responses Text-Only Upstreams**: Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
|
||||||
|
- **Zhipu Coding-Plan Quota Window Mislabeling**: The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
|
||||||
|
- **Duplicate Provider Terminal Sessions on macOS**: Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
|
||||||
|
- **Claude Desktop Model-Mapping Placeholders**: The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
|
||||||
|
- **Popovers Behind Fullscreen Panels**: Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
|
||||||
|
- **ToggleRow Icon Shrinking**: Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Release Notes Contributor Mentions**: Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
|
||||||
|
|
||||||
## [3.16.2] - 2026-06-07
|
## [3.16.2] - 2026-06-07
|
||||||
|
|
||||||
Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual.
|
Development since v3.16.1 focuses on broadening data portability and usage observability — S3-compatible cloud sync, OpenCode session usage import, and an opt-in official-subscription quota template — while hardening Codex Chat Completions routing (stream truncation, `tool_choice` / custom-tool / reasoning-token edge cases, file and audio attachments, and a Codex CLI models endpoint), strengthening proxy robustness (ephemeral ports, takeover/placeholder restore, system-message normalization, clearer upstream errors, and a text-only image fallback), fixing coding-plan quota lookups (Zhipu, MiniMax) and several Windows/macOS issues, adding the CherryIN and ZenMux providers, and refreshing the user manual.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||||
|
|
||||||
### 🌐 The Only Official Website: **[ccswitch.io](https://ccswitch.io)**
|
### 🌐 The Only Official Website: **[ccswitch.io](https://ccswitch.io)**
|
||||||
|
|
||||||
@@ -24,11 +25,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Deutsch](README_
|
|||||||
<details open>
|
<details open>
|
||||||
<summary>Click to collapse</summary>
|
<summary>Click to collapse</summary>
|
||||||
|
|
||||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||||
|
|
||||||
MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations.
|
Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI. It delivers stronger coding and agent performance, with substantial improvements in real-world long-horizon coding tasks. These gains translate into higher end-to-end task success rates across complex software engineering workflows. K2.7 Code also improves reasoning efficiency, reducing thinking-token usage by approximately 30% compared with K2.6. **[Click here to start using Kimi](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||||
|
|
||||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||||
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -107,13 +106,8 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||||
<td>Thanks to LemonData for sponsoring this project! LemonData is a high-performance AI API aggregation platform — one API key for 300+ models including GPT, Claude, Gemini, DeepSeek, and more. All models priced 30–70% below official rates with auto-failover, smart routing, and unlimited concurrency. New users get $1 free credit instantly upon registration — sign up via <a href="https://lemondata.cc/r/FFX1ZDUP">this link</a>to claim your bonus and start building right away</strong>!</td>
|
<td>Thanks to ETok.ai for sponsoring this project! ETok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://etok.ai">here</a> to register!</td>
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
|
||||||
<td>Thanks to CTok.ai for sponsoring this project! CTok.ai is dedicated to building a one-stop AI programming tool service platform. We offer professional Claude Code packages and technical community services, with support for Google Gemini and OpenAI Codex. Through carefully designed plans and a professional tech community, we provide developers with reliable service guarantees and continuous technical support, making AI-assisted programming a true productivity tool. Click <a href="https://ctok.ai">here</a> to register!</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -147,10 +141,15 @@ Register now via <a href="https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/">this lin
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||||
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
<td>Thanks to CCSub for sponsoring this project! CCSub is a stable, affordable AI API relay platform — your drop-in replacement for a Claude.ai subscription. One API key gives you access to Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini, and DeepSeek at roughly 30% of direct API cost, with no VPN required from anywhere in the world. Compatible with Claude Code, Codex, Cursor, Cline, Continue, Windsurf, and all major AI coding tools. Register via <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">this link</a> and get $5 free credit on sign-up.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||||
|
<td>Thanks to Unity2.ai for sponsoring this project! Unity2.ai is a high-performance AI model API relay platform for individual developers, teams, and enterprises. Long trusted by leading companies in China, it serves over 30 billion tokens per day and supports high concurrency at the 5,000 RPM level. It offers balance-based billing, first top-up bonuses, bundle subscriptions, corporate invoicing, and dedicated support. Register via <a href="https://unity2.ai/register?source=ccs">this link</a> to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||||
|
|
||||||
### 🌐 Die einzige offizielle Website: **[ccswitch.io](https://ccswitch.io)**
|
### 🌐 Die einzige offizielle Website: **[ccswitch.io](https://ccswitch.io)**
|
||||||
|
|
||||||
@@ -24,11 +25,9 @@
|
|||||||
<details open>
|
<details open>
|
||||||
<summary>Zum Einklappen klicken</summary>
|
<summary>Zum Einklappen klicken</summary>
|
||||||
|
|
||||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||||
|
|
||||||
MiniMax-M2.7 ist ein großes Sprachmodell der nächsten Generation, das auf autonome Weiterentwicklung und praxisnahe Produktivität ausgelegt ist. Anders als herkömmliche Modelle beteiligt sich M2.7 aktiv an seiner eigenen Verbesserung — durch Agententeams, dynamische Werkzeugnutzung und Reinforcement-Learning-Schleifen. Es liefert starke Leistung im Software-Engineering (56,22 % bei SWE-Pro, 55,6 % bei VIBE-Pro, 57,0 % bei Terminal Bench 2) und überzeugt bei komplexen Büro-Workflows mit einem führenden Wert von 1495 ELO bei GDPval-AA. Mit originalgetreuer Bearbeitung von Word-, Excel- und PowerPoint-Dateien sowie einer Befolgungsrate von 97 % über 40+ komplexe Skills hinweg setzt M2.7 einen neuen Standard für den Aufbau KI-nativer Workflows und Organisationen.
|
Kimi K2.7 Code ist ein quelloffenes, auf Programmierung spezialisiertes Agenten-Modell von Moonshot AI. Es bietet eine stärkere Programmier- und Agentenleistung mit erheblichen Verbesserungen bei realen, langfristigen Programmieraufgaben. Diese Fortschritte führen zu höheren End-to-End-Erfolgsraten in komplexen Software-Engineering-Workflows. Zudem verbessert K2.7 Code die Reasoning-Effizienz und reduziert den Verbrauch an Thinking-Tokens um rund 30 % gegenüber K2.6. **[Hier klicken, um Kimi auszuprobieren](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||||
|
|
||||||
[Klicken Sie hier](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link), um exklusive 12 % Rabatt auf den MiniMax Token Plan zu erhalten!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||||
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
<td>Danke an Dola seed für die Unterstützung dieses Projekts! Dola Seed 2.0 ist ein voll-modales Allzweck-Großmodell, das von ByteDance eigenständig für den globalen Markt entwickelt wurde. Aufbauend auf einer einheitlichen multimodalen Architektur unterstützt es das gemeinsame Verstehen und Generieren von Text, Bildern, Audio und Video. Es ermöglicht von Haus aus die Zusammenarbeit von Agenten und verfügt über starke Fähigkeiten in den Bereichen Schlussfolgern, Ausführung langer Aufgaben, Werkzeugintegration und Programmierung. Es ist breit einsetzbar — etwa für intelligente Cockpits, persönliche Assistenten, Bildung, Kundensupport, Marketing, Einzelhandel und weitere Szenarien. Es überzeugt bei multimodaler Wahrnehmung, der Ende-zu-Ende-Bewältigung komplexer Aufgaben, stabiler Interaktion und Datensicherheit und ist über die ModelArk-Plattform einfach zugänglich und bereitstellbar. Registrieren Sie sich über <a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">diesen Link</a> und erhalten Sie pro Modell ein kostenloses Inferenzkontingent von 500.000 Token.<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -107,13 +106,8 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||||
<td>Danke an LemonData für die Unterstützung dieses Projekts! LemonData ist eine leistungsstarke KI-API-Aggregationsplattform — ein API-Schlüssel für mehr als 300 Modelle, darunter GPT, Claude, Gemini, DeepSeek und weitere. Alle Modelle zu Preisen 30–70 % unter den offiziellen Tarifen, mit automatischem Failover, intelligentem Routing und unbegrenzter Nebenläufigkeit. Neukunden erhalten bei der Registrierung sofort 1 $ Gratisguthaben — registrieren Sie sich über <a href="https://lemondata.cc/r/FFX1ZDUP">diesen Link</a>, um Ihren Bonus einzulösen und sofort mit dem Entwickeln zu beginnen</strong>!</td>
|
<td>Danke an ETok.ai für die Unterstützung dieses Projekts! ETok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://etok.ai">hier</a>, um sich zu registrieren!</td>
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
|
||||||
<td>Danke an CTok.ai für die Unterstützung dieses Projekts! CTok.ai widmet sich dem Aufbau einer Komplettlösung für KI-Programmierwerkzeuge. Wir bieten professionelle Claude-Code-Pakete und Dienste einer technischen Community, mit Unterstützung für Google Gemini und OpenAI Codex. Durch sorgfältig gestaltete Pläne und eine professionelle Tech-Community geben wir Entwicklern verlässliche Servicegarantien und kontinuierlichen technischen Support an die Hand und machen KI-gestützte Programmierung zu einem echten Produktivitätswerkzeug. Klicken Sie <a href="https://ctok.ai">hier</a>, um sich zu registrieren!</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -147,10 +141,15 @@ Registrieren Sie sich jetzt über <a href="https://pateway.ai/?ch=etzpm8&aff=WB6
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||||
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
<td>Danke an CCSub für die Unterstützung dieses Projekts! CCSub ist eine zuverlässige und kostengünstige AI-API-Relay-Plattform — Ihr direkter Ersatz für ein Claude.ai-Abonnement. Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Claude Opus 4.8, Sonnet, Haiku, GPT-5, Gemini und DeepSeek zu etwa 30 % der Kosten der direkten API-Nutzung — ohne VPN, weltweit nutzbar. Kompatibel mit Claude Code, Codex, Cursor, Cline, Continue, Windsurf und allen gängigen AI-Coding-Tools. Registrieren Sie sich über <a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">diesen Link</a> und erhalten Sie $5 Startguthaben bei der Anmeldung.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||||
|
<td>Danke an Unity2.ai für die Unterstützung dieses Projekts! Unity2.ai ist eine leistungsstarke AI-Modell-API-Relay-Plattform für Einzelentwickler, Teams und Unternehmen. Sie wird seit Langem von führenden Unternehmen in China genutzt, verarbeitet täglich über 30 Milliarden Tokens und unterstützt hohe Parallelität auf 5.000-RPM-Niveau. Geboten werden Guthaben-Abrechnung, Ersteinzahlungsbonus, Kombi-Abonnements, Firmenrechnungen und persönliche Betreuung. Registrieren Sie sich über <a href="https://unity2.ai/register?source=ccs">diesen Link</a> und erhalten Sie $2 Guthaben, plus weitere $10 für den Beitritt zur offiziellen Gruppe — bis zu $12 Gratis-Guthaben!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||||
|
|
||||||
### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**
|
### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**
|
||||||
|
|
||||||
@@ -24,11 +25,9 @@
|
|||||||
<details open>
|
<details open>
|
||||||
<summary>クリックで折りたたむ</summary>
|
<summary>クリックで折りたたむ</summary>
|
||||||
|
|
||||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||||
|
|
||||||
MiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。
|
Kimi K2.7 Code は Moonshot AI が開発した、コーディングに特化したオープンソースのエージェントモデルです。コーディング能力とエージェント性能が全面的に強化され、実世界の長程コーディングタスクで大幅な向上を実現し、複雑なソフトウェアエンジニアリングのワークフロー全体でエンドツーエンドのタスク成功率を高めます。さらに K2.7 Code は推論効率を改善し、K2.6 と比べて推論トークンの消費を約 30% 削減します。**[ここをクリックして Kimi を体験する](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||||
|
|
||||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
<td width="180"><a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
|
||||||
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>
|
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"> >>中国大陆地区的开发者请点击这里</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -105,14 +104,10 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||||
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href="https://www.micuapi.ai/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
|
||||||
<td>LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — <a href="https://lemondata.cc/r/FFX1ZDUP">こちらのリンク</a>から登録してボーナスを獲得し、すぐに開発を始めましょう!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||||
<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
<td>ETok.ai のご支援に感謝します!ETok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://etok.ai">こちら</a>から登録してください!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -146,10 +141,15 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||||
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
<td>CCSub のご支援に感謝します!CCSub は安定した低価格の AI API リレープラットフォームで、Claude Code 公式サブスクリプションの強力な代替です。1 つの API キーで Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek の全モデルを公式直接利用の約 1/3 のコストでご利用いただけます。VPN 不要で世界中から直接接続可能。Claude Code、Codex、Cursor、Cline、Continue、Windsurf など主要な AI コーディングツールすべてに対応しています。<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">こちらのリンク</a>から登録すると $5 の無料クレジットがもらえます。</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||||
|
<td>Unity2.ai のご支援に感謝します!Unity2.ai は個人開発者・チーム・企業向けの高性能 AI モデル API リレープラットフォームです。中国の大手企業に長年利用されており、1 日 300 億トークン以上を処理し、5000 RPM クラスの高並列に対応しています。残高課金、初回チャージボーナス、組み合わせサブスクリプション、企業向け請求書発行、専任サポートを提供。<a href="https://unity2.ai/register?source=ccs">こちらのリンク</a>から登録すると $2 のクレジット、公式グループへの参加でさらに $10、最大 $12 の無料クレジットがもらえます!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/15372" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15372" alt="farion1231%2Fcc-switch | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
<a href="https://www.star-history.com/#farion1231/cc-switch&Date"><picture><source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=farion1231/cc-switch&theme=dark" /><img alt="Star History Rank" src="https://api.star-history.com/badge?repo=farion1231/cc-switch" width="196" height="55" /></picture></a>
|
||||||
|
|
||||||
### 🌐 唯一官方网站:**[ccswitch.io](https://ccswitch.io)**
|
### 🌐 唯一官方网站:**[ccswitch.io](https://ccswitch.io)**
|
||||||
|
|
||||||
@@ -24,11 +25,9 @@
|
|||||||
<details open>
|
<details open>
|
||||||
<summary>点击折叠</summary>
|
<summary>点击折叠</summary>
|
||||||
|
|
||||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
[](https://platform.moonshot.cn/console?aff=cc-switch)
|
||||||
|
|
||||||
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。
|
Kimi K2.7 Code 是 Moonshot AI 开发的编程专用开源智能体模型。它在编程与智能体执行能力上全面增强,在真实长程编程任务中实现显著提升,带来复杂软件工程工作流中更高的端到端任务成功率。同时,K2.7 Code 优化了推理效率,相较 K2.6 平均减少约 30% 的推理 token 消耗。**[点击此处开启 Kimi 使用体验](https://platform.moonshot.cn/console?aff=cc-switch)**
|
||||||
|
|
||||||
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)享 MiniMax Token Plan 专属 88 折优惠!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -62,8 +61,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
<td width="180"><a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
|
||||||
<td>感谢火山方舟Agent Plan 模型赞助了本项目!方舟Agent Plan 模型订阅套餐集成了包含Doubao-Seed、Doubao-Seedance、Doubao-Seedream等在内的字节跳动自研SOTA级模型,覆盖文本、代码、图像、视频等多模态任务。同时支持一站式接入DeepSeek V4、GLM 5.1等主流大模型。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。方舟 Agent Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟AgentPlan,新客户首月40元起!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过<a href="https://www.volcengine.com/activity/ai618?utm_campaign=hw&utm_content=hw&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!<a href="https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch">>>For developers outside Mainland China, please click here</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -106,14 +105,10 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
<td width="180"><a href="https://www.micuapi.ai/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||||
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
<td>感谢 米醋API 赞助了本项目!米醋API 是一家致力于提供极致性价比与高稳定性的全球大模型中转服务商。米醋API 背后有实体企业做核心保障,杜绝跑路风险,支持极速正规开票!我们主打“试错零成本”:1 元起充低门槛,0 手续费随时退款!米醋API 为本软件的用户提供了特别优惠,使用<a href="https://www.micuapi.ai/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td width="180"><a href="https://lemondata.cc/r/FFX1ZDUP"><img src="assets/partners/logos/lemondata.png" alt="LemonData" width="150"></a></td>
|
|
||||||
<td>感谢 LemonData 赞助了本项目!LemonData 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 GPT、Claude、Gemini、DeepSeek 等 300+ 模型。所有模型定价为官方价格的 30%-70%,支持自动故障转移、智能路由和无限并发。新用户注册即获 $1 免费额度——通过<a href="https://lemondata.cc/r/FFX1ZDUP">此链接</a>注册即可领取奖励,立即开始开发!</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://ctok.ai"><img src="assets/partners/logos/ctok.png" alt="CTok" width="150"></a></td>
|
<td width="180"><a href="https://etok.ai"><img src="assets/partners/logos/etok.png" alt="ETok" width="150"></a></td>
|
||||||
<td>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
<td>感谢 ETok.ai 赞助了本项目!ETok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://etok.ai">这里</a>注册!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
@@ -147,10 +142,15 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
|||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.jpg" alt="CCSub" width="150"></a></td>
|
<td width="180"><a href="https://www.ccsub.net/register?ref=Y6Z8DXEA"><img src="assets/partners/logos/ccsub.svg" alt="CCSub" width="150"></a></td>
|
||||||
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
<td>感谢 CCSub 赞助本项目!CCSub 是稳定、实惠的 AI API 中转平台,是 Claude Code 官方订阅的超强平替。一个 API Key 即可调用 Claude Opus 4.8、Sonnet 4.6、Haiku 4.5、GPT-5、Gemini、DeepSeek 全系列模型,价格约为官方直连的 1/3,全球直连无需梯子。兼容 Claude Code、Codex、Cursor、Cline、Continue、Windsurf 等所有主流 AI 编程工具。通过<a href="https://www.ccsub.net/register?ref=Y6Z8DXEA">此链接</a>注册即送 $5 体验额度!</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td width="180"><a href="https://unity2.ai/register?source=ccs"><img src="assets/partners/logos/unity2.jpg" alt="Unity2.ai" width="150"></a></td>
|
||||||
|
<td>感谢 Unity2.ai 赞助了本项目!Unity2.ai 是面向个人开发者、团队和企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。支持余额计费、首充赠额、组合订阅、企业开票和专属对接。通过<a href="https://unity2.ai/register?source=ccs">此链接</a>注册可领取 $2 余额,加入官方群再送 $10 余额,最高可领 $12 免费额度!</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 511 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 88 KiB |
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* 统一供应商(Universal Provider)预设配置
|
|
||||||
*
|
|
||||||
* 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。
|
|
||||||
* 适用于 NewAPI 等支持多种协议的 API 网关。
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
UniversalProvider,
|
|
||||||
UniversalProviderApps,
|
|
||||||
UniversalProviderModels,
|
|
||||||
} from "@/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一供应商预设接口
|
|
||||||
*/
|
|
||||||
export interface UniversalProviderPreset {
|
|
||||||
/** 预设名称 */
|
|
||||||
name: string;
|
|
||||||
/** 供应商类型标识 */
|
|
||||||
providerType: string;
|
|
||||||
/** 默认启用的应用 */
|
|
||||||
defaultApps: UniversalProviderApps;
|
|
||||||
/** 默认模型配置 */
|
|
||||||
defaultModels: UniversalProviderModels;
|
|
||||||
/** 网站链接 */
|
|
||||||
websiteUrl?: string;
|
|
||||||
/** 图标名称 */
|
|
||||||
icon?: string;
|
|
||||||
/** 图标颜色 */
|
|
||||||
iconColor?: string;
|
|
||||||
/** 描述 */
|
|
||||||
description?: string;
|
|
||||||
/** 是否为自定义模板(允许用户完全自定义) */
|
|
||||||
isCustomTemplate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NewAPI 默认模型配置
|
|
||||||
*/
|
|
||||||
const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = {
|
|
||||||
claude: {
|
|
||||||
model: "claude-sonnet-4-20250514",
|
|
||||||
haikuModel: "claude-haiku-4-20250514",
|
|
||||||
sonnetModel: "claude-sonnet-4-20250514",
|
|
||||||
opusModel: "claude-sonnet-4-20250514",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-4o",
|
|
||||||
reasoningEffort: "high",
|
|
||||||
},
|
|
||||||
gemini: {
|
|
||||||
model: "gemini-2.5-pro",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const N1N_DEFAULT_MODELS: UniversalProviderModels = {
|
|
||||||
claude: {
|
|
||||||
model: "claude-3-5-sonnet-20240620",
|
|
||||||
haikuModel: "claude-3-haiku-20240307",
|
|
||||||
sonnetModel: "claude-3-5-sonnet-20240620",
|
|
||||||
opusModel: "claude-3-opus-20240229",
|
|
||||||
},
|
|
||||||
codex: {
|
|
||||||
model: "gpt-4o",
|
|
||||||
reasoningEffort: "high",
|
|
||||||
},
|
|
||||||
gemini: {
|
|
||||||
model: "gemini-1.5-pro-latest",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一供应商预设列表
|
|
||||||
*/
|
|
||||||
export const universalProviderPresets: UniversalProviderPreset[] = [
|
|
||||||
{
|
|
||||||
name: "n1n.ai",
|
|
||||||
providerType: "n1n",
|
|
||||||
defaultApps: {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
defaultModels: N1N_DEFAULT_MODELS,
|
|
||||||
websiteUrl: "https://n1n.ai",
|
|
||||||
icon: "openai",
|
|
||||||
iconColor: "#000000",
|
|
||||||
description:
|
|
||||||
"n1n.ai - 聚合 OpenAI, Anthropic, Google 等主流大模型的一站式 AI 服务平台",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "NewAPI",
|
|
||||||
providerType: "newapi",
|
|
||||||
defaultApps: {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
|
||||||
websiteUrl: "https://www.newapi.pro",
|
|
||||||
icon: "newapi",
|
|
||||||
iconColor: "#00A67E",
|
|
||||||
description:
|
|
||||||
"NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "自定义网关",
|
|
||||||
providerType: "custom_gateway",
|
|
||||||
defaultApps: {
|
|
||||||
claude: true,
|
|
||||||
codex: true,
|
|
||||||
gemini: true,
|
|
||||||
},
|
|
||||||
defaultModels: NEWAPI_DEFAULT_MODELS,
|
|
||||||
icon: "openai",
|
|
||||||
iconColor: "#6366F1",
|
|
||||||
description: "自定义配置的 API 网关",
|
|
||||||
isCustomTemplate: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据预设创建统一供应商
|
|
||||||
*/
|
|
||||||
export function createUniversalProviderFromPreset(
|
|
||||||
preset: UniversalProviderPreset,
|
|
||||||
id: string,
|
|
||||||
baseUrl: string,
|
|
||||||
apiKey: string,
|
|
||||||
customName?: string,
|
|
||||||
): UniversalProvider {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: customName || preset.name,
|
|
||||||
providerType: preset.providerType,
|
|
||||||
apps: { ...preset.defaultApps },
|
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
|
||||||
models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy
|
|
||||||
websiteUrl: preset.websiteUrl,
|
|
||||||
icon: preset.icon,
|
|
||||||
iconColor: preset.iconColor,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取预设的显示名称(用于 UI)
|
|
||||||
*/
|
|
||||||
export function getPresetDisplayName(preset: UniversalProviderPreset): string {
|
|
||||||
return preset.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 根据类型查找预设
|
|
||||||
*/
|
|
||||||
export function findPresetByType(
|
|
||||||
providerType: string,
|
|
||||||
): UniversalProviderPreset | undefined {
|
|
||||||
return universalProviderPresets.find((p) => p.providerType === providerType);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Can't See Custom Models in the Codex Desktop App? (FAQ)
|
||||||
|
|
||||||
|
> Applies to CC Switch v3.16.1 and later. This article explains "why the Codex desktop app can't see custom models" and the available mitigation; for the detailed step-by-step setup with screenshots, see [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md).
|
||||||
|
|
||||||
|
## Symptom
|
||||||
|
|
||||||
|
After you switch Codex to a third-party / custom model in CC Switch (DeepSeek, Kimi, GLM, MiniMax, an aggregator, etc.):
|
||||||
|
|
||||||
|
- The model picker in the **Codex desktop app** doesn't show these custom models — often only the official default model remains, and the reasoning level falls back to the official default;
|
||||||
|
- but everything works fine in the **command-line `codex`** `/model` menu.
|
||||||
|
|
||||||
|
Many users have run into this. Here's why, and what you can do about it.
|
||||||
|
|
||||||
|
## Why this happens
|
||||||
|
|
||||||
|
This is **not a CC Switch local-config problem and not a CC Switch bug** — it is the **Codex desktop app's (the upstream closed-source client's) own model-gating behavior**.
|
||||||
|
|
||||||
|
The Codex desktop app's model picker decides which models to allow based on your **current login identity**: when it can't detect an official ChatGPT / Codex login state, it forces the picker back to the official default model and hides the custom models you configured through `config.toml` (the reasoning level falls back to the official default too). The upstream has marked "exposing custom-provider models in the desktop GUI" as not planned, so CC Switch cannot fully fix this at the desktop-GUI level.
|
||||||
|
|
||||||
|
The command-line `codex` `/model` menu and request routing both recognize the custom providers in `config.toml` correctly — **only the desktop GUI picker is constrained by this gating layer**.
|
||||||
|
|
||||||
|
## Mitigation: keep the official login
|
||||||
|
|
||||||
|
The workaround is to **keep the official login state** so the desktop app's gating allows your custom models through. The key points are below (the full step-by-step setup with screenshots is in the linked guide):
|
||||||
|
|
||||||
|
1. Log in once with an official ChatGPT / Codex account in Codex (a Free subscription is enough) to keep the official login state.
|
||||||
|
2. In CC Switch, enable `Settings -> General -> Codex App Enhancements -> Keep official login when switching third-party providers` (**off by default**).
|
||||||
|
3. Enable local routing and route Codex through it for this third-party provider (required for Chat Completions providers such as DeepSeek / Kimi / MiniMax).
|
||||||
|
4. Fully quit and restart Codex.
|
||||||
|
|
||||||
|
Once enabled, CC Switch preserves the official login state in `~/.codex/auth.json` when switching to a third-party provider and writes the third-party key into `config.toml`, so the desktop app still recognizes the official login identity, the gating lets your models through, and the custom models you configured reappear in the picker. **The preserved official token is never sent to the third party** — third-party model requests still use the key you configured, forwarded through the local route.
|
||||||
|
|
||||||
|
> 📖 Detailed step-by-step setup: [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md)
|
||||||
|
|
||||||
|
## Still can't see them?
|
||||||
|
|
||||||
|
- **Confirm the toggle is on**: this toggle is off by default, and many people overwrite the official login state the first time they switch to a third-party provider, which is exactly why the models disappear — enable it as above.
|
||||||
|
- **The official login state expires**: if you haven't used the official login for several days, the picker may go empty again once the token expires — log in to the official account once more to restore it.
|
||||||
|
- **Command-line fallback diagnosis**: run `codex debug models` to list the models actually available on the CLI side and confirm the model itself is configured correctly (the CLI is unaffected by this gating).
|
||||||
|
- Individual Codex desktop versions may behave slightly differently; this is in the upstream client's domain, and no CC Switch version can fully fix it at the desktop-GUI level.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs](./codex-official-auth-preservation-guide-en.md)
|
||||||
|
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
||||||
|
- [Local Routing](../user-manual/en/4-proxy/4.2-routing.md)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Codex デスクトップアプリでカスタムモデルが見えない?(よくある質問)
|
||||||
|
|
||||||
|
> 対象バージョン: CC Switch v3.16.1 以降。本記事は「なぜ Codex デスクトップアプリでカスタムモデルが見えないのか」と、使える緩和策を解説します。図入りの詳細な設定手順は [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md) を参照してください。
|
||||||
|
|
||||||
|
## 現象
|
||||||
|
|
||||||
|
CC Switch で Codex をサードパーティ / カスタムモデル(DeepSeek、Kimi、GLM、MiniMax、中継サービスなど)へ切り替えた後:
|
||||||
|
|
||||||
|
- **Codex デスクトップアプリ**のモデルセレクタにこれらのカスタムモデルが表示されず、多くの場合は公式の既定モデルだけが残り、思考レベルも公式の既定へ戻ってしまう。
|
||||||
|
- 一方で**コマンドライン `codex`** の `/model` ではすべて正常に表示される。
|
||||||
|
|
||||||
|
多くのユーザーがこの現象に遭遇しています。以下で原因と対処を解説します。
|
||||||
|
|
||||||
|
## なぜこうなるのか
|
||||||
|
|
||||||
|
これは **CC Switch のローカル設定の問題でも、CC Switch のバグでもありません**。**Codex デスクトップアプリ(上流のクローズドソースクライアント)自身のモデルゲーティング挙動**です。
|
||||||
|
|
||||||
|
Codex デスクトップアプリのモデルセレクタは、あなたの**現在のログイン ID** に応じてどのモデルを通すかを決めます。公式 ChatGPT / Codex のログイン状態を検出できないとき、セレクタを公式の既定モデルへ強制的に戻し、`config.toml` で設定したカスタムモデルを隠します(思考レベルもあわせて公式の既定へ戻ります)。公式は「デスクトップ GUI でカスタムプロバイダーのモデルを公開する」ことを not planned としてマークしているため、CC Switch がデスクトップ GUI のレベルでこれを根本的に修正することはできません。
|
||||||
|
|
||||||
|
コマンドライン `codex` の `/model` とリクエストルーティングは `config.toml` 内のカスタムプロバイダーを正常に認識できます。**デスクトップ GUI のセレクタだけがこのゲーティングの制限を受けます**。
|
||||||
|
|
||||||
|
## 緩和策: 公式ログインを保持する
|
||||||
|
|
||||||
|
対処は**公式ログイン状態を保持する**ことで、デスクトップアプリのゲーティングにあなたのカスタムモデルを通させます。要点は次のとおりです(完全な図入り手順は下のリンク先のガイドを参照してください):
|
||||||
|
|
||||||
|
1. まず Codex で公式 ChatGPT / Codex に一度ログインし(Free サブスクリプションで構いません)、公式ログイン状態を保持する。
|
||||||
|
2. CC Switch で `設定 → 一般 → Codex アプリ拡張 → サードパーティ切替時に公式ログインを保持` をオンにする(**デフォルトはオフ**)。
|
||||||
|
3. そのサードパーティプロバイダーでローカルルーティングを有効化し、Codex のルーティングをオンにする(DeepSeek / Kimi / MiniMax など Chat Completions プロトコルのプロバイダーでは必須)。
|
||||||
|
4. Codex を完全に終了して再起動する。
|
||||||
|
|
||||||
|
オンにすると、CC Switch はサードパーティプロバイダーへ切り替える際に `~/.codex/auth.json` 内の公式ログイン状態を保持し、サードパーティの Key を `config.toml` へ書き込みます。これにより、デスクトップアプリは引き続き公式ログイン ID を認識してゲーティングを通すため、設定したカスタムモデルがセレクタに再び表示されます。**保持された公式 Token がサードパーティへ送られることはありません**——サードパーティのモデルリクエストは引き続き、設定した Key でローカルルーティング経由で転送されます。
|
||||||
|
|
||||||
|
> 📖 詳細な図入り手順: [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md)
|
||||||
|
|
||||||
|
## それでも見えない場合は
|
||||||
|
|
||||||
|
- **スイッチがオンか確認する**: このスイッチはデフォルトでオフです。多くの人は初めてサードパーティへ切り替えたときに公式ログイン状態を上書きしてしまい、その結果見えなくなっています——上記の手順でオンにしてください。
|
||||||
|
- **公式ログイン状態は期限切れになる**: 数日間公式ログインを使わないと、Token が失効した後にセレクタが再び空になることがあります——公式に一度ログインし直せば回復します。
|
||||||
|
- **コマンドラインでの確認**: `codex debug models` を使うと CLI 側で実際に利用可能なモデルを一覧でき、モデル自体が正しく設定されていることを確認できます(CLI はこのゲーティングの影響を受けません)。
|
||||||
|
- 個々の Codex デスクトップ版で挙動が多少異なる場合があります。これは上流クライアントの範疇であり、CC Switch のどのバージョンでもデスクトップ GUI のレベルで根本解決することはできません。
|
||||||
|
|
||||||
|
## 参考リンク
|
||||||
|
|
||||||
|
- [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する](./codex-official-auth-preservation-guide-ja.md)
|
||||||
|
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
||||||
|
- [ローカルルーティング](../user-manual/ja/4-proxy/4.2-routing.md)
|
||||||
|
</content>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Codex 桌面应用里看不到自定义模型?(常见问题)
|
||||||
|
|
||||||
|
> 适用版本:CC Switch v3.16.1 及以上。本文解释「为什么 Codex 桌面应用看不到自定义模型」以及可用的缓解办法;详细的图文配置步骤见 [使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)。
|
||||||
|
|
||||||
|
## 现象
|
||||||
|
|
||||||
|
在 CC Switch 里把 Codex 切换到第三方 / 自定义模型(DeepSeek、Kimi、GLM、MiniMax、中转站等)后:
|
||||||
|
|
||||||
|
- **Codex 桌面应用**的模型选择器里看不到这些自定义模型,往往只剩官方默认模型,思考等级也回落到官方默认;
|
||||||
|
- 但**命令行 `codex`** 的 `/model` 里一切正常。
|
||||||
|
|
||||||
|
很多用户都遇到过这个现象,下面解释原因与办法。
|
||||||
|
|
||||||
|
## 为什么会这样
|
||||||
|
|
||||||
|
这**不是 CC Switch 的本地配置问题,也不是 CC Switch 的 bug**,而是 **Codex 桌面应用(上游闭源客户端)自身的模型门控行为**。
|
||||||
|
|
||||||
|
Codex 桌面应用的模型选择器会按你**当前的登录身份**来决定放行哪些模型:当它检测不到官方 ChatGPT / Codex 登录态时,会把选择器强制回落到官方默认模型,把你通过 `config.toml` 配置的自定义模型藏起来(思考等级也会一并回落到官方默认)。官方已把「在桌面 GUI 里暴露自定义供应商模型」标记为 not planned,因此 CC Switch 无法从桌面 GUI 层面彻底修复它。
|
||||||
|
|
||||||
|
命令行 `codex` 的 `/model` 与请求路由都能正常识别 `config.toml` 里的自定义供应商,**唯独桌面 GUI 的选择器受这层门控限制**。
|
||||||
|
|
||||||
|
## 怎么缓解:保留官方登录
|
||||||
|
|
||||||
|
办法是**保留官方登录态**,让桌面应用的门控放行你的自定义模型。要点如下(完整图文步骤见下方链接的攻略):
|
||||||
|
|
||||||
|
1. 先在 Codex 里登录一次官方 ChatGPT / Codex(Free 订阅即可),保留官方登录态。
|
||||||
|
2. 在 CC Switch 开启 `设置 → 通用 → Codex 应用增强 → 切换第三方时保留官方登录`(**默认关闭**)。
|
||||||
|
3. 为该第三方供应商开启本地路由并接管 Codex(Chat Completions 协议的供应商如 DeepSeek / Kimi / MiniMax 必须开启)。
|
||||||
|
4. 完全退出并重启 Codex。
|
||||||
|
|
||||||
|
开启后,CC Switch 在切换第三方供应商时会保留 `~/.codex/auth.json` 里的官方登录态、把第三方 Key 写进 `config.toml`,于是桌面应用仍识别官方登录身份、门控放行,你配置的自定义模型就会重新出现在选择器里。**保留的官方 Token 不会被发往第三方**——第三方模型请求仍用你配置的 Key 经本地路由转发。
|
||||||
|
|
||||||
|
> 📖 详细图文步骤:[使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)
|
||||||
|
|
||||||
|
## 仍然看不到怎么办
|
||||||
|
|
||||||
|
- **确认开关已开**:该开关默认关闭,很多人第一次切到第三方就把官方登录态覆盖掉了,所以才看不到——按上面开启即可。
|
||||||
|
- **官方登录态会过期**:如果连续几天没用过官方登录,Token 失效后选择器可能又变空——重新登录一次官方即可恢复。
|
||||||
|
- **命令行兜底诊断**:用 `codex debug models` 可以列出 CLI 端实际可用的模型,确认模型本身已正确配置(CLI 不受此门控影响)。
|
||||||
|
- 个别 Codex 桌面版本的行为可能略有差异;这属于上游客户端范畴,CC Switch 各版本都无法从桌面 GUI 层根治。
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [使用第三方 API 时保留 Codex 远程操作和官方插件](./codex-official-auth-preservation-guide-zh.md)
|
||||||
|
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
||||||
|
- [本地路由](../user-manual/zh/4-proxy/4.2-routing.md)
|
||||||
@@ -202,6 +202,7 @@ Because Codex App Enhancements and routing takeover can create unnecessary troub
|
|||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
|
- [Can't see custom models in the Codex desktop app? (FAQ)](./codex-desktop-custom-model-visibility-en.md)
|
||||||
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
- [Codex DeepSeek local routing hands-on guide](./codex-deepseek-routing-guide-en.md)
|
||||||
- [Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)
|
- [Add a Codex provider: Chat Completions routing and model mapping](../user-manual/en/2-providers/2.1-add.md)
|
||||||
- [Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
- [Local Proxy Service](../user-manual/en/4-proxy/4.1-service.md)
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ Codex アプリ拡張やルーティング管理は、必要ないユーザー
|
|||||||
|
|
||||||
## 参考リンク
|
## 参考リンク
|
||||||
|
|
||||||
|
- [Codex デスクトップアプリでカスタムモデルが見えない?(よくある質問)](./codex-desktop-custom-model-visibility-ja.md)
|
||||||
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
- [Codex DeepSeek ローカルルーティング実践ガイド](./codex-deepseek-routing-guide-ja.md)
|
||||||
- [Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)
|
- [Codex プロバイダーの追加: Chat Completions ルーティングとモデルマッピング](../user-manual/ja/2-providers/2.1-add.md)
|
||||||
- [ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
- [ローカルプロキシサービス](../user-manual/ja/4-proxy/4.1-service.md)
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ Codex 的模型目录是启动时读取的。即使 CC Switch 已经生成了新
|
|||||||
|
|
||||||
## 参考链接
|
## 参考链接
|
||||||
|
|
||||||
|
- [Codex 桌面应用里看不到自定义模型?(常见问题)](./codex-desktop-custom-model-visibility-zh.md)
|
||||||
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
- [Codex DeepSeek 本地路由实战攻略](./codex-deepseek-routing-guide-zh.md)
|
||||||
- [添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)
|
- [添加 Codex 供应商:Chat Completions 路由与模型映射](../user-manual/zh/2-providers/2.1-add.md)
|
||||||
- [本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
- [本地代理服务](../user-manual/zh/4-proxy/4.1-service.md)
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Unified Codex Session History: Feature Overview and Usage Guide (CC Switch)
|
||||||
|
|
||||||
|
> Applies to CC Switch v3.16.x and later. This guide is based on the current code; every command and path can be verified by hand. Examples use de-identified data and contain no real session content or API keys.
|
||||||
|
|
||||||
|
## What this feature is
|
||||||
|
|
||||||
|
"Unified Codex session history" is a switch that CC Switch v3.16.x adds for Codex. You'll find it under **Settings -> General -> the "Codex App Enhancements" group** ("Codex App Enhancements" is the group title; the switch itself is called "Unified Codex session history"). Once enabled, **sessions from your official subscription (ChatGPT login / OpenAI API key) appear in the same history / resume list as sessions from every third-party provider CC Switch manages**—they are no longer split into two lists that can't see each other.
|
||||||
|
|
||||||
|
## What problem it solves
|
||||||
|
|
||||||
|
Codex classifies sessions by a "provider tag" (a field called `model_provider`), and **the resume / history list only shows sessions whose tag matches your currently active provider**. As a result, sessions are naturally sorted into two separate "drawers":
|
||||||
|
|
||||||
|
- Sessions from your official subscription go under Codex's built-in **`openai`** tag;
|
||||||
|
- Every third-party provider CC Switch manages goes under the **`custom`** tag.
|
||||||
|
|
||||||
|
The two drawers can't see each other. If you **switch frequently between official and third-party**, you'll hit this kind of fragmentation: "the session I was just chatting in with the official account disappeared from the history list after I switched to a third-party provider"—it isn't actually gone, it's just been sorted into the other drawer. This split both makes it easy to believe a session was lost, and makes it inconvenient to review and resume all your sessions in one place.
|
||||||
|
|
||||||
|
**This switch exists to eliminate that fragmentation**: it makes the official subscription run under the `custom` tag too, so official and third-party sessions merge into one list and everything is easy to find and resume in a single place.
|
||||||
|
|
||||||
|
> ✅ **One important premise that runs through this whole guide, please remember it first**: this feature (unify / migrate / restore) **only ever rewrites that one classification tag `model_provider` in your session records, and it automatically makes a backup of the original file before every rewrite**. It never deletes, clears, or overwrites a single line of your conversations. So whenever this guide later mentions "some sessions are no longer visible," it almost always means "they've been sorted into the other drawer," not "the data is gone." If you're truly worried, jump straight to the [symptom reference table](#i-feel-like-my-sessions-are-gone-symptom-reference-table) and [verify the files are still there by hand](#verify-by-hand-your-session-files-are-still-on-disk-the-most-important-section).
|
||||||
|
|
||||||
|
## How it works (one-line version)
|
||||||
|
|
||||||
|
Think of it as **two drawers + automatic backup**:
|
||||||
|
|
||||||
|
- By default, official sessions live in the `openai` drawer and third-party sessions live in the `custom` drawer, invisible to each other;
|
||||||
|
- The switch makes **the official side use the `custom` drawer too**, merging the two drawers into one shared list;
|
||||||
|
- You can optionally choose to "move" your **existing official sessions** into the shared drawer as well (this step is called **migration**; it's optional and requires you to opt in by checking a box), and **before anything is moved a backup copy is made first**, so the whole process is **reversible**;
|
||||||
|
- **Authentication is completely unaffected**—your official subscription still uses your ChatGPT login and still goes through the official backend; only the session's classification tag changes.
|
||||||
|
|
||||||
|
For the full mechanism (what gets injected, why it's reversible, how migration / restore guarantee no data loss) see [The core mental model](#the-core-mental-model-two-drawers--automatic-backup) and the [Advanced mechanism appendix](#advanced-mechanism-appendix-for-users-who-want-to-truly-understand-how-it-works) at the end.
|
||||||
|
|
||||||
|
## How to use it (at a glance)
|
||||||
|
|
||||||
|
1. **Enable**: Settings -> General -> Codex App Enhancements -> turn on "Unified Codex session history" -> in the dialog decide whether to check "Also migrate existing official session history" (check it if you want your **earlier** official sessions merged into the unified list too; leave it unchecked if you only want unification from now on) -> confirm. See [What happens when you enable it](#what-happens-when-you-enable-it-step-by-step).
|
||||||
|
2. **Disable**: turn the same switch off -> in the dialog keep "restore exactly from backup" checked (it's checked by default) -> confirm, and the official sessions you migrated in will be precisely flipped back to the official list. See [What happens when you disable it](#what-happens-when-you-disable-it-step-by-step).
|
||||||
|
3. **Feel like a session is gone?** Don't panic—jump to the [symptom reference table](#i-feel-like-my-sessions-are-gone-symptom-reference-table) to locate it by symptom, and use the commands in the [verify by hand](#verify-by-hand-your-session-files-are-still-on-disk-the-most-important-section) section to see for yourself that the files are all there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The core mental model: two drawers + automatic backup
|
||||||
|
|
||||||
|
To understand this feature, you only need to remember two things: **drawers** and **backups**.
|
||||||
|
|
||||||
|
### Drawers: how Codex classifies sessions
|
||||||
|
|
||||||
|
Every time you start a Codex session, Codex records a tag `model_provider` in the session file header, marking "which provider this session was chatted with." Codex's **resume / history list is filtered precisely by the currently active tag**—it only shows sessions whose tag matches "the provider you're on right now."
|
||||||
|
|
||||||
|
- Sessions from your official subscription (ChatGPT login / OpenAI API key) carry the built-in tag **`openai`**.
|
||||||
|
- Every third-party provider CC Switch manages uses the tag **`custom`**.
|
||||||
|
|
||||||
|
So by default, official sessions and third-party sessions are inherently invisible to each other—they live in two different drawers. This is **Codex's own design**, not CC Switch losing anything.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Default state (unified switch off):
|
||||||
|
┌───────────────────────┐ ┌──────────────────────────┐
|
||||||
|
│ openai drawer │ │ custom drawer │
|
||||||
|
│ (official sessions) │ │ (third-party sessions) │
|
||||||
|
└───────────────────────┘ └──────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
visible only while visible only while
|
||||||
|
on the official provider on a third-party provider
|
||||||
|
|
||||||
|
The two drawers can't see each other.
|
||||||
|
```
|
||||||
|
|
||||||
|
**What the "Unified Codex session history" switch does is make the official subscription run under the `custom` tag too, merging the two drawers into one**, so official and third-party sessions appear in the same resume list. Note: **authentication doesn't change**—your official subscription still uses your ChatGPT login and still goes through the official backend; only the session's "classification tag" changes from `openai` to `custom`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
After the unified switch is on:
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ custom shared drawer │
|
||||||
|
│ official sessions + third-party sessions │
|
||||||
|
│ (appear in the same history / resume list) │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backups: a copy is made before every tag change
|
||||||
|
|
||||||
|
"Merging the drawers" requires changing the tag of some official sessions from `openai` to `custom` (this step is called **migration**, and it's **optional and requires you to opt in**). And **before any rewrite, CC Switch first copies the original file untouched** to here:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/
|
||||||
|
```
|
||||||
|
|
||||||
|
This backup is the sole basis for "restore exactly from backup" later. It makes the whole process **reversible**: at any time you can turn off the switch and precisely flip the official sessions you migrated in back to the `openai` drawer.
|
||||||
|
|
||||||
|
Remember these two words—**drawer** (a session just gets reclassified) and **backup** (a copy is always made before a change)—and everything that follows will be easy to understand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happens when you enable it: step by step
|
||||||
|
|
||||||
|
### Step 1: Find the switch
|
||||||
|
|
||||||
|
```text
|
||||||
|
Settings -> General -> Codex App Enhancements
|
||||||
|
```
|
||||||
|
|
||||||
|
In the "Codex App Enhancements" block there are two rows of switches; the **second row** (the blue history icon) is the subject of this guide:
|
||||||
|
|
||||||
|
> **Unified Codex session history**
|
||||||
|
|
||||||
|
Below it is a line of description text (verbatim):
|
||||||
|
|
||||||
|
> When enabled, the official subscription runs under the shared "custom" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.
|
||||||
|
|
||||||
|
> **Note**: this single line of description already previews three things—sessions will appear in one list, you can optionally migrate them in with an automatic backup, and resuming across providers "may fail." Here, "fail" means **you can't resume / can't generate a new turn**, not "the record is lost." This is exactly the core misunderstanding we'll dig into below.
|
||||||
|
|
||||||
|
### Step 2: Flip the switch from off to on -> a confirmation dialog pops up
|
||||||
|
|
||||||
|
The moment you flip the switch on, CC Switch **does not save immediately**; instead it first pops up a confirmation dialog. The dialog text reads as follows (verbatim):
|
||||||
|
|
||||||
|
- **Title**: Unified Codex session history
|
||||||
|
- **Body**:
|
||||||
|
|
||||||
|
> When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.
|
||||||
|
>
|
||||||
|
> You can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).
|
||||||
|
|
||||||
|
- **Checkbox**: Also migrate existing official session history
|
||||||
|
- **Confirm button**: I understand, enable
|
||||||
|
- **Cancel button**: Cancel
|
||||||
|
|
||||||
|
**This checkbox is unchecked by default.** This is an important fork in the road:
|
||||||
|
|
||||||
|
| Your choice | Effect | Where your data is right now |
|
||||||
|
|---|---|---|
|
||||||
|
| **Unchecked** (default) | Only switches the tag. **Only official sessions created after enabling** land in the `custom` shared drawer | Your official sessions from **before** enabling keep the `openai` tag, stay exactly where they were, still in `~/.codex/sessions/` |
|
||||||
|
| **Checked** | In addition to switching the tag, also migrates your **existing official sessions** from the `openai` drawer into the `custom` drawer | After being **copied to backup**, the old sessions' tag is rewritten to `custom`; the original data is covered by the backup |
|
||||||
|
|
||||||
|
> **If you want "my earlier official sessions to appear in the unified list too," you must opt in by checking this box.** Otherwise you'll run into "scenario A" in the reference table below—the old sessions look "gone," when in fact they're just sitting in the original drawer.
|
||||||
|
|
||||||
|
Click "Cancel" or click outside the dialog: the switch flips straight back to off and nothing happens.
|
||||||
|
Click "I understand, enable": the switch is saved as on, and CC Switch persists the configuration in the background (and runs the migration if you checked it).
|
||||||
|
|
||||||
|
### Step 3 (only if you checked migration): how migration runs + data safety
|
||||||
|
|
||||||
|
If you check "Also migrate existing official session history," CC Switch runs this procedure on your existing official sessions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
For each official (openai tag) session file:
|
||||||
|
① First copy the original file untouched into the backup directory <- data now has its first safety net
|
||||||
|
② Using "write a temp file -> replace the whole thing" atomic style,
|
||||||
|
change only the model_provider in the session_meta line at the header
|
||||||
|
from "openai" to "custom" <- not a single byte of the conversation body is touched
|
||||||
|
③ Update the index database state_5.sqlite to switch the tag in the same transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backup location**: `~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/`. Each migration produces one timestamped "generation directory," containing `jsonl/` (session copies), `state/` (index DB copy), and `meta.json` (recording which Codex directory this migration belongs to).
|
||||||
|
- **What's changed**: only the value of the single field `model_provider`. Your conversation content, reasoning content, and all body text are **kept exactly as is**.
|
||||||
|
- **What's deleted**: **nothing**. The backup is a "copy," the rewrite is an "atomic replacement of the same file," and at no point is any session or index deleted. The file is complete at every moment (either the old content or the new content, never empty or half-written).
|
||||||
|
|
||||||
|
After a successful migration, these existing official sessions show up in the unified list. **At this moment your data is**: ① the original copy in the backup directory; ② in the active file, only the classification tag changed, the content intact.
|
||||||
|
|
||||||
|
> **Note**: enabling and migration themselves **do not pop a success toast**. Migration runs as a side task on the backend during save; in the UI you'll only see the switch turn on. So "I didn't see a migration-success popup" is normal and does not mean failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happens when you disable it: step by step
|
||||||
|
|
||||||
|
### Step 1: Flip the switch from on to off -> probe for backups -> a confirmation dialog pops up
|
||||||
|
|
||||||
|
When disabling, CC Switch **first spends a moment probing whether there's a migration backup**, then pops up a confirmation dialog (so the disable dialog has a slight delay, which is normal). The text reads as follows (verbatim):
|
||||||
|
|
||||||
|
- **Title**: Turn off unified session history
|
||||||
|
- **Body**:
|
||||||
|
|
||||||
|
> After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.
|
||||||
|
|
||||||
|
- **Checkbox** (shown conditionally): Restore the official sessions migrated at enable time back to the official history (exact restore from backup)
|
||||||
|
- **Confirm button**: Turn off
|
||||||
|
- **Cancel button**: Cancel
|
||||||
|
|
||||||
|
> **Key point**: the body says the official subscription **will not see them**—**won't see**, not **delete**. The new sessions you chatted during the unified period are still fully present in the `custom` drawer; after disabling, the official side simply won't see them.
|
||||||
|
|
||||||
|
**This restore checkbox is checked by default.** In other words, the default behavior is "restore the official sessions you migrated in back to the official history at the same time you disable." You only need to keep it checked and click "Turn off."
|
||||||
|
|
||||||
|
If the checkbox **doesn't appear**, the system has determined there's no backup that needs restoring (either you never checked migration, or no backup was found)—in that case your existing official sessions were never touched, and turning off the switch returns them to the `openai` drawer on their own.
|
||||||
|
|
||||||
|
### Step 2: How restore runs (precise flip-back per the backup ledger)
|
||||||
|
|
||||||
|
If you keep the box checked and click "Turn off," CC Switch's restore flow goes like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
① First copy the current state once more into a separate restore-backup directory
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<timestamp>/
|
||||||
|
(restore itself backs up first, so restore won't lose data either)
|
||||||
|
② Comb through all migration backup generations, find the session ids "whose tag was originally openai," and assemble a "ledger"
|
||||||
|
③ Only for sessions that are [both in the ledger AND currently still custom], change the tag back to "openai"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the **dual condition** in step ③—it must be in the ledger (proving it really was migrated from the official side) AND currently still `custom` (showing you haven't manually changed it). Only when both conditions hold does it get flipped back. This guarantees the restore is both precise and free of collateral damage.
|
||||||
|
|
||||||
|
**At this moment your data is**: the migrated-back official sessions have their tag changed back to `openai` and reappear in the official list; meanwhile both the migration backup and the restore backup copies are still on disk.
|
||||||
|
|
||||||
|
### Step 3: Read the toast, confirm the result
|
||||||
|
|
||||||
|
Only the "disable + check restore" path pops a result toast. The toasts you may see (verbatim):
|
||||||
|
|
||||||
|
| Toast you see | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| **Official session history restored from backup ({{files}} session files, {{rows}} index rows)** | Restore succeeded. `{{files}}` / `{{rows}}` show the actual numbers |
|
||||||
|
| **No restorable migration backup for the current Codex directory** | Nothing to restore (**does not mean data is lost**, see scenario E in the reference table) |
|
||||||
|
| **Unified session history was re-enabled; restore skipped** | You turned the switch back on while restore was queued, so the system deliberately abandoned the restore (see scenario F) |
|
||||||
|
| **Failed to restore official session history, please try again** | The restore process errored; just retry, the data is not corrupted |
|
||||||
|
| **Save failed, please try again** | The disable save itself failed; in this case **restore is never triggered** and the switch flips back to its original position |
|
||||||
|
|
||||||
|
> **A thoughtful safety design**: if the "disable the switch" save fails, CC Switch **never runs the restore**. Otherwise you'd end up in a torn state of "switch still on, but sessions flipped back to the openai bucket." When the save fails, the switch **automatically flips back to its original position**, so you won't be stuck in a fake state of "looks off but didn't actually save."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "I feel like my sessions are gone?" symptom reference table
|
||||||
|
|
||||||
|
The six scenarios below are the situations where users most easily believe "sessions are gone." **The truth in every one is: the data is intact, it just moved drawers or is temporarily out of sight.** Use this table to locate your symptom first, then read the detailed explanation below.
|
||||||
|
|
||||||
|
| Scenario | What you see | The data truth | One-line fix |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** Didn't check migration | Old official sessions not in the unified list | All present, still carry the `openai` tag | Re-enable and check migration, or turn off the switch |
|
||||||
|
| **B** Cross-provider resume fails | Can't resume / errors out | Files intact, the ciphertext just can't be decrypted across backends | Resume on the original provider; to only read content, read the jsonl directly |
|
||||||
|
| **C** Proxy takeover / injection refused | No migration and no restore | Migration was safely skipped, files untouched | Exit takeover -> restart and retry; or just turn off the switch |
|
||||||
|
| **D** New sessions didn't return to official after restore | New sessions from the unified period aren't on the official side | They're in the `custom` drawer, untouched by design | Switch to a third-party provider to see them |
|
||||||
|
| **E** Toast "no restorable backup" | Restore "failed" | Usually nothing was ever migrated, sessions are in the original drawer | Turn off the switch and the official sessions reappear automatically |
|
||||||
|
| **F** Toast "switch was re-enabled, restore skipped" | Restore refused | Prevents a torn data state, nothing was changed | Fully turn off the switch first, then restore |
|
||||||
|
|
||||||
|
### Scenario A: You enabled the switch but didn't check migration -> old official sessions "disappear"
|
||||||
|
|
||||||
|
**Symptom**: you turned on the unified switch, but didn't check "Also migrate existing official session history" in the enable dialog (it's unchecked by default). After enabling, your earlier official sessions seem to be gone from the list.
|
||||||
|
|
||||||
|
**The truth**: 100% of your data is present, not a single line moved. The switch only takes effect on official sessions "created after enabling"; your official sessions from **before** enabling still carry the `openai` tag and sit untouched in `~/.codex/sessions/`. You're now on the `custom` drawer, so naturally you can't see the old sessions left in the `openai` drawer—that's the entire reason for the "apparent disappearance."
|
||||||
|
|
||||||
|
**What to do** (pick either):
|
||||||
|
1. **Re-enable the switch and check "Also migrate existing official session history,"** which moves the old sessions to the `custom` drawer and they immediately appear in the unified list (automatic backup before the rewrite).
|
||||||
|
2. **Or simply turn off the unified switch**, the official side runs on the `openai` drawer again, and the old sessions reappear right where they were.
|
||||||
|
|
||||||
|
### Scenario B: Cross-provider resume of an old session fails -> you think "this session is broken / gone"
|
||||||
|
|
||||||
|
**Symptom**: after unification, the list shows an old session chatted with "another provider." You switch to your current provider and click "Resume," but it errors out or can't connect.
|
||||||
|
|
||||||
|
**The truth**: the session file is intact; what's lost is not data, it's "cross-backend decryption ability." A Codex session stores an encrypted block of reasoning content `encrypted_content`, and **this ciphertext can only be decrypted by the backend that originally generated it**. Using provider B to resume a session generated by provider A means B can't decrypt A's ciphertext -> resume fails. This is **a design limitation of upstream Codex (by design)** and has nothing to do with whether CC Switch touched the file. The text content of the session is readable at any time.
|
||||||
|
|
||||||
|
> This is the **only "looks like a real problem" genuine exception** in this whole guide—but note: it just means **you can't resume (can't generate a new turn)**, and **the original file is still fully present**, the conversation text readable at any time.
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- **Resume with "the provider that originally created this session,"** so it can decrypt normally and connect.
|
||||||
|
- Just want to read the history without continuing? Read that session's `.jsonl` file directly (commands at the end).
|
||||||
|
- Rule of thumb: **cross-provider is better suited to "starting a new session"; resume old sessions on their original provider whenever possible.**
|
||||||
|
|
||||||
|
### Scenario C: You enabled the switch and checked migration, but migration was silently skipped -> you think "migration lost the sessions"
|
||||||
|
|
||||||
|
**Symptom**: you enabled the switch and checked migration, but the old official sessions neither entered the unified list nor could be restored when you turned the switch off (or the restore checkbox didn't even appear in the disable dialog, see scenario E). You suspect migration lost the sessions during the process.
|
||||||
|
|
||||||
|
**The truth**: migration **never ran**, so it couldn't have lost anything—not a single character of your sessions was changed. CC Switch has a safety gate before migration: it checks whether Codex's live config (`~/.codex/config.toml`) is **actually** routed to the shared `custom` drawer right now, and only migrates if the routing truly went there. The following two situations are judged "not yet unified" (internal reason code `live_not_unified`), so CC Switch **deliberately skips the migration, preserves your switch and migration intent, and migrates later once the conditions are met**:
|
||||||
|
|
||||||
|
- **During proxy takeover**: CC Switch's proxy has taken over the live config, and the live config during takeover doesn't carry the unified routing marker.
|
||||||
|
- **Injection refused**: your `config.toml` already has a manually specified `model_provider`, or there's already a differently-shaped `[model_providers.custom]` table (possibly with a third-party address). To avoid incorrectly routing official traffic to a third-party backend, CC Switch would rather not inject and not migrate.
|
||||||
|
|
||||||
|
Skipping migration = touching no session files. **No migration means nothing moved, so there's nothing to lose.** This is "safe deferral," not "failure with data loss."
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Exit proxy takeover -> **restart CC Switch**: on startup it automatically retries migration (your migration intent is preserved the whole time).
|
||||||
|
- Check `~/.codex/config.toml`: if there's a conflicting route you wrote by hand, clean up the conflict before enabling the switch.
|
||||||
|
- If you'd rather not bother: just turn off the switch, the official sessions still display normally on the `openai` drawer, completely intact.
|
||||||
|
|
||||||
|
### Scenario D: You turned off the switch and restored, but "the new sessions chatted during the unified period" didn't return to official -> you think "the new sessions are gone"
|
||||||
|
|
||||||
|
**Symptom**: during the unified period, you chatted a few more new sessions with the official account. Later you turned off the switch, checked restore, and after restoring you find those new sessions didn't return to the official drawer.
|
||||||
|
|
||||||
|
**The truth**: this is **intentional** design; the new sessions are perfectly fine in the `custom` drawer, visible and resumable. Restore is based on "the backup ledger from migration time"—**only sessions that were originally migrated in from the `openai` drawer** are recorded in the backup and get precisely flipped back to `openai`. The sessions you **created during the unified period** are in no backup ledger; and after unification both official and third-party use the `custom` tag, so **CC Switch can't tell whether a new session was chatted with the official account or a third-party**. To avoid wrongly stuffing third-party sessions into the official history, the product decision is: these new sessions all stay in the `custom` (third-party) history and are never moved automatically. The disable dialog's text says this explicitly too—"Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history."
|
||||||
|
|
||||||
|
**What to do**:
|
||||||
|
- Switch to any third-party provider (the `custom` drawer) to see these sessions in the history list.
|
||||||
|
- To read content, read the `.jsonl` directly; to resume, follow scenario B's rule (go back to the backend that originally generated it).
|
||||||
|
- If you really want to manually return **one specific** session to official: there's currently no automatic button (deliberately omitted, to avoid misjudging the direction). Advanced users can, **after backing up** that file first, manually change `model_provider` in the `session_meta` of the first line of its `.jsonl` from `custom` back to `openai` (an advanced operation; always make a copy before editing).
|
||||||
|
|
||||||
|
### Scenario E: Restore toast "No restorable migration backup for the current Codex directory" -> you think "restore failed = data is gone"
|
||||||
|
|
||||||
|
**Symptom**: you checked restore when turning off the switch, and got the toast "No restorable migration backup for the current Codex directory." You panic: restore failed, is the data completely gone?
|
||||||
|
|
||||||
|
**The truth**: "nothing to restore" ≠ "data is lost." On the contrary, it's usually because **there was no migration that needed restoring**. Common reasons:
|
||||||
|
|
||||||
|
- **You never checked "migrate existing official sessions" in the first place**: with no migration, there's naturally no migration backup and no sessions to flip back. Your old official sessions have been in the `openai` drawer all along and reappear after you turn off the switch (same as scenario A). (In this case, the disable dialog may **not even show the restore checkbox**—because the system can't find any backup.)
|
||||||
|
- **You've already restored once**: the session tags have all been flipped back to `openai`, so clicking again naturally finds "no targets still in custom to restore"—this is **idempotent protection, not failure**.
|
||||||
|
- **You switched Codex directories**: restore only recognizes the backup ledger belonging to the **current** directory; switch directories and it can't find the old directory's ledger. Just switch the directory back.
|
||||||
|
|
||||||
|
In all three cases, no session was deleted.
|
||||||
|
|
||||||
|
**What to do**: use the end-of-guide commands to count the total session files in `~/.codex/sessions/` and confirm the files are all there; then check whether `~/.cc-switch/backups/` contains a `codex-official-history-unify-v1` directory—if even this directory is absent, you never triggered a migration and the sessions have been in their original drawer all along.
|
||||||
|
|
||||||
|
### Scenario F: Restore refused, toast "Unified session history was re-enabled; restore skipped"
|
||||||
|
|
||||||
|
**Symptom**: you turned off the switch -> checked restore -> but you were quick and immediately turned the switch back on, then saw the toast "Unified session history was re-enabled; restore skipped."
|
||||||
|
|
||||||
|
**The truth**: this is a safeguard against putting your data into a "torn" state, and again no sessions are lost. The restore action is "flip session tags from `custom` back to `openai`," but if the switch is on again at this moment, the live config is routing to `custom`—flipping history back to `openai` on one side while new sessions land in `custom` on the other would artificially tear sessions in two. So when CC Switch detects "the switch is on again," it **deliberately abandons this restore and changes nothing**. Sessions stay as they are, with no deletion or corruption.
|
||||||
|
|
||||||
|
**What to do**: to truly restore, **turn the switch off and keep it off** (don't immediately turn it back on), then do disable + check restore; to keep things unified, don't restore, and let the sessions stay in the `custom` shared drawer for normal use.
|
||||||
|
|
||||||
|
**The overriding principle: CC Switch's unify / migrate / restore only ever changes a single tag field in a session, and automatically backs up before every rewrite. It never deletes your conversations. Out of sight ≠ gone—look in the other drawer, or use the commands below to confirm with your own eyes.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verify by hand: your session files are still on disk (the most important section)
|
||||||
|
|
||||||
|
No amount of text beats seeing it for yourself. Below are the **real paths** (taken from the CC Switch source) and how to view session files and backup directories on different systems. **The whole process is read-only and changes nothing; you're strongly encouraged to try it by hand.**
|
||||||
|
|
||||||
|
### The simplest way: open it directly in a file manager (no command line at all)
|
||||||
|
|
||||||
|
- **macOS (Finder)**: press `Cmd + Shift + G`, paste `~/.codex/sessions` and hit Enter to see a pile of `.jsonl` session files and their modification times; for the backup directory paste `~/.cc-switch/backups`.
|
||||||
|
- **Windows (File Explorer)**: paste `%USERPROFILE%\.codex\sessions` into the address bar and hit Enter to see the session folders and the `.jsonl` files inside; for the backup directory paste `%USERPROFILE%\.cc-switch\backups`.
|
||||||
|
|
||||||
|
**As long as you can see a batch of `.jsonl` files here, that proves your session data is intact on disk.** The file count and modification times are more intuitive than any amount of text.
|
||||||
|
|
||||||
|
### Where exactly your session / history files live
|
||||||
|
|
||||||
|
| Content | Real path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **Session body (the core)** | `~/.codex/sessions/` (includes date-based subdirectories, recursive) | One `.jsonl` text file per session—**this is your conversation content** |
|
||||||
|
| **Archived sessions** | `~/.codex/archived_sessions/` | Also `.jsonl` |
|
||||||
|
| **Session index database** | `~/.codex/state_5.sqlite` | The `model_provider` column of the `threads` table is the "drawer tag"—**this is the actual classification source the resume list reads** |
|
||||||
|
| **Migration backup** (auto-created when migration is enabled) | `~/.cc-switch/backups/codex-official-history-unify-v1/<timestamp>/` | Contains `jsonl/`, `state/`, `meta.json` |
|
||||||
|
| **Restore backup** (auto-created when you restore) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<timestamp>/` | A safety copy taken before restore |
|
||||||
|
|
||||||
|
> **Note**: if you've changed the Codex directory in CC Switch, or set `sqlite_home` in `config.toml`, replace `~/.codex` above with your actual directory. Below, `~` = your user home directory.
|
||||||
|
|
||||||
|
### macOS / Linux commands
|
||||||
|
|
||||||
|
**1. Count the total number of session files (this is the hard evidence of "nothing lost")**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Count the total number of session files -- as long as this number matches your expectation, the data is all there
|
||||||
|
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# Show the 10 most recently modified session files
|
||||||
|
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||||
|
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. (Auxiliary) See how many sessions are in each "drawer"**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Number of session files in the official drawer (openai)
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# Number of session files in the unified drawer (custom)
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# See the tag distribution at a glance
|
||||||
|
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important note, don't let this step scare you**: **early versions of Codex did not write the `model_provider` field into the `.jsonl`**, so these old official sessions **can't be counted** by the grep above—but they're still classified as `openai` in the index database `state_5.sqlite` and still show up in the resume list. So **judge "nothing lost" by the total file count from step 1**—the per-drawer grep is only there to help you understand the classification, and counting fewer than the total file count is **completely normal** and never means "a batch was lost."
|
||||||
|
|
||||||
|
**3. (Advanced) Query the index database `state_5.sqlite`—the classification the resume list actually reads**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Requires sqlite3 to be installed; skip if you don't have it
|
||||||
|
sqlite3 ~/.codex/state_5.sqlite \
|
||||||
|
"SELECT COALESCE(model_provider,'<empty>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
> This `threads` table is the actual classification source Codex's resume list reads; the `openai` row count ≈ the number of sessions you can see in your official drawer. It may not match step 2's jsonl grep—the reason is exactly what's described above: "old sessions don't write the jsonl field, but they're still openai in the index database." A mismatch between the two is not an anomaly.
|
||||||
|
|
||||||
|
**4. Read the content of a specific session directly (confirm the conversation text is still there)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Replace <filename> with one of the .jsonl paths listed by ls above
|
||||||
|
python3 -m json.tool < "<filename>.jsonl" 2>/dev/null | head -50
|
||||||
|
|
||||||
|
# Or just open it in an editor (plain text)
|
||||||
|
open -e "<filename>.jsonl" # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Look at CC Switch's backup directory (proof that a copy was kept before migration / restore)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows commands (PowerShell)
|
||||||
|
|
||||||
|
The session directory is usually at `C:\Users\<your username>\.codex\`, and backups at `C:\Users\<your username>\.cc-switch\backups\`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Total number of session files (hard evidence of "nothing lost")
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||||
|
|
||||||
|
# 2. The 10 most recently modified sessions
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||||
|
|
||||||
|
# 3. (Auxiliary) How many session files in the official (openai) / unified (custom) drawers
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||||
|
|
||||||
|
# 4. Look at the backup directories
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||||
|
```
|
||||||
|
|
||||||
|
> Same reminder: the step-3 grep counting **fewer** than the total file count is normal (old sessions don't write that field); judge "nothing lost" by the **total file count** from step 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced mechanism appendix (for users who want to truly understand how it works)
|
||||||
|
|
||||||
|
### 1. The bucketing mechanism (the essence of the drawers)
|
||||||
|
|
||||||
|
Codex's resume / history list filters by the currently active `model_provider` id with **exact string matching**. The **first line** of a session's `.jsonl` file is a `type:"session_meta"` record whose `payload.model_provider` is the drawer that session belongs to (`grep -rl` counts a file as long as the tag appears once anywhere in it, so no line-by-line parsing is needed; sessions from old versions that didn't write the field can't be counted). What actually drives the resume list is the `threads.model_provider` column of the index database `state_5.sqlite`. When `config.toml` has no explicit `model_provider`, the official subscription falls into the built-in default id `openai`; all of CC Switch's third-party providers uniformly use `custom`.
|
||||||
|
|
||||||
|
### 2. What the switch does (injection, lives only in live)
|
||||||
|
|
||||||
|
When enabled, CC Switch injects the following into the official live `config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model_provider = "custom"
|
||||||
|
|
||||||
|
[model_providers.custom]
|
||||||
|
name = "OpenAI"
|
||||||
|
requires_openai_auth = true
|
||||||
|
supports_websockets = true
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field has a purpose: `requires_openai_auth = true` keeps authentication going through the ChatGPT login in `auth.json`, with the base_url defaulting back to the official Codex backend; `name = "OpenAI"` lets Codex's official feature gates (web search, remote compaction, etc.) keep matching; `supports_websockets = true` restores the capability that custom entries lose by default; `wire_api = "responses"` uses the official responses protocol. **The net effect is: authentication is unchanged, only the bucket name changed.**
|
||||||
|
|
||||||
|
**Key invariant: this injection can only exist in the live `config.toml`, and is never written into the database's stored configuration.** When you switch away from the official provider and write live back to the database, CC Switch strips this injection precisely (it strips only when the shape exactly matches the injected artifact; a third-party-customized `custom` table is kept as is). Precisely because of this, "turning off the switch + switching once" fully restores live, and the database always holds your original clean official configuration—this is the cornerstone of the whole switch's reversibility.
|
||||||
|
|
||||||
|
### 3. The two refusal gates for injection (corresponding to scenario C)
|
||||||
|
|
||||||
|
- `config.toml` already has an explicit `model_provider` -> don't override the user's route;
|
||||||
|
- A differently-shaped `[model_providers.custom]` table already exists (possibly with a third-party `base_url`) -> refuse injection, otherwise ChatGPT OAuth traffic would be routed to the wrong backend.
|
||||||
|
|
||||||
|
When injection is refused, live is not unified, and the migration gate (checking whether live's `model_provider` equals `custom` after trim) judges `live_not_unified` -> skip migration, preserve intent, and do it later on the next startup retry. This is "safe deferral," not "failure with data loss."
|
||||||
|
|
||||||
|
### 4. The three session classes (which determine the migration / restore boundary)
|
||||||
|
|
||||||
|
- **Class A**: existing official sessions migrated in at enable time—the backup is the ledger, and they can be precisely restored back to `openai`;
|
||||||
|
- **Class B**: created during the unified period—in no backup, and official / third-party can't be distinguished, so they're **never moved automatically** (stay `custom`);
|
||||||
|
- **Class C**: pure third-party history from before enabling—never touched.
|
||||||
|
|
||||||
|
### 5. The safety of migration / restore (data is never truly deleted; where the guarantee comes from)
|
||||||
|
|
||||||
|
Four layers of design jointly guarantee that under **all paths, normal and abnormal**, the original session data is never truly deleted.
|
||||||
|
|
||||||
|
- **Only change the field, never the body**: migration / restore only switch the `model_provider` value in session metadata between `openai` and `custom`; conversation content, `response_item`, and `encrypted_content` are all kept exactly as is.
|
||||||
|
- **Always copy a backup before a rewrite**: jsonl uses file copy, the state DB uses a full SQLite copy, both stored in a timestamped generation directory. Migration backups live in `codex-official-history-unify-v1/`, restore backups in the separate `codex-official-history-unify-restore-v1/`—the two are kept apart to keep the ledger clean.
|
||||||
|
- **Only move, never delete + atomic writes**: all jsonl rewrites go through "temp file + whole-file replacement," and the state DB goes through a transactional `UPDATE`, with no deletion of any session or index at any point. The file is complete at every moment.
|
||||||
|
- **Pessimistic skip + idempotent and retryable**: when buckets are inconsistent (`live_not_unified`), it would rather not migrate; a single process lock serializes migration and restore to avoid "startup retry / post-save background task / disable-time restore" concurrently rewriting the same batch of files in both directions; the completion marker is bound to the Codex directory and written conditionally to prevent missed migrations; restore uses the "in the ledger + currently still custom" dual condition to prevent wrong changes. Restore scans the union of all backup generations, so even after many switch cycles it can still restore early-migrated sessions; a repeated restore returns `nothing_to_restore`, which is idempotent protection rather than failure.
|
||||||
|
|
||||||
|
### 6. Cross-backend encrypted_content (corresponding to scenario B)
|
||||||
|
|
||||||
|
The reasoning ciphertext inside a session can only be decrypted by the backend that generated it; upstream Codex by design does not support cross-backend decryption. This is the root cause of "resume failure" and has nothing to do with file integrity—the session `.jsonl` sits fully on disk and `encrypted_content` is intact too. Switching back to the original provider to resume, or starting a new session, both work fine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Keep Codex Remote Control and Official Plugins While Using Third-Party APIs: CC Switch Setup Guide](./codex-official-auth-preservation-guide-en.md)
|
||||||
|
- [Using DeepSeek-Style Chat APIs in Codex: CC Switch Local Routing Guide](./codex-deepseek-routing-guide-en.md)
|
||||||
|
- The "Codex App Enhancements" section in the CC Switch user manual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**One last word for you**: what you see as "sessions disappeared / resume failed" is essentially **the session being moved to another history list (drawer), or the other backend being unable to decrypt the old reasoning content**; the files always sit untouched in `~/.codex/sessions/` (and `state_5.sqlite`). Checking "restore from backup" when you turn off the switch precisely flips the official sessions you migrated in back to the official list; and even if you don't restore, both the original `.jsonl` files and the backup copies under `~/.cc-switch/backups/codex-official-history-unify-*/` are all still there—**the data is never truly lost.**
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# Codex セッション履歴の統一: 機能紹介と利用ガイド(CC Switch)
|
||||||
|
|
||||||
|
> 対象バージョン: CC Switch v3.16.x 以降。本記事は現在のコードをもとに整理しており、コマンドとパスはご自身で検証できます。例示には匿名化したデータを使用しており、実際のセッション内容や API Key は含まれていません。
|
||||||
|
|
||||||
|
## この機能とは何か
|
||||||
|
|
||||||
|
「Codex セッション履歴を統一」は、CC Switch v3.16.x が Codex 向けに新しく追加したスイッチです。その場所は **設定 → 一般 → 「Codex アプリ拡張」グループ** の中にあります(「Codex アプリ拡張」はこのグループの見出しで、スイッチ自体は「Codex セッション履歴を統一」という名前です)。オンにすると、**公式サブスクリプション(ChatGPT ログイン / OpenAI API Key)のセッションが、CC Switch で管理するすべてのサードパーティプロバイダーのセッションと同じ履歴 / セッション再開リストに表示されます**——もう、互いに見えない 2 つのリストに分断されることはありません。
|
||||||
|
|
||||||
|
## どんな問題を解決するのか
|
||||||
|
|
||||||
|
Codex は「プロバイダーのラベル」(`model_provider` というフィールド)でセッションを分類しており、しかも **セッション再開 / 履歴リストには、現在アクティブなプロバイダーと同じラベルのセッションしか表示しません**。そのため、セッションは自然と 2 つの「引き出し」に分けられてしまいます。
|
||||||
|
|
||||||
|
- 公式サブスクリプションのセッションは、Codex 内蔵の **`openai`** ラベルに分類されます。
|
||||||
|
- CC Switch が管理するすべてのサードパーティプロバイダーは、**`custom`** ラベルに分類されます。
|
||||||
|
|
||||||
|
2 つの引き出しは互いに見えません。**公式とサードパーティを頻繁に切り替えている** 場合、この分断に遭遇します——「さっき公式で話したセッションが、サードパーティに切り替えたら履歴リストから消えた」というように。実際にはなくなっておらず、別の引き出しに分けられただけです。この分断は、セッションが失われたと誤解させやすいうえに、すべてのセッションを 1 か所でまとめて振り返ったり再開したりするのにも不便です。
|
||||||
|
|
||||||
|
**このスイッチは、まさにこの分断を解消するためのものです**。公式サブスクリプションも `custom` ラベルで動作させることで、公式とサードパーティのセッションが同じリストに統合され、探すのも再開するのも 1 か所で済みます。
|
||||||
|
|
||||||
|
> ✅ **本記事全体を貫く重要な前提を、まず覚えておいてください**: この機能(統一 / 移行 / 復元)は **常にセッション記録内のあの分類ラベル `model_provider` 1 つだけを書き換え、しかも毎回書き換える前に自動で元ファイルをバックアップします**。あなたの会話を 1 文たりとも削除・消去・上書きすることはありません。ですので、本記事の後半で「あるセッションが見えなくなった」とあっても、そのほとんどは「別の引き出しに分けられた」だけであり、「データが消えた」わけではありません——本当に心配なときは、[症状対照表](#会話が消えた症状対照表) と [自分の目でファイルが残っていることを確認する](#自分の目で確認-セッションファイルはディスク上に残っている最重要セクション) を直接ご覧ください。
|
||||||
|
|
||||||
|
## 動作原理(一言版)
|
||||||
|
|
||||||
|
これを **2 つの引き出し + 自動バックアップ** と考えてください。
|
||||||
|
|
||||||
|
- デフォルトでは、公式セッションは `openai` の引き出しに、サードパーティのセッションは `custom` の引き出しにあり、互いに見えません。
|
||||||
|
- スイッチは **公式も `custom` の引き出しを使うように** させ、2 つの引き出しを 1 つの共有リストに統合します。
|
||||||
|
- **既存の公式の古いセッション** も一緒に共有の引き出しへ「移す」ことを選べます(この操作を **移行** と呼びます。任意で、能動的にチェックを入れる必要があります)。そして **いかなる移動の前にも、まずコピーをバックアップ** するので、プロセス全体が **可逆** です。
|
||||||
|
- **認証はまったく影響を受けません**——公式サブスクリプションは引き続きあなたの ChatGPT ログインを使い、引き続き公式バックエンドを経由します。変わるのはセッションの分類ラベルだけです。
|
||||||
|
|
||||||
|
完全な仕組み(何が注入されるのか、なぜ可逆なのか、移行 / 復元がどうやってデータ消失を防ぐのか)は、後述の [コア・メンタルモデル](#コアメンタルモデル-2-つの引き出し--自動バックアップ) と巻末の [応用原理付録](#応用原理付録仕組みを本当に理解したい人向け) をご覧ください。
|
||||||
|
|
||||||
|
## 使い方(クイック)
|
||||||
|
|
||||||
|
1. **有効化**: 設定 → 一般 → Codex アプリ拡張 → 「Codex セッション履歴を統一」をオン → ダイアログで「既存の公式セッション履歴も移行する」にチェックを入れるか決める(**以前** の公式セッションも統一リストに合流させたいならチェックを入れる。今後だけ統一したいならチェックを入れない)→ 確定。詳しくは [有効化したとき何が起きるか](#有効化したとき何が起きるか-ステップ別解説) を参照。
|
||||||
|
2. **無効化**: 同じスイッチをオフにする → ダイアログで「バックアップから正確に復元する」のチェックを保持(デフォルトでチェック済み)→ 確定すれば、移行した公式セッションを正確に公式リストへ戻せます。詳しくは [無効化したとき何が起きるか](#無効化したとき何が起きるか-ステップ別解説) を参照。
|
||||||
|
3. **セッションが消えた気がする?** 慌てずに [症状対照表](#会話が消えた症状対照表) へ進んで症状から原因を特定し、[自分の目で確認](#自分の目で確認-セッションファイルはディスク上に残っている最重要セクション) セクションのコマンドで、ファイルがすべて残っていることを自分の目で確かめてください。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## コア・メンタルモデル: 2 つの引き出し + 自動バックアップ
|
||||||
|
|
||||||
|
この機能を理解するには、**引き出し** と **バックアップ** の 2 つだけ覚えれば十分です。
|
||||||
|
|
||||||
|
### 引き出し: Codex はどうやってセッションを分類するか
|
||||||
|
|
||||||
|
Codex セッションを 1 つ開くたびに、Codex はセッションファイルの先頭に `model_provider` というラベルを記録し、「このセッションはどのプロバイダーで話したか」を示します。Codex の **セッション再開 / 履歴リストは、現在アクティブなこのラベルで正確にフィルタリングされます**——「今あなたが使っているプロバイダー」と同じラベルのセッションだけが表示されます。
|
||||||
|
|
||||||
|
- 公式サブスクリプション(ChatGPT ログイン / OpenAI API Key)のセッションのラベルは、内蔵の **`openai`** です。
|
||||||
|
- CC Switch が管理するすべてのサードパーティプロバイダーは、一律にラベル **`custom`** を使います。
|
||||||
|
|
||||||
|
そのためデフォルトでは、公式セッションとサードパーティセッションは生まれつき互いに見えません——2 つの異なる引き出しにあるからです。これは **Codex 自身の設計** であり、CC Switch が何かをなくしたわけではありません。
|
||||||
|
|
||||||
|
```text
|
||||||
|
デフォルト状態(統一スイッチをオンにしていない):
|
||||||
|
┌──────────────────────┐ ┌──────────────────────────────────┐
|
||||||
|
│ openai の引き出し │ │ custom の引き出し │
|
||||||
|
│ (公式セッション) │ │ (サードパーティのセッション) │
|
||||||
|
└──────────────────────┘ └──────────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
公式のときは サードパーティのときは
|
||||||
|
こちらだけ表示 こちらだけ表示
|
||||||
|
|
||||||
|
(2 つの引き出しは互いに見えない)
|
||||||
|
```
|
||||||
|
|
||||||
|
**「Codex セッション履歴を統一」スイッチがすることは、公式サブスクリプションも `custom` ラベルで動作させ、2 つの引き出しを 1 つに統合することです**。その結果、公式セッションとサードパーティセッションが同じセッション再開リストに表示されます。注意してほしいのは、**認証は変わらない** ということです——あなたの公式サブスクリプションは引き続き ChatGPT ログインを使い、引き続き公式バックエンドを経由します。変わるのはセッションの「分類ラベル」が `openai` から `custom` になることだけです。
|
||||||
|
|
||||||
|
```text
|
||||||
|
統一スイッチをオンにした後:
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ custom 共有引き出し │
|
||||||
|
│ 公式セッション + サードパーティのセッション │
|
||||||
|
│ (同じ履歴 / 再開リストに表示される) │
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### バックアップ: ラベルを変更する前に必ずコピーを取る
|
||||||
|
|
||||||
|
「引き出しの統合」では、一部の公式セッションのラベルを `openai` から `custom` に変更する必要があります(この操作を **移行** と呼び、これは **任意で、あなたが能動的にチェックを入れる必要があります**)。そして **どの書き換えの前にも、CC Switch はまず元ファイルをそのままコピー** して、ここに保存します。
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/
|
||||||
|
```
|
||||||
|
|
||||||
|
このバックアップが、後の「バックアップから正確に復元する」ための唯一の拠り所です。これによってプロセス全体が **可逆** になります——いつでもスイッチをオフにして、移行した公式セッションを正確に `openai` の引き出しへ戻せます。
|
||||||
|
|
||||||
|
この 2 つの言葉——**引き出し**(セッションは分類が変わるだけ)、**バックアップ**(変更前に必ずコピー)——を覚えておけば、以降の内容はすべて簡単に理解できます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 有効化したとき何が起きるか: ステップ別解説
|
||||||
|
|
||||||
|
### Step 1: スイッチを見つける
|
||||||
|
|
||||||
|
```text
|
||||||
|
設定 → 一般 → Codex アプリ拡張
|
||||||
|
```
|
||||||
|
|
||||||
|
「Codex アプリ拡張」のセクションには 2 行のスイッチがあり、**2 行目**(青い履歴アイコン)が本ガイドの主役です。
|
||||||
|
|
||||||
|
> **Codex セッション履歴を統一**
|
||||||
|
|
||||||
|
その下には説明文があります(逐語)。
|
||||||
|
|
||||||
|
> オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。
|
||||||
|
|
||||||
|
> **注意**: この説明文には、すでに 3 つのことが予告されています——同じリストに表示される、移行を選べて自動バックアップされる、プロバイダーをまたいだ再開は「失敗する場合がある」。ここでの「再開に失敗する」は **続けられない、新しいターンを生成できない** という意味であり、「記録が消える」ではありません。これこそ、この後で重点的に解きほぐす核心的な誤解です。
|
||||||
|
|
||||||
|
### Step 2: スイッチをオフからオンに切り替える → 確認ダイアログが表示される
|
||||||
|
|
||||||
|
スイッチをオンに切り替えると、CC Switch は **すぐには保存せず**、まず確認ダイアログを表示します。ダイアログの文言は次のとおりです(逐語)。
|
||||||
|
|
||||||
|
- **タイトル**: Codex セッション履歴を統一
|
||||||
|
- **本文**:
|
||||||
|
|
||||||
|
> オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。
|
||||||
|
>
|
||||||
|
> 既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。
|
||||||
|
|
||||||
|
- **チェックボックス**: 既存の公式セッション履歴も移行する
|
||||||
|
- **確認ボタン**: 理解しました、オンにする
|
||||||
|
- **キャンセルボタン**: キャンセル
|
||||||
|
|
||||||
|
**このチェックボックスはデフォルトでオフです。** これは重要な分岐点です。
|
||||||
|
|
||||||
|
| あなたの選択 | 効果 | この時点でデータはどこにあるか |
|
||||||
|
|---|---|---|
|
||||||
|
| **チェックしない**(デフォルト) | ラベルを切り替えるだけ。**オンにした後に新規作成された公式セッションだけ** が `custom` の共有引き出しに入る | あなたが **オンにする前** の公式の古いセッションは、ラベルが `openai` のまま、その場で動かず、引き続き `~/.codex/sessions/` にある |
|
||||||
|
| **チェックする** | ラベルの切り替えに加えて、**既存の公式の古いセッション** も `openai` の引き出しから `custom` の引き出しへ移行する | 古いセッションは **コピーしてバックアップ** された後、ラベルが `custom` に書き換えられる。元データはバックアップで保護される |
|
||||||
|
|
||||||
|
> **「以前の公式セッションも統一リストに表示したい」なら、必ずこのチェックボックスを能動的にオンにしてください。** さもないと、下の対照表の「シナリオ A」に遭遇します——古いセッションが「消えた」ように見えますが、実際は元の引き出しに残っているだけです。
|
||||||
|
|
||||||
|
「キャンセル」を押すか、ダイアログの外側をクリックすると、スイッチはそのままオフ状態に戻り、何も起きません。
|
||||||
|
「理解しました、オンにする」を押すと、スイッチはオンとして保存され、CC Switch はバックグラウンドで設定をディスクに書き込みます(移行にチェックを入れていれば、移行を実行します)。
|
||||||
|
|
||||||
|
### Step 3(移行にチェックを入れた場合のみ): 移行はどう実行されるか + データの安全性
|
||||||
|
|
||||||
|
「既存の公式セッション履歴も移行する」にチェックを入れた場合、CC Switch はあなたの公式の古いセッションに対して、次の一連の流れを実行します。
|
||||||
|
|
||||||
|
```text
|
||||||
|
公式(openai ラベル)の各セッションファイルについて:
|
||||||
|
① まず元ファイルをそのままバックアップディレクトリへコピー ← データの一次保険ができる
|
||||||
|
② 「一時ファイルに書く → まるごと置換」という原子的な方法で、
|
||||||
|
先頭行 session_meta 内の model_provider を
|
||||||
|
"openai" から "custom" へ変更するだけ ← 会話本文は 1 バイトも触らない
|
||||||
|
③ インデックス DB state_5.sqlite も同じトランザクション内でラベルを変更
|
||||||
|
```
|
||||||
|
|
||||||
|
- **バックアップの場所**: `~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/`。移行のたびに、タイムスタンプ付きの「世代ディレクトリ」を生成し、その中に `jsonl/`(セッションのコピー)、`state/`(インデックス DB のコピー)、`meta.json`(この移行がどの Codex ディレクトリに属するかの記録)が含まれます。
|
||||||
|
- **変更するもの**: `model_provider` というフィールドの値だけ。あなたの会話内容、推論内容、すべての本文は **そのまま保持** されます。
|
||||||
|
- **削除するもの**: **何も削除しません**。バックアップは「コピー」、書き換えは「同一ファイルの原子的な置換」であり、全工程でセッションやインデックスを削除する操作は一切ありません。ファイルはいかなる時点でも完全です(古い内容か新しい内容かのどちらかであり、空や中途半端になることは決してありません)。
|
||||||
|
|
||||||
|
移行が成功すると、これらの公式の古いセッションが統一リストに表示されます。**この時点でのあなたのデータ**: ① 元のコピーがバックアップディレクトリにある。② アクティブファイルは分類ラベルが変わっただけで、内容は無傷。
|
||||||
|
|
||||||
|
> **注意**: 有効化と移行そのものは **成功通知を表示しません**。移行は保存時にバックエンドが付随的に実行するもので、UI 上ではスイッチがオン状態になったのが見えるだけです。ですので「移行成功のダイアログが見えなかった」のは正常であり、失敗を意味しません。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 無効化したとき何が起きるか: ステップ別解説
|
||||||
|
|
||||||
|
### Step 1: スイッチをオンからオフに切り替える → バックアップを探索 → 確認ダイアログが表示される
|
||||||
|
|
||||||
|
無効化のとき、CC Switch はまず **一瞬かけて移行バックアップの有無を探索** し、それから確認ダイアログを表示します(そのため無効化のダイアログは少しだけ遅延しますが、これは正常です)。文言は次のとおりです(逐語)。
|
||||||
|
|
||||||
|
- **タイトル**: セッション履歴の統一をオフにする
|
||||||
|
- **本文**:
|
||||||
|
|
||||||
|
> オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。
|
||||||
|
|
||||||
|
- **チェックボックス**(条件付き表示): オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)
|
||||||
|
- **確認ボタン**: オフにする
|
||||||
|
- **キャンセルボタン**: キャンセル
|
||||||
|
|
||||||
|
> **ポイント**: 本文が言っているのは「公式サブスクリプションからは **見えなくなる**」——**見えなくなる** であり、**削除される** ではありません。オン期間中に新たに話したセッションは、引き続き `custom` の引き出しに完全な形で残っており、オフにした後で公式側から見えなくなるだけです。
|
||||||
|
|
||||||
|
**この復元チェックボックスはデフォルトでオンです。** つまりデフォルトの動作は「オフにすると同時に、移行した公式セッションを公式履歴へ正確に復元する」です。チェックを保持したまま「オフにする」を押すだけで構いません。
|
||||||
|
|
||||||
|
チェックボックスが **表示されない** 場合は、復元が必要なバックアップがないとシステムが判断したことを意味します(移行に一度もチェックを入れていない、またはバックアップを探索できない)——この場合、あなたの公式の古いセッションは一度も変更されていないので、スイッチをオフにすれば自然と `openai` の引き出しに戻ります。
|
||||||
|
|
||||||
|
### Step 2: 復元はどう実行されるか(バックアップ台帳に従って正確に戻す)
|
||||||
|
|
||||||
|
チェックを保持して「オフにする」を押すと、CC Switch の復元フローは次のようになります。
|
||||||
|
|
||||||
|
```text
|
||||||
|
① まず現在の状態を独立した復元バックアップディレクトリへもう一度コピー
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<時間スタンプ>/
|
||||||
|
(復元自体もまずバックアップするので、復元でもデータは失われない)
|
||||||
|
② すべての移行バックアップ世代を走査し、「当初のラベルが openai」のセッション id を集めて「台帳」を作る
|
||||||
|
③ 【台帳に含まれ、かつ現在もまだ custom】のセッションだけ、ラベルを "openai" に戻す
|
||||||
|
```
|
||||||
|
|
||||||
|
③ のステップの **二重条件** に注意してください——台帳に含まれていること(当初確かに公式から移行されたものだと証明できる)に加えて、現在もまだ `custom` であること(あなたが手動で変更していないことを示す)。両方の条件を満たして初めて戻します。これにより、復元は正確であり、かつ誤って手を加えることもありません。
|
||||||
|
|
||||||
|
**この時点でのあなたのデータ**: 戻された公式セッションはラベルが `openai` に変わり、再び公式リストに表示されます。同時に、移行バックアップと復元バックアップの 2 つのコピーがどちらもディスク上に残っています。
|
||||||
|
|
||||||
|
### Step 3: 通知を見て、結果を確認する
|
||||||
|
|
||||||
|
「オフにする + 復元にチェック」というパスだけが結果通知を表示します。表示され得る通知(逐語)。
|
||||||
|
|
||||||
|
| 表示される通知 | 意味 |
|
||||||
|
|---|---|
|
||||||
|
| **バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)** | 復元成功。`{{files}}` / `{{rows}}` の部分には実際の数字が表示される |
|
||||||
|
| **現在の Codex ディレクトリに復元可能な移行バックアップはありません** | 復元できる内容がない(**データが消えたわけではない**。対照表シナリオ E を参照) |
|
||||||
|
| **統一セッション履歴が再度有効化されたため、復元をスキップしました** | 復元のキュー待ち中にスイッチを再びオンにしたため、システムが復元を自発的に取りやめた(対照表シナリオ F を参照) |
|
||||||
|
| **公式セッション履歴の復元に失敗しました。もう一度お試しください** | 復元の途中でエラー。もう一度試せばよく、データは破壊されていない |
|
||||||
|
| **保存に失敗しました。もう一度お試しください** | オフにするステップの保存そのものが失敗。この場合 **復元は決して起動されず**、スイッチは元の位置に戻る |
|
||||||
|
|
||||||
|
> **気の利いた安全設計**: 「スイッチをオフにする」ステップの保存が失敗した場合、CC Switch は **復元を決して実行しません**。さもないと「スイッチはまだオン、しかしセッションは `openai` バケットに戻された」という矛盾状態が生じてしまいます。保存失敗時、スイッチは **自動で元の位置に戻る** ので、「オフに見えるのに実は保存されていない」という偽の状態に取り残されることはありません。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 「会話が消えた?」症状対照表
|
||||||
|
|
||||||
|
以下の 6 つのシナリオは、ユーザーが最も「セッションが消えた」と誤解しやすいケースです。**どれも真相は: データは無傷で、引き出しが変わったか一時的に見えないだけ。** まずこの表で症状から原因を特定し、その後で下の詳細説明を読んでください。
|
||||||
|
|
||||||
|
| シナリオ | あなたが見るもの | データの真相 | 一言での解決法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** 移行にチェックなし | 公式の古いセッションが統一リストにない | すべて存在、`openai` ラベルのまま | 移行にチェックを入れて再度オンにする、またはスイッチをオフにする |
|
||||||
|
| **B** プロバイダーをまたいだ再開が失敗 | 続けられない / エラー | ファイルは無傷、暗号文がバックエンドをまたいで復号できないだけ | 元のプロバイダーで再開する。内容だけ見るなら jsonl を直接読む |
|
||||||
|
| **C** プロキシ接管 / 注入が拒否 | 移行も復元もされない | 移行が安全にスキップされ、ファイルは未変更 | 接管を終了 → 再起動して再試行。またはスイッチを直接オフにする |
|
||||||
|
| **D** 復元後、新セッションが公式に戻らない | オン期間中の新セッションが公式にない | `custom` の引き出しにある、設計上動かさない | サードパーティプロバイダーに切り替えれば見える |
|
||||||
|
| **E** 「復元可能なバックアップなし」と通知 | 復元が「失敗」 | 通常はそもそも移行していない、セッションは元の引き出しにある | スイッチをオフにすれば公式セッションが自動で再表示 |
|
||||||
|
| **F** 「スイッチが再度有効化、復元スキップ」と通知 | 復元が拒否 | データの矛盾を防止、何も変更していない | まずスイッチを完全にオフにしてから復元する |
|
||||||
|
|
||||||
|
### シナリオ A: スイッチをオンにしたが移行にチェックを入れなかった → 公式の古いセッションが「消えた」
|
||||||
|
|
||||||
|
**現象**: 統一スイッチをオンにしたが、有効化ダイアログの「既存の公式セッション履歴も移行する」にチェックを入れなかった(デフォルトでチェックなし)。オンにした後で見ると、以前の公式の古いセッションがすべてリストにないように見える。
|
||||||
|
|
||||||
|
**真相**: データは 100% すべて存在し、1 行も動いていません。スイッチは「オンにした後に新規作成された」公式セッションにのみ効きます。あなたが **オンにする前** の公式の古いセッションはラベルが `openai` のままで、そっくりそのまま `~/.codex/sessions/` に横たわっています。今あなたがアクティブにしているのは `custom` の引き出しなので、`openai` の引き出しに残った古いセッションが見えないのは当然です——これが「消えたように見える」理由のすべてです。
|
||||||
|
|
||||||
|
**どうするか**(いずれか):
|
||||||
|
1. **スイッチを再度オンにするときに「既存の公式セッション履歴も移行する」にチェックを入れ**、古いセッションを `custom` の引き出しへ移せば、すぐに統一リストに表示されます(書き換え前に自動バックアップ)。
|
||||||
|
2. **または単に統一スイッチをオフにする** と、公式は再び `openai` の引き出しで動作し、古いセッションがその場で再表示されます。
|
||||||
|
|
||||||
|
### シナリオ B: プロバイダーをまたいで古いセッションを再開して失敗 → 「このセッションが壊れた / 消えた」と思う
|
||||||
|
|
||||||
|
**現象**: 統一した後、リストに「別のプロバイダー」で話した古いセッションが見える。今のプロバイダーに切り替えて「再開」を押すと、エラーになったり繋がらなかったりする。
|
||||||
|
|
||||||
|
**真相**: セッションファイルは完全に無傷で、失われたのはデータではなく「バックエンドをまたいだ復号能力」です。Codex セッションには暗号化された推論内容 `encrypted_content` が保存されており、**この暗号文は、それを生成したバックエンドだけが復号できます**。B プロバイダーで A プロバイダーが生成したセッションを再開しようとすると、B は A の暗号文を解けない → 再開失敗。これは **上流の Codex の設計上の制約(by design)** であり、CC Switch がファイルに手を加えたかどうかとは無関係です。セッション内の文字内容はいつでも読めます。
|
||||||
|
|
||||||
|
> これは本記事全体で **唯一「本当に問題が起きたように見える」実在の例外** です——ただし注意してください: これは **再開できない(新しいターンを生成できない)** だけであり、**元ファイルは依然として完全に存在し**、会話の文字はいつでも読めます。
|
||||||
|
|
||||||
|
**どうするか**:
|
||||||
|
- **「このセッションを最初に作成したプロバイダー」で再開すれば**、正常に復号でき、繋がります。
|
||||||
|
- 履歴の内容だけ見たくて、続ける必要がない場合は、そのセッションの `.jsonl` ファイルを直接読んでください(巻末にコマンドあり)。
|
||||||
|
- 経験則: **プロバイダーをまたぐ場合は「新規セッションを始める」のが向いており、古いセッションはできるだけ元のプロバイダーで再開してください。**
|
||||||
|
|
||||||
|
### シナリオ C: スイッチをオンにし移行にもチェックを入れたが、移行が静かにスキップされた → 「移行がセッションをなくした」と思う
|
||||||
|
|
||||||
|
**現象**: オンにして移行にチェックを入れたのに、公式の古いセッションは統一リストに入らず、スイッチをオフにして復元しようとしても「復元できるものがない」と通知される(または無効化ダイアログに復元チェックボックスがそもそも現れない。シナリオ E を参照)。あなたは、移行の過程でセッションをなくしたのではと疑います。
|
||||||
|
|
||||||
|
**真相**: 移行はそもそも **実行されていない** ので、なくすことも不可能です——あなたのセッションは 1 文字も変更されていません。CC Switch には移行前に安全ゲートがあります: Codex の live 設定(`~/.codex/config.toml`)が、この時点で **本当に** 共有の `custom` 引き出しへルーティングされているかを確認し、本当にルーティングされている場合だけ移行します。以下の 2 つのケースでは「まだ統一されていない」と判定され(内部の理由コード `live_not_unified`)、**移行を自発的にスキップし、あなたのスイッチと移行の意思は保持し、条件が満たされてから移行します**。
|
||||||
|
|
||||||
|
- **プロキシ接管中**: CC Switch のプロキシが live 設定を接管しており、接管中の live には統一ルーティングのマークが付いていません。
|
||||||
|
- **注入が拒否された**: あなたの `config.toml` にすでに手動指定の `model_provider` があるか、形態の異なる `[model_providers.custom]` テーブルが既に存在する(サードパーティのアドレスが付いている可能性がある)。公式トラフィックを誤ってサードパーティバックエンドへルーティングするのを避けるため、CC Switch は注入も移行もしないことを選びます。
|
||||||
|
|
||||||
|
移行のスキップ = どのセッションファイルにも触れない。**移行していない=動かしていない、消えようがない。** これは「安全な先送り」であり、「失敗してデータが消えた」ではありません。
|
||||||
|
|
||||||
|
**どうするか**:
|
||||||
|
- プロキシ接管を終了 → **CC Switch を再起動**: 起動時に自動で移行を再試行します(あなたの移行の意思はずっと保持されています)。
|
||||||
|
- `~/.codex/config.toml` を確認: 手動で書いた競合するルーティングがあれば、競合を整理してからスイッチをオンにします。
|
||||||
|
- どうしても手間をかけたくない場合は、スイッチをオフにすれば、公式セッションは引き続き `openai` の引き出しで正常に表示され、まったく無傷です。
|
||||||
|
|
||||||
|
### シナリオ D: スイッチをオフにして復元したが、「オン期間中に新たに話したセッション」が公式に戻らない → 「新セッションが消えた」と思う
|
||||||
|
|
||||||
|
**現象**: 統一をオンにしている間、公式でさらにいくつかの新セッションを話した。後でスイッチをオフにし、復元にチェックを入れた。復元が終わると、その数本の新セッションが公式の引き出しに戻っていない。
|
||||||
|
|
||||||
|
**真相**: これは **意図的な** 設計で、新セッションはちゃんと `custom` の引き出しにあり、見えるし続けられます。復元の拠り所は「移行時のバックアップ台帳」です——**当初 `openai` の引き出しから移行されてきたセッションだけ** がバックアップに記録されており、正確に `openai` へ戻されます。あなたが **オン期間中に新規作成した** セッションはどのバックアップ台帳にもありません。しかも統一後は公式もサードパーティも `custom` ラベルを使うので、**CC Switch はこの新セッションが公式で話したものかサードパーティで話したものか判別できません**。サードパーティのセッションを公式履歴に誤って押し込まないため、プロダクトの決定として、これらの新セッションは一律に `custom`(サードパーティ)の履歴に残し、決して自動で動かしません。無効化ダイアログの文言もこれを明示しています——「オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残ります」。
|
||||||
|
|
||||||
|
**どうするか**:
|
||||||
|
- 任意のサードパーティプロバイダー(`custom` の引き出し)に切り替えれば、履歴リストでこれらのセッションが見えます。
|
||||||
|
- 内容を見たいなら `.jsonl` を直接読み、再開したいならシナリオ B のルール(それを生成した元のバックエンドに戻る)に従ってください。
|
||||||
|
- もし **ある 1 本** を手動で公式に戻したい場合: 現在は自動ボタンはありません(方向を誤判定するのを避けるため、あえて作っていません)。上級ユーザーは、そのファイルを **先にバックアップ** したうえで、`.jsonl` の 1 行目 `session_meta` 内の `model_provider` を `custom` から `openai` に手動で戻せます(上級操作です。変更前に必ずコピーを取ってください)。
|
||||||
|
|
||||||
|
### シナリオ E: 復元時に「現在の Codex ディレクトリに復元可能な移行バックアップはありません」と通知 → 「復元失敗 = データが消えた」と思う
|
||||||
|
|
||||||
|
**現象**: スイッチをオフにするときに復元にチェックを入れたら、「現在の Codex ディレクトリに復元可能な移行バックアップはありません」と通知が出た。あなたは慌てます: 復元すら失敗した、データは完全に消えたのでは?
|
||||||
|
|
||||||
|
**真相**: 「復元できるものがない」≠「データが消えた」。むしろ逆で、通常は **そもそも復元すべき移行が存在しない** からです。よくある原因:
|
||||||
|
|
||||||
|
- **当初「既存の公式セッションを移行する」にチェックを入れていない**: 移行していない以上、移行バックアップもなく、戻すべきセッションもありません。あなたの公式の古いセッションはずっと `openai` の引き出しにあり、スイッチをオフにすれば直接再表示されます(シナリオ A と同じ)。(この場合、無効化ダイアログは復元チェックボックスを **そもそも表示しない** こともあります——システムがバックアップを一切探索できないためです。)
|
||||||
|
- **すでに一度復元済み**: セッションラベルはすべて `openai` に戻っており、もう一度押しても「まだ `custom` の対象がない」のは当然です——これは **冪等保護であり、失敗ではありません**。
|
||||||
|
- **Codex ディレクトリを切り替えた**: 復元は **現在の** ディレクトリに属するバックアップ台帳しか認識しないので、ディレクトリを変えると旧ディレクトリの台帳が見つかりません。ディレクトリを戻せば解決します。
|
||||||
|
|
||||||
|
この 3 つのケースでは、どのセッションも削除されていません。
|
||||||
|
|
||||||
|
**どうするか**: 巻末のコマンドで `~/.codex/sessions/` 内のセッションファイル総数を数え、ファイルがすべて残っていることを確認してください。次に `~/.cc-switch/backups/` に `codex-official-history-unify-v1` ディレクトリがあるかを見てください——もしこのディレクトリすらなければ、あなたは一度も移行を起動しておらず、セッションはずっと元の引き出しにある、ということです。
|
||||||
|
|
||||||
|
### シナリオ F: 復元が拒否され、「統一セッション履歴が再度有効化されたため、復元をスキップしました」と通知
|
||||||
|
|
||||||
|
**現象**: スイッチをオフにする → 復元にチェック → 手が速くて、すぐにスイッチを再びオンにした。すると「統一セッション履歴が再度有効化されたため、復元をスキップしました」と通知が出た。
|
||||||
|
|
||||||
|
**真相**: これはデータを「矛盾」状態にしてしまうのを防ぐ防護であり、セッションは同じく消えていません。復元の動作は「セッションラベルを `custom` から `openai` へ戻す」ことですが、この時点でスイッチが再びオンになっていると、live 設定は `custom` へルーティングしています——一方で履歴を `openai` へ戻し、一方で新セッションを `custom` に落とせば、セッションが人為的に 2 つに引き裂かれてしまいます。そのため CC Switch は「スイッチが再びオンになった」のを検知すると、**この復元を自発的に取りやめ、何も変更しません**。セッションは現状を維持し、削除も破壊もありません。
|
||||||
|
|
||||||
|
**どうするか**: 本当に復元したいなら、**まずスイッチを安定してオフにし**(すぐにオンにし直さない)、それから「オフにする + 復元にチェック」を実行してください。統一を保ちたいなら、復元せず、セッションを `custom` の共有引き出しに残して通常どおり使ってください。
|
||||||
|
|
||||||
|
**大原則: CC Switch の統一 / 移行 / 復元は、全工程でセッションの 1 つのラベルフィールドだけを変更し、しかも毎回書き換える前に自動でバックアップします。あなたの会話を削除することはありません。見えない ≠ 消えた——別の引き出しを見るか、下のコマンドで自分の目で確かめてください。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 自分の目で確認: セッションファイルはディスク上に残っている(最重要セクション)
|
||||||
|
|
||||||
|
文字をいくら重ねるより、自分の目で見るのが一番です。以下に **実際のパス**(CC Switch のソースコードから取得)と、異なる OS でセッションファイル・バックアップディレクトリを見る方法を示します。**全工程は読み取りのみで変更なし。ぜひ一度ご自身で試してみてください。**
|
||||||
|
|
||||||
|
### 最も簡単な方法: ファイルマネージャーで直接開く(コマンドライン完全不要)
|
||||||
|
|
||||||
|
- **macOS(Finder)**: `Cmd + Shift + G` を押して `~/.codex/sessions` を貼り付けて Enter すれば、たくさんの `.jsonl` セッションファイルとその更新時刻が見えます。バックアップディレクトリは `~/.cc-switch/backups` を貼り付けます。
|
||||||
|
- **Windows(エクスプローラー)**: アドレスバーに `%USERPROFILE%\.codex\sessions` を貼り付けて Enter すれば、セッションフォルダとその中の `.jsonl` が見えます。バックアップディレクトリは `%USERPROFILE%\.cc-switch\backups` を貼り付けます。
|
||||||
|
|
||||||
|
**ここで一連の `.jsonl` ファイルが見えれば、それがセッションデータが無傷でディスク上にある証拠です。** ファイル数や更新時刻は、どんな文章よりも直感的です。
|
||||||
|
|
||||||
|
### あなたのセッション / 履歴ファイルはどこにあるのか
|
||||||
|
|
||||||
|
| 内容 | 実際のパス | 説明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **セッション本文(コア)** | `~/.codex/sessions/`(日付別サブディレクトリを含む、再帰的) | セッション 1 つにつき 1 つの `.jsonl` テキストファイル。**これがあなたの会話内容** |
|
||||||
|
| **アーカイブ済みセッション** | `~/.codex/archived_sessions/` | 同じく `.jsonl` |
|
||||||
|
| **セッションインデックス DB** | `~/.codex/state_5.sqlite` | `threads` テーブルの `model_provider` 列が「引き出しラベル」。**これこそ、セッション再開リストが実際に読み取る分類のソース** |
|
||||||
|
| **移行バックアップ**(移行をオンにすると自動生成) | `~/.cc-switch/backups/codex-official-history-unify-v1/<時間スタンプ>/` | `jsonl/`、`state/`、`meta.json` を含む |
|
||||||
|
| **復元バックアップ**(復元を押すと自動生成) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<時間スタンプ>/` | 復元前の安全なコピー |
|
||||||
|
|
||||||
|
> **注意**: CC Switch で Codex ディレクトリを変更した場合や、`config.toml` で `sqlite_home` を設定している場合は、上記の `~/.codex` をあなたの実際のディレクトリに置き換えてください。以下の `~` = あなたのユーザーホームディレクトリ。
|
||||||
|
|
||||||
|
### macOS / Linux コマンド
|
||||||
|
|
||||||
|
**1. セッションファイル総数を数える(これこそ「消えていない」確固たる証拠)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# セッションファイルの総数を数える —— この数が想定どおりなら、データはすべて残っている
|
||||||
|
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 最近更新されたセッションファイル上位 10 件を見る
|
||||||
|
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||||
|
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. (補助)各「引き出し」にそれぞれ何個のセッションがあるか見る**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 公式の引き出し(openai)のセッションファイル数
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 統一の引き出し(custom)のセッションファイル数
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 各ラベルの分布をひと目で確認
|
||||||
|
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重要なヒント、このステップに驚かないでください**: **初期バージョンの Codex は `.jsonl` に `model_provider` フィールドを書き込みません**。これらの古い公式セッションは上記の grep では **数えられません** が、インデックス DB `state_5.sqlite` では依然として `openai` に分類されており、セッション再開リストではちゃんと見えます。ですので **「セッションが消えていない」かの判断はステップ 1 のファイル総数を基準にしてください**——バケット別 grep は分類を理解する補助に過ぎず、数えた結果がファイル総数より少ないのは **まったく正常** であり、決して「ひとまとまり消えた」ことを意味しません。
|
||||||
|
|
||||||
|
**3. (応用)インデックス DB `state_5.sqlite` を見る——セッション再開リストが実際に読む分類**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# sqlite3 がインストール済みであること;未インストールならスキップ可
|
||||||
|
sqlite3 ~/.codex/state_5.sqlite \
|
||||||
|
"SELECT COALESCE(model_provider,'<空>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
> この `threads` テーブルこそ、Codex のセッション再開リストが実際に読み取る分類のソースであり、`openai` の行数 ≈ あなたの公式の引き出しで見えるセッション数です。ステップ 2 の jsonl grep とは数が合わないことがあります——その理由は、上述の「古いセッションは jsonl フィールドを書き込まないが、インデックス DB では依然として openai」だからです。両者が合わないのは異常ではありません。
|
||||||
|
|
||||||
|
**4. あるセッションの内容を直接読む(会話の文字が残っていることを確認)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# <ファイル名> を、上の ls で表示された .jsonl のパスに置き換える
|
||||||
|
python3 -m json.tool < "<ファイル名>.jsonl" 2>/dev/null | head -50
|
||||||
|
|
||||||
|
# またはエディタで直接開いて見る(プレーンテキスト)
|
||||||
|
open -e "<ファイル名>.jsonl" # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. CC Switch のバックアップディレクトリを見る(移行 / 復元の前に必ずコピーを残した証拠)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows コマンド(PowerShell)
|
||||||
|
|
||||||
|
セッションディレクトリは通常 `C:\Users\<あなたのユーザー名>\.codex\` にあり、バックアップは `C:\Users\<あなたのユーザー名>\.cc-switch\backups\` にあります。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. セッションファイルの総数(「消えていない」ことの動かぬ証拠)
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||||
|
|
||||||
|
# 2. 最近更新されたセッション上位 10 件
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||||
|
|
||||||
|
# 3. (補助)公式(openai) / 統一(custom) の引き出しにそれぞれ何件のセッションファイルがあるか
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||||
|
|
||||||
|
# 4. バックアップディレクトリを見る
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||||
|
```
|
||||||
|
|
||||||
|
> 同じく注意: ステップ 3 の grep の数がファイル総数より **少なくなる** のは正常です(古いセッションはこのフィールドを書き込まないため)。「セッションが消えていない」の判断は、ステップ 1 の **ファイル総数** を基準にしてください。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 応用原理付録(仕組みを本当に理解したい人向け)
|
||||||
|
|
||||||
|
### 1. バケット分け機構(引き出しの本質)
|
||||||
|
|
||||||
|
Codex のセッション再開 / 履歴リストは、現在アクティブな `model_provider` id で **厳密な文字列フィルタリング** を行います。セッションファイル `.jsonl` の **1 行目** は `type:"session_meta"` のレコードで、その `payload.model_provider` がそのセッションの属する引き出しです(`grep -rl` はファイル内にそのラベルが 1 回でも出現すればそのファイルをカウントするので、行ごとに解析する必要はありません。旧バージョンでこのフィールドを書き込んでいないセッションは数えられません)。セッション再開リストを実際に駆動するのはインデックス DB `state_5.sqlite` の `threads.model_provider` 列です。公式サブスクリプションは `config.toml` に明示的な `model_provider` がないとき、内蔵のデフォルト id `openai` に入ります。CC Switch のすべてのサードパーティプロバイダーは一律に `custom` を使います。
|
||||||
|
|
||||||
|
### 2. スイッチがすること(注入、live にのみ存在)
|
||||||
|
|
||||||
|
オンにすると、CC Switch は公式 live `config.toml` に次の内容を注入します。
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model_provider = "custom"
|
||||||
|
|
||||||
|
[model_providers.custom]
|
||||||
|
name = "OpenAI"
|
||||||
|
requires_openai_auth = true
|
||||||
|
supports_websockets = true
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
各フィールドには役割があります。`requires_openai_auth = true` は認証を引き続き `auth.json` 内の ChatGPT ログインで行わせ、base_url 未指定時は公式 Codex バックエンドへフォールバックさせます。`name = "OpenAI"` は Codex の公式機能ゲート(web search、リモート圧縮など)を引き続きヒットさせます。`supports_websockets = true` は custom エントリでデフォルトに失われる能力を補います。`wire_api = "responses"` は公式の responses プロトコルを使います。**正味の効果は: 認証は変わらず、バケット名が変わるだけ。**
|
||||||
|
|
||||||
|
**重要な不変条件: この注入は live `config.toml` にのみ存在でき、決してデータベースの保存設定には書き込まれません。** 公式プロバイダーから切り替えて離れ、live をデータベースへ書き戻すとき、CC Switch はこの注入を正確に剥離します(形態が注入物と完全に一致するときだけ剥離し、サードパーティがカスタムした `custom` テーブルはそのまま保持します)。だからこそ「スイッチをオフにする + 一度切り替える」だけで live を完全に復元でき、データベースには常にあなた本来のクリーンな公式設定が保たれます——これがスイッチ全体の可逆性の礎です。
|
||||||
|
|
||||||
|
### 3. 注入の 2 つの拒否ゲート(シナリオ C に対応)
|
||||||
|
|
||||||
|
- `config.toml` に明示的な `model_provider` がすでにある → ユーザーのルーティングを上書きしない。
|
||||||
|
- 形態の異なる `[model_providers.custom]` テーブルがすでに存在する(サードパーティの `base_url` が付いている可能性がある)→ 注入を拒否、さもないと ChatGPT OAuth トラフィックを誤ったバックエンドへルーティングしてしまう。
|
||||||
|
|
||||||
|
注入を拒否したとき live は統一されず、移行ゲート(live の `model_provider` が trim 後に `custom` と等しいかを確認)が `live_not_unified` と判定 → 移行をスキップし、意思を保持し、次回起動の再試行時に行います。これは「安全な先送り」であり、「失敗してデータが消えた」ではありません。
|
||||||
|
|
||||||
|
### 4. セッションの三分類(移行 / 復元の境界を決める)
|
||||||
|
|
||||||
|
- **A 類**: オン時に移行した既存の公式セッション——バックアップが台帳であり、正確に `openai` へ復元可能。
|
||||||
|
- **B 類**: オン期間中に新規作成——どのバックアップにもなく、公式 / サードパーティを判別不能、**決して自動で動かさない**(`custom` に残す)。
|
||||||
|
- **C 類**: オン前の純粋なサードパーティ履歴——絶対に触れない。
|
||||||
|
|
||||||
|
### 5. 移行 / 復元の安全性(データが本当に削除されることはない、その保証はどこから来るか)
|
||||||
|
|
||||||
|
4 層の設計が共同で保証します: **正常・異常のあらゆるパス** において、元のセッションデータが本当に削除されることはありません。
|
||||||
|
|
||||||
|
- **フィールドだけ変更、本文には触れない**: 移行 / 復元はセッションメタデータ内の `model_provider` の値を `openai` と `custom` の間で切り替えるだけで、会話内容、`response_item`、`encrypted_content` はすべてそのまま保持します。
|
||||||
|
- **書き換え前に必ずコピーをバックアップ**: jsonl はファイルコピー、state DB は SQLite の完全なコピーで、タイムスタンプ付きの世代ディレクトリに保存します。移行バックアップは `codex-official-history-unify-v1/` に、復元バックアップは独立した `codex-official-history-unify-restore-v1/` にあり、台帳を純粋に保つため両者は分けられています。
|
||||||
|
- **移すだけ削除しない + 原子書き込み**: すべての jsonl 書き換えは「一時ファイル + 全体置換」を経由し、state DB はトランザクション化された `UPDATE` を経由し、全工程でセッションやインデックスを削除する操作は一切ありません。ファイルはいかなる時点でも完全です。
|
||||||
|
- **悲観的スキップ + 冪等で再試行可能**: バケットが不一致のとき(`live_not_unified`)は移行しないことを選びます。一つのプロセスロックが移行と復元を直列化し、「起動時の再試行 / 保存後のバックグラウンドタスク / 無効化時の復元」が同じ一群のファイルを並行して双方向に書き換えるのを防ぎます。完了マークは Codex ディレクトリに紐づけて条件付きで書き込み、移行漏れを防ぎます。復元は「台帳にある + 現在もまだ custom」の二重条件を使い、誤変更を防ぎます。復元スキャンはすべてのバックアップ世代の和集合を取り、何度もスイッチを切り替えた後でも初期に移行したセッションを復元できます。重複した復元は `nothing_to_restore` を返しますが、これは冪等保護であり失敗ではありません。
|
||||||
|
|
||||||
|
### 6. バックエンドをまたいだ encrypted_content(シナリオ B に対応)
|
||||||
|
|
||||||
|
セッション内の推論暗号文は、それを生成したバックエンドだけが復号でき、上流の Codex は by design でバックエンドをまたいだ復号をサポートしません。これが「再開失敗」の根本原因であり、ファイルの完全性とは無関係です——セッション `.jsonl` は完全にディスク上に横たわり、`encrypted_content` も無傷です。元のプロバイダーに戻して再開するか、新規セッションを始めれば、どちらも正常です。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考リンク
|
||||||
|
|
||||||
|
- [サードパーティ API 利用時に Codex のリモート操作と公式プラグインを保持する: CC Switch 設定ガイド](./codex-official-auth-preservation-guide-ja.md)
|
||||||
|
- [Codex で DeepSeek などの Chat 形式 API を使う: CC Switch ローカルルーティングガイド](./codex-deepseek-routing-guide-ja.md)
|
||||||
|
- CC Switch ユーザーマニュアル内の「Codex アプリ拡張」関連の章
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**最後に一言**: あなたが見た「セッションが消えた / 再開失敗」は、本質的には **セッションが別の履歴リスト(引き出し)に移されたか、相手のバックエンドが古い推論内容を復号できない** ことであり、ファイルは常にそっくりそのまま `~/.codex/sessions/`(および `state_5.sqlite`)に横たわっています。スイッチをオフにするとき「バックアップから復元する」にチェックを入れれば、移行した公式セッションを正確に公式リストへ戻せます。たとえ復元しなくても、元の `.jsonl` ファイルと `~/.cc-switch/backups/codex-official-history-unify-*/` 配下のバックアップコピーはどちらも残っています——**データが本当に失われることは決してありません。**
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# 统一 Codex 会话历史:功能介绍与使用攻略(CC Switch)
|
||||||
|
|
||||||
|
> 适用版本:CC Switch v3.16.x 及以上。本文根据当前代码整理,命令与路径均可亲手验证;示例使用去敏数据,不包含真实会话内容或 API Key。
|
||||||
|
|
||||||
|
## 这个功能是什么
|
||||||
|
|
||||||
|
「统一 Codex 会话历史」是 CC Switch v3.16.x 为 Codex 新增的一个开关。它的位置在 **设置 → 通用 → 「Codex 应用增强」分组**里("Codex 应用增强"是这个分组的标题,开关本身叫"统一 Codex 会话历史")。开启后,**官方订阅(ChatGPT 登录 / OpenAI API Key)的会话,会和 CC Switch 管理的所有第三方供应商会话,出现在同一个历史 / 续聊列表里**——不再被分隔在两个互相看不见的列表中。
|
||||||
|
|
||||||
|
## 它解决什么问题
|
||||||
|
|
||||||
|
Codex 自己按"供应商标签"(一个叫 `model_provider` 的字段)给会话分类,而且**续聊 / 历史列表只显示和你当前激活的供应商同标签的会话**。于是会话天然被分进两个"抽屉":
|
||||||
|
|
||||||
|
- 官方订阅的会话,归在 Codex 内建的 **`openai`** 标签下;
|
||||||
|
- CC Switch 管理的所有第三方供应商,归在 **`custom`** 标签下。
|
||||||
|
|
||||||
|
两个抽屉互相看不见。如果你**经常在官方与第三方之间切换**,就会遇到这种割裂:"刚才用官方聊的会话,切到第三方后在历史列表里找不到了"——它其实没丢,只是被分到了另一个抽屉。这种割裂既容易让人误以为会话丢失,也不方便把所有会话放在一处统一回顾、续聊。
|
||||||
|
|
||||||
|
**这个开关就是为了消除这种割裂**:让官方订阅也以 `custom` 标签运行,于是官方与第三方会话合并进同一个列表,找起来、续起来都在一处。
|
||||||
|
|
||||||
|
> ✅ **一个贯穿全文的重要前提,请先记住**:这个功能(统一 / 迁移 / 还原)**全程只改写会话记录里那一个归类标签 `model_provider`,而且每次改写前都会自动把原文件备份一份**。它不会删除、清空或覆盖你的任何一句对话。所以本文后面若提到"某些会话看不到了",几乎都是"被分到了另一个抽屉",而不是"数据没了"——真担心时,直接看 [症状对照表](#我感觉会话丢了症状对照表) 与 [亲手验证文件还在](#亲手验证你的会话文件还在硬盘上最重要的一节)。
|
||||||
|
|
||||||
|
## 工作原理(一句话版)
|
||||||
|
|
||||||
|
把它想成 **两个抽屉 + 自动备份**:
|
||||||
|
|
||||||
|
- 默认时,官方会话在 `openai` 抽屉、第三方会话在 `custom` 抽屉,互不可见;
|
||||||
|
- 开关让**官方也改用 `custom` 抽屉**,于是两个抽屉合并成一个共享列表;
|
||||||
|
- 你可以选择把**现有的官方老会话**也一并"搬"进共享抽屉(这一步叫**迁移**,可选、需主动勾选),而**任何搬动前都会先复制一份备份**,所以整个过程**可逆**;
|
||||||
|
- **认证完全不受影响**——官方订阅照常用你的 ChatGPT 登录、照常走官方后端,变的只是会话的归类标签。
|
||||||
|
|
||||||
|
完整机制(注入了什么、为什么可逆、迁移/还原如何保证不丢数据)见下文 [核心心智模型](#核心心智模型两个抽屉--自动备份) 与文末 [进阶原理附录](#进阶原理附录给想真正搞懂机制的用户)。
|
||||||
|
|
||||||
|
## 如何使用(速览)
|
||||||
|
|
||||||
|
1. **开启**:设置 → 通用 → Codex 应用增强 → 打开「统一 Codex 会话历史」→ 在弹窗里决定是否勾选"同时迁入现有官方会话历史"(想让**以前**的官方会话也并进统一列表,就勾上;只想从现在起统一,就不勾)→ 确认。详见 [开启时会发生什么](#开启时会发生什么分步说明)。
|
||||||
|
2. **关闭**:关掉同一开关 → 弹窗里保持勾选"按备份精确还原"(默认就勾着)→ 确认,即可把当初迁入的官方会话精确翻回官方列表。详见 [关闭时会发生什么](#关闭时会发生什么分步说明)。
|
||||||
|
3. **感觉会话丢了?** 别慌,跳到 [症状对照表](#我感觉会话丢了症状对照表) 按症状定位,并用 [亲手验证](#亲手验证你的会话文件还在硬盘上最重要的一节) 一节的命令亲眼确认文件都在。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 核心心智模型:两个抽屉 + 自动备份
|
||||||
|
|
||||||
|
要理解这个功能,你只需要记住两件事:**抽屉**和**备份**。
|
||||||
|
|
||||||
|
### 抽屉:Codex 怎么给会话分类
|
||||||
|
|
||||||
|
你每开一个 Codex 会话,Codex 会在会话文件头部记一个标签 `model_provider`,标记"这条会话是用哪个供应商聊的"。Codex 的**续聊 / 历史列表是按当前激活的这个标签精确过滤的**——只显示和"你现在这个供应商"同标签的会话。
|
||||||
|
|
||||||
|
- 官方订阅(ChatGPT 登录 / OpenAI API Key)的会话,标签是内建的 **`openai`**。
|
||||||
|
- CC Switch 管理的所有第三方供应商,统一用标签 **`custom`**。
|
||||||
|
|
||||||
|
所以默认情况下,官方会话和第三方会话天生互相看不见——它们在两个不同的抽屉里。这是 **Codex 自身的设计**,不是 CC Switch 弄丢了什么。
|
||||||
|
|
||||||
|
```text
|
||||||
|
默认状态(没开统一开关):
|
||||||
|
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ openai 抽屉 │ │ custom 抽屉 │
|
||||||
|
│ (官方订阅会话) │ │ (第三方供应商会话)│
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
用官方时只看到这边 用第三方时只看到这边
|
||||||
|
(两个抽屉互相看不见)
|
||||||
|
```
|
||||||
|
|
||||||
|
**「统一 Codex 会话历史」开关做的事,就是让官方订阅也以 `custom` 标签运行,把两个抽屉合并成一个**,于是官方会话和第三方会话出现在同一个续聊列表里。注意:**认证没变**——你的官方订阅照常用你的 ChatGPT 登录、照常走官方后端,只是会话的"归类标签"从 `openai` 变成了 `custom`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
开启统一开关后:
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ custom 共享抽屉 │
|
||||||
|
│ 官方订阅会话 + 第三方供应商会话 │
|
||||||
|
│ (出现在同一个历史 / 续聊列表里) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 备份:每次改标签前都先复制一份
|
||||||
|
|
||||||
|
"合并抽屉"需要把一部分官方会话的标签从 `openai` 改成 `custom`(这一步叫**迁移**,且是**可选的、需要你主动勾选**)。而**任何一次改写之前,CC Switch 都会先把原文件原封不动地复制一份**到这里:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/
|
||||||
|
```
|
||||||
|
|
||||||
|
这份备份,就是日后"按备份精确还原"的唯一依据。它让整个过程变得**可逆**:你随时可以关掉开关,把当初迁进来的官方会话精确地翻回 `openai` 抽屉。
|
||||||
|
|
||||||
|
记住这两个词——**抽屉**(会话只是换了归类)、**备份**(改前必先复制)——后面所有内容你都能轻松理解。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开启时会发生什么:分步说明
|
||||||
|
|
||||||
|
### 第 1 步:找到开关
|
||||||
|
|
||||||
|
```text
|
||||||
|
设置 → 通用 → Codex 应用增强
|
||||||
|
```
|
||||||
|
|
||||||
|
在"Codex 应用增强"这个区块里有两行开关,**第二行**(蓝色历史图标)就是本攻略的主角:
|
||||||
|
|
||||||
|
> **统一 Codex 会话历史**
|
||||||
|
|
||||||
|
它下方有一段说明文字(逐字):
|
||||||
|
|
||||||
|
> 开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败
|
||||||
|
|
||||||
|
> **注意**:这一句说明里已经预告了三件事——会出现在同一列表、可选迁入并自动备份、跨供应商续聊"可能继续失败"。这里的"继续失败"指的是**续不上、生成不了新回合**,不是"记录丢失"。这正是后面要重点拆解的核心误解。
|
||||||
|
|
||||||
|
### 第 2 步:把开关从关拨到开 → 弹出确认窗
|
||||||
|
|
||||||
|
一旦你把开关拨到开,CC Switch **不会立刻保存**,而是先弹出一个确认窗口。窗口文案如下(逐字):
|
||||||
|
|
||||||
|
- **标题**:统一 Codex 会话历史
|
||||||
|
- **正文**:
|
||||||
|
|
||||||
|
> 开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。
|
||||||
|
>
|
||||||
|
> 可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。
|
||||||
|
|
||||||
|
- **复选框**:同时迁入现有官方会话历史
|
||||||
|
- **确认按钮**:我已了解,继续开启
|
||||||
|
- **取消按钮**:取消
|
||||||
|
|
||||||
|
**这个复选框默认是不勾选的。** 这是一个重要的分岔点:
|
||||||
|
|
||||||
|
| 你的选择 | 效果 | 此刻你的数据在哪 |
|
||||||
|
|---|---|---|
|
||||||
|
| **不勾**(默认) | 只切换标识。**只有开启之后新建的官方会话**才会落进 `custom` 共享抽屉 | 你**开启前**的官方老会话,标签仍是 `openai`,原地未动,仍在 `~/.codex/sessions/` |
|
||||||
|
| **勾上** | 除了切换标识,还会把**现有的官方老会话**也从 `openai` 抽屉迁进 `custom` 抽屉 | 老会话被**复制备份**后,标签改写为 `custom`;原始数据有备份兜底 |
|
||||||
|
|
||||||
|
> **如果你希望"以前的官方会话也出现在统一列表里",必须主动勾选这个复选框。** 否则你会遇到下面对照表里的"场景 A"——老会话看起来"不见了",其实只是留在原抽屉里。
|
||||||
|
|
||||||
|
点"取消"或点窗口外面:开关直接弹回关闭状态,什么都没发生。
|
||||||
|
点"我已了解,继续开启":开关保存为开启,CC Switch 在后台落盘配置(如果勾了迁移,就执行迁移)。
|
||||||
|
|
||||||
|
### 第 3 步(仅当勾了迁移):迁移如何执行 + 数据安全
|
||||||
|
|
||||||
|
如果你勾了"同时迁入现有官方会话历史",CC Switch 会对你的官方老会话做这套流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
对每个官方(openai 标签)会话文件:
|
||||||
|
① 先把原文件原样复制一份到备份目录 ← 数据有了第一道保险
|
||||||
|
② 用「写临时文件 → 整体替换」的原子方式,
|
||||||
|
只把头部那行 session_meta 里的 model_provider
|
||||||
|
从 "openai" 改成 "custom" ← 对话正文一个字节都不动
|
||||||
|
③ 索引数据库 state_5.sqlite 同步在一个事务里把标签改过来
|
||||||
|
```
|
||||||
|
|
||||||
|
- **备份位置**:`~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/`,每次迁移生成一个带时间戳的"代际目录",内含 `jsonl/`(会话副本)、`state/`(索引库副本)、`meta.json`(记录这次迁移属于哪个 Codex 目录)。
|
||||||
|
- **改的是什么**:只有 `model_provider` 这一个字段值。你的对话内容、推理内容、所有正文**原样保留**。
|
||||||
|
- **删的是什么**:**什么都没删**。备份是"复制",改写是"原子替换同一个文件",全程没有任何删除会话或索引的动作。文件在任何时刻都是完整的(要么是旧内容、要么是新内容,绝不会是空或半截)。
|
||||||
|
|
||||||
|
迁移成功后,这些官方老会话就出现在统一列表里了。**此刻你的数据**:① 原始副本在备份目录;② 活动文件里只有归类标签变了,内容完好。
|
||||||
|
|
||||||
|
> **注意**:开启与迁移本身**不会弹成功提示**。迁移是后端在保存时顺带跑的,UI 上你只会看到开关变成了打开状态。所以"没看到迁移成功的弹窗"是正常的,不代表失败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关闭时会发生什么:分步说明
|
||||||
|
|
||||||
|
### 第 1 步:把开关从开拨到关 → 探测备份 → 弹出确认窗
|
||||||
|
|
||||||
|
关闭时,CC Switch 会**先花一瞬间探测有没有迁移备份**,然后弹出确认窗口(所以关闭弹窗会有一点点延迟,属正常)。文案如下(逐字):
|
||||||
|
|
||||||
|
- **标题**:关闭统一会话历史
|
||||||
|
- **正文**:
|
||||||
|
|
||||||
|
> 关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。
|
||||||
|
|
||||||
|
- **复选框**(条件显示):把开启时迁入的官方会话还原回官方历史(按备份精确还原)
|
||||||
|
- **确认按钮**:关闭
|
||||||
|
- **取消按钮**:取消
|
||||||
|
|
||||||
|
> **划重点**:正文说的是"官方订阅**将看不到它们**"——是**看不到**,不是**删除**。开启期间你新聊的会话仍然完整地在 `custom` 抽屉里,只是关闭后官方那一侧看不到而已。
|
||||||
|
|
||||||
|
**这个还原复选框默认是勾选的。** 也就是说,默认行为就是"关闭的同时,把当初迁入的官方会话精确还原回官方历史"。你只要保持勾选、点"关闭"即可。
|
||||||
|
|
||||||
|
如果复选框**没有出现**,说明系统判断当前没有需要还原的备份(要么你从没勾过迁移、要么探测不到备份)——这种情况下你的官方老会话从没被改动过,关掉开关它们自己就回到 `openai` 抽屉了。
|
||||||
|
|
||||||
|
### 第 2 步:还原如何执行(按备份账本精确翻回)
|
||||||
|
|
||||||
|
如果你保持勾选并点"关闭",CC Switch 的还原流程是这样的:
|
||||||
|
|
||||||
|
```text
|
||||||
|
① 先把当前现场再复制一份到独立的还原备份目录
|
||||||
|
~/.cc-switch/backups/codex-official-history-unify-restore-v1/<时间戳>/
|
||||||
|
(还原本身也先备份,所以还原也不会丢数据)
|
||||||
|
② 翻遍所有迁移备份代际,找出"当初标签是 openai"的会话 id,组成一份"账本"
|
||||||
|
③ 只对【既在账本里、当前又仍是 custom】的会话,把标签改回 "openai"
|
||||||
|
```
|
||||||
|
|
||||||
|
注意第 ③ 步的**双重条件**——既要在账本里(证明它当初确实是官方迁来的),又要当前仍是 `custom`(说明你没手动改过它)。两个条件都满足才翻回。这保证了还原既精确又不会误伤。
|
||||||
|
|
||||||
|
**此刻你的数据**:被迁回的官方会话标签改回 `openai`,重新出现在官方列表;同时迁移备份和还原备份两份副本都还在硬盘上。
|
||||||
|
|
||||||
|
### 第 3 步:看提示,确认结果
|
||||||
|
|
||||||
|
只有"关闭 + 勾选还原"这条路径会弹结果提示。可能看到的提示(逐字):
|
||||||
|
|
||||||
|
| 你看到的提示 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| **已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)** | 还原成功。`{{files}}` / `{{rows}}` 处会显示实际数字 |
|
||||||
|
| **当前 Codex 目录没有可恢复的迁移备份** | 没有可还原的内容(**不等于数据丢了**,详见对照表场景 E) |
|
||||||
|
| **统一会话历史开关已重新开启,已跳过还原** | 还原排队期间你又把开关打开了,系统主动放弃还原(详见对照表场景 F) |
|
||||||
|
| **还原官方会话历史失败,请重试** | 还原过程报错,重试即可,数据未被破坏 |
|
||||||
|
| **保存失败,请重试** | 关闭这一步保存本身就失败了;此时**绝不会触发还原**,开关弹回原位 |
|
||||||
|
|
||||||
|
> **一个贴心的安全设计**:如果"关闭开关"这一步保存失败,CC Switch **绝不会去执行还原**。否则就会出现"开关还开着、会话却被翻回 openai 桶"的撕裂状态。保存失败时开关会**自动弹回原来的位置**,你不会停留在一个"看起来已关、实则没保存"的假状态里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## "我感觉会话丢了?"症状对照表
|
||||||
|
|
||||||
|
下面六个场景,是用户最容易误以为"会话丢了"的情形。**每一个的真相都是:数据完好,只是换了抽屉或暂时看不到。** 先用这张表按症状定位,再看下面的详细说明。
|
||||||
|
|
||||||
|
| 场景 | 你看到的 | 数据真相 | 一句话解法 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **A** 没勾迁移 | 官方老会话不在统一列表 | 全在,仍带 `openai` 标签 | 重开并勾迁移,或关开关 |
|
||||||
|
| **B** 跨供应商续聊失败 | 续不上 / 报错 | 文件完好,只是密文跨后端解不开 | 回原供应商续;只看内容直接读 jsonl |
|
||||||
|
| **C** 代理接管 / 注入被拒 | 没迁也没还原 | 迁移被安全跳过,文件没动 | 退出接管 → 重启重试;或直接关开关 |
|
||||||
|
| **D** 还原后新会话没回官方 | 开启期间新会话不在官方 | 在 `custom` 抽屉,设计上不动 | 切第三方供应商即可见 |
|
||||||
|
| **E** 提示"没有可恢复备份" | 还原"失败" | 通常压根没迁移过,会话在原抽屉 | 关开关官方会话自动复现 |
|
||||||
|
| **F** 提示"开关已重新开启,跳过还原" | 还原被拒 | 防数据撕裂,啥也没改 | 先彻底关开关再还原 |
|
||||||
|
|
||||||
|
### 场景 A:开了开关但没勾迁移 → 官方老会话"不见了"
|
||||||
|
|
||||||
|
**现象**:你开了统一开关,但开启弹窗里那个"同时迁入现有官方会话历史"没勾(它默认就不勾)。开启后一看,以前的官方老会话好像都不在列表里了。
|
||||||
|
|
||||||
|
**真相**:数据 100% 都在,一行都没动。开关只对"开启之后新建"的官方会话生效,你**开启前**的官方老会话标签仍是 `openai`,原封不动地躺在 `~/.codex/sessions/` 里。你现在激活的是 `custom` 抽屉,自然看不到留在 `openai` 抽屉里的老会话——这就是"看起来消失"的全部原因。
|
||||||
|
|
||||||
|
**怎么办**(任选其一):
|
||||||
|
1. **重新开启开关时勾上"同时迁入现有官方会话历史"**,把老会话换到 `custom` 抽屉,它们立刻出现在统一列表(改写前自动备份)。
|
||||||
|
2. **或者干脆关掉统一开关**,官方重新以 `openai` 抽屉运行,老会话原地复现。
|
||||||
|
|
||||||
|
### 场景 B:跨供应商续聊旧会话失败 → 以为"这条会话坏了 / 没了"
|
||||||
|
|
||||||
|
**现象**:统一之后列表里能看到一条用"另一家供应商"聊出来的旧会话,你切到现在的供应商点"继续",结果报错或接不上。
|
||||||
|
|
||||||
|
**真相**:会话文件完好无损,丢的不是数据,是"跨后端解密能力"。Codex 会话里保存了一段加密的推理内容 `encrypted_content`,**这段密文只有当初生成它的那个后端能解密**。你用 B 供应商去续 A 供应商生成的会话,B 解不开 A 的密文 → 续聊失败。这是**上游 Codex 的设计限制(by design)**,与 CC Switch 是否动过文件无关。会话里的文字内容你随时能读到。
|
||||||
|
|
||||||
|
> 这是整篇攻略里**唯一一个"看起来真出了问题"的真实例外**——但请注意:它只是**无法续聊(生成不了新回合)**,**原始文件依然完整存在**,对话文字随时可读。
|
||||||
|
|
||||||
|
**怎么办**:
|
||||||
|
- **用"当初创建这条会话的那个供应商"去续聊**,就能正常解密、接上。
|
||||||
|
- 只想看历史内容、不必继续?直接读那条会话的 `.jsonl` 文件(文末有命令)。
|
||||||
|
- 经验法则:**跨供应商更适合"开新会话",老会话尽量回原供应商续。**
|
||||||
|
|
||||||
|
### 场景 C:开了开关也勾了迁移,但迁移被静默跳过 → 以为"迁移把会话弄丢了"
|
||||||
|
|
||||||
|
**现象**:你开启并勾了迁移,但官方老会话既没进统一列表、关开关想还原也提示没东西可还原(或者关闭弹窗里压根没出现还原复选框,参见场景 E)。你怀疑迁移过程中把会话搞丢了。
|
||||||
|
|
||||||
|
**真相**:迁移根本**没执行**,所以也不可能弄丢——你的会话一个字都没被改。CC Switch 在迁移前有一道安全闸门:它会检查 Codex 的 live 配置(`~/.codex/config.toml`)此刻是否**真的**路由到了共享 `custom` 抽屉,只有真路由过去了才迁移。以下两种情况会判定"还没统一"(内部原因码 `live_not_unified`),于是**主动跳过迁移、保留你的开关和迁移意愿、等条件满足后再迁**:
|
||||||
|
|
||||||
|
- **代理接管期间**:CC Switch 的代理接管了 live 配置,接管期的 live 不带统一路由标记。
|
||||||
|
- **注入被拒**:你的 `config.toml` 已有手工指定的 `model_provider`,或已存在一张形态不同的 `[model_providers.custom]` 表(可能带第三方地址)。为避免把官方流量错误路由到第三方后端,CC Switch 宁可不注入、不迁移。
|
||||||
|
|
||||||
|
跳过迁移 = 不碰任何会话文件。**没迁,等于没动,谈不上丢。** 这是"安全延后",不是"失败丢数据"。
|
||||||
|
|
||||||
|
**怎么办**:
|
||||||
|
- 退出代理接管 → **重启 CC Switch**:启动时会自动重试迁移(你的迁移意愿一直保留着)。
|
||||||
|
- 检查 `~/.codex/config.toml`:若有你手工写的冲突路由,整理掉冲突后再开开关。
|
||||||
|
- 实在不想折腾:直接关开关,官方会话仍以 `openai` 抽屉正常显示,毫发无损。
|
||||||
|
|
||||||
|
### 场景 D:关了开关并还原,但"开启期间新聊的会话"没回官方 → 以为"新会话丢了"
|
||||||
|
|
||||||
|
**现象**:你开启统一期间,用官方又聊了几条新会话。后来关开关、勾了还原,还原完发现那几条新会话没回到官方抽屉。
|
||||||
|
|
||||||
|
**真相**:这是**有意为之**的设计,新会话好端端在 `custom` 抽屉里,能看见、能续。还原的依据是"迁移时的备份账本"——**只有当初从 `openai` 抽屉迁进来的会话**,备份里有据可查,才会被精确翻回 `openai`。你**开启期间新建**的会话不在任何备份账本里;而且统一之后官方和第三方都用 `custom` 标签,**CC Switch 无法分辨这条新会话到底是官方聊的还是第三方聊的**。为了不把第三方会话误塞进官方历史,产品决策是:这些新会话一律留在 `custom`(第三方)历史里,绝不自动搬动。关闭弹窗的文案也明示了这一点——"开启期间产生的会话因无法区分来源,将留在第三方历史中"。
|
||||||
|
|
||||||
|
**怎么办**:
|
||||||
|
- 切到任意一个第三方供应商(`custom` 抽屉),就能在历史列表里看到这些会话。
|
||||||
|
- 想看内容直接读 `.jsonl`;想续聊遵循场景 B 的规则(回到当初生成它的后端)。
|
||||||
|
- 如果你确实想把**某一条**手动归回官方:目前没有自动按钮(刻意不做,避免误判方向)。进阶用户可在**先备份**该文件后,手动把它 `.jsonl` 第一行 `session_meta` 里的 `model_provider` 从 `custom` 改回 `openai`(属高阶操作,改前务必复制一份)。
|
||||||
|
|
||||||
|
### 场景 E:还原提示"当前 Codex 目录没有可恢复的迁移备份" → 以为"还原失败 = 数据没了"
|
||||||
|
|
||||||
|
**现象**:关开关时勾了还原,结果弹出提示"当前 Codex 目录没有可恢复的迁移备份"。你慌了:还原都失败了,是不是数据彻底没了?
|
||||||
|
|
||||||
|
**真相**:"没有可还原的东西"≠"数据丢了"。恰恰相反,通常是因为**根本没有需要还原的迁移**。常见原因:
|
||||||
|
|
||||||
|
- **你当初没勾过"迁入现有官方会话"**:既然没迁移,自然没有迁移备份、也没有需要翻回去的会话。你的官方老会话一直在 `openai` 抽屉,关开关后直接复现(同场景 A)。(这种情况下,关闭弹窗甚至可能**根本不显示还原复选框**——因为系统探测不到任何备份。)
|
||||||
|
- **已经还原过一遍了**:会话标签已全部翻回 `openai`,再点一次自然"没有仍是 custom 的目标可还原"——这是**幂等保护,不是失败**。
|
||||||
|
- **切换过 Codex 目录**:还原只认属于**当前**目录的备份账本,换了目录就找不到旧目录的账本,把目录切回去即可。
|
||||||
|
|
||||||
|
这三种情况下,没有任何会话被删除。
|
||||||
|
|
||||||
|
**怎么办**:用文末命令统计 `~/.codex/sessions/` 里的会话文件总数,确认文件都在;再看 `~/.cc-switch/backups/` 里有没有 `codex-official-history-unify-v1` 目录——如果连这个目录都没有,说明你从没触发过迁移,会话一直在原抽屉。
|
||||||
|
|
||||||
|
### 场景 F:还原被拒,提示"统一会话历史开关已重新开启,已跳过还原"
|
||||||
|
|
||||||
|
**现象**:关开关 → 勾还原 → 你手很快,紧接着又把开关重新打开了,然后看到提示"统一会话历史开关已重新开启,已跳过还原"。
|
||||||
|
|
||||||
|
**真相**:这是一道防护,防止把数据弄成"撕裂"状态,会话同样没丢。还原的动作是"把会话标签从 `custom` 翻回 `openai`",但如果此刻开关又开着,live 配置正路由到 `custom`——一边把历史翻回 `openai`、一边新会话往 `custom` 落,会话会被人为撕成两半。所以 CC Switch 检测到"开关又开了",**主动放弃这次还原、什么都不改**。会话维持现状,没有任何删除或破坏。
|
||||||
|
|
||||||
|
**怎么办**:想真正还原,就**先把开关稳定地关掉**(别再立刻打开),再执行关闭 + 勾还原;想保持统一,就别还原,让会话留在 `custom` 共享抽屉正常使用。
|
||||||
|
|
||||||
|
**总原则:CC Switch 的统一 / 迁移 / 还原全程只改会话的一个标签字段,并且每次改写前都自动备份。它不会删你的对话。看不见 ≠ 丢了——换个抽屉看,或用下面的命令亲眼确认。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 亲手验证:你的会话文件还在硬盘上(最重要的一节)
|
||||||
|
|
||||||
|
文字再多,不如亲眼看见。下面给出**真实路径**(取自 CC Switch 源码)和在不同系统下查看会话文件、备份目录的方法。**全程只读不改,强烈建议你亲手试一遍。**
|
||||||
|
|
||||||
|
### 最简单的方式:用文件管理器直接打开(完全不用命令行)
|
||||||
|
|
||||||
|
- **macOS(Finder)**:按 `Cmd + Shift + G`,粘贴 `~/.codex/sessions` 回车,就能看到一堆 `.jsonl` 会话文件和它们的修改时间;备份目录粘贴 `~/.cc-switch/backups`。
|
||||||
|
- **Windows(文件资源管理器)**:在地址栏粘贴 `%USERPROFILE%\.codex\sessions` 回车,就能看到会话文件夹和里面的 `.jsonl`;备份目录粘贴 `%USERPROFILE%\.cc-switch\backups`。
|
||||||
|
|
||||||
|
**只要你能在这里看到一批 `.jsonl` 文件,就证明会话数据完好无损地在硬盘上。** 文件数量、修改时间,比任何文字都直观。
|
||||||
|
|
||||||
|
### 你的会话 / 历史文件到底在哪
|
||||||
|
|
||||||
|
| 内容 | 真实路径 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| **会话正文(核心)** | `~/.codex/sessions/`(含按日期分的子目录,递归) | 每个会话一个 `.jsonl` 文本文件,**这就是你的对话内容** |
|
||||||
|
| **归档会话** | `~/.codex/archived_sessions/` | 同为 `.jsonl` |
|
||||||
|
| **会话索引数据库** | `~/.codex/state_5.sqlite` | `threads` 表的 `model_provider` 列就是"抽屉标签",**它才是续聊列表真正读取的归类来源** |
|
||||||
|
| **迁移备份**(开启迁移时自动产生) | `~/.cc-switch/backups/codex-official-history-unify-v1/<时间戳>/` | 内含 `jsonl/`、`state/`、`meta.json` |
|
||||||
|
| **还原备份**(点还原时自动产生) | `~/.cc-switch/backups/codex-official-history-unify-restore-v1/<时间戳>/` | 还原前的安全副本 |
|
||||||
|
|
||||||
|
> **注意**:如果你在 CC Switch 里改过 Codex 目录,或在 `config.toml` 里设了 `sqlite_home`,请把上面的 `~/.codex` 换成你的实际目录。下文 `~` = 你的用户主目录。
|
||||||
|
|
||||||
|
### macOS / Linux 命令
|
||||||
|
|
||||||
|
**1. 数会话文件总数(这才是"没丢"的硬证据)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 统计会话文件总数 —— 只要这个数字符合你的预期,数据就都在
|
||||||
|
find ~/.codex/sessions ~/.codex/archived_sessions -name '*.jsonl' 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 看最近修改的 10 个会话文件
|
||||||
|
find ~/.codex/sessions -name '*.jsonl' 2>/dev/null -print0 \
|
||||||
|
| xargs -0 ls -lt 2>/dev/null | head -10
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. (辅助)看每个"抽屉"各有多少会话**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 官方抽屉(openai)会话文件数
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"openai"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 统一抽屉(custom)会话文件数
|
||||||
|
grep -rlE '"model_provider"[[:space:]]*:[[:space:]]*"custom"' ~/.codex/sessions 2>/dev/null | wc -l
|
||||||
|
|
||||||
|
# 看各标签分布一目了然
|
||||||
|
grep -rhoE '"model_provider"[[:space:]]*:[[:space:]]*"[^"]*"' ~/.codex/sessions 2>/dev/null | sort | uniq -c
|
||||||
|
```
|
||||||
|
|
||||||
|
> **重要提示,别被这一步吓到**:**早期版本的 Codex 不在 `.jsonl` 里写 `model_provider` 字段**,这些旧官方会话用上面的 grep 是**数不到**的,但它们在索引库 `state_5.sqlite` 里仍然归类为 `openai`、续聊列表照样能看到。所以**判断"会话没丢"请以第 1 步的文件总数为准**——分桶 grep 只是帮你理解归类,数出来比文件总数少**完全正常**,绝不代表"丢了一批"。
|
||||||
|
|
||||||
|
**3. (进阶)查索引库 `state_5.sqlite`——续聊列表真正读的归类**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 需要已安装 sqlite3;没装可跳过
|
||||||
|
sqlite3 ~/.codex/state_5.sqlite \
|
||||||
|
"SELECT COALESCE(model_provider,'<空>'), COUNT(*) FROM threads GROUP BY 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这张 `threads` 表才是 Codex 续聊列表真正读取的归类来源,`openai` 行数 ≈ 你官方抽屉里能看到的会话数。它和第 2 步的 jsonl grep 可能对不上数——原因就是上面说的"旧会话不写 jsonl 字段,但索引库里仍是 openai"。两边对不上不是异常。
|
||||||
|
|
||||||
|
**4. 直接读某条会话的内容(确认对话文字还在)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 把 <文件名> 换成上面 ls 列出的某个 .jsonl 路径
|
||||||
|
python3 -m json.tool < "<文件名>.jsonl" 2>/dev/null | head -50
|
||||||
|
|
||||||
|
# 或者直接用编辑器打开看(纯文本)
|
||||||
|
open -e "<文件名>.jsonl" # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. 看 CC Switch 的备份目录(证明迁移 / 还原前都留了副本)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-v1/ 2>/dev/null
|
||||||
|
ls -la ~/.cc-switch/backups/codex-official-history-unify-restore-v1/ 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 命令(PowerShell)
|
||||||
|
|
||||||
|
会话目录通常在 `C:\Users\<你的用户名>\.codex\`,备份在 `C:\Users\<你的用户名>\.cc-switch\backups\`。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 会话文件总数("没丢"的硬证据)
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions","$env:USERPROFILE\.codex\archived_sessions" -Recurse -Filter *.jsonl -ErrorAction SilentlyContinue).Count
|
||||||
|
|
||||||
|
# 2. 最近修改的 10 个会话
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Sort-Object LastWriteTime -Descending | Select-Object -First 10 FullName,LastWriteTime
|
||||||
|
|
||||||
|
# 3. (辅助)官方(openai) / 统一(custom) 抽屉各多少会话文件
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"openai"' -List).Count
|
||||||
|
(Get-ChildItem "$env:USERPROFILE\.codex\sessions" -Recurse -Filter *.jsonl |
|
||||||
|
Select-String -Pattern 'model_provider"\s*:\s*"custom"' -List).Count
|
||||||
|
|
||||||
|
# 4. 看备份目录
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-v1" -ErrorAction SilentlyContinue
|
||||||
|
Get-ChildItem "$env:USERPROFILE\.cc-switch\backups\codex-official-history-unify-restore-v1" -ErrorAction SilentlyContinue
|
||||||
|
```
|
||||||
|
|
||||||
|
> 同样提醒:第 3 步的 grep 数会**少于**文件总数属正常(旧会话不写该字段),请以第 1 步的**文件总数**作为"会话没丢"的判断依据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进阶原理附录(给想真正搞懂机制的用户)
|
||||||
|
|
||||||
|
### 1. 分桶机制(抽屉的本质)
|
||||||
|
|
||||||
|
Codex 的续聊 / 历史列表按当前激活的 `model_provider` id **精确字符串过滤**。会话文件 `.jsonl` 的**第一行**是一条 `type:"session_meta"` 记录,其 `payload.model_provider` 即该会话所属抽屉(`grep -rl` 只要文件里出现一次该标签就计入该文件,因此无需逐行解析;旧版本未写该字段的会话则数不到)。真正驱动续聊列表的是索引库 `state_5.sqlite` 的 `threads.model_provider` 列。官方订阅在 `config.toml` 没有显式 `model_provider` 时落进内建默认 id `openai`;CC Switch 的所有第三方供应商统一用 `custom`。
|
||||||
|
|
||||||
|
### 2. 开关做的事(注入,只活在 live)
|
||||||
|
|
||||||
|
开启后,CC Switch 对官方 live `config.toml` 注入如下内容:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
model_provider = "custom"
|
||||||
|
|
||||||
|
[model_providers.custom]
|
||||||
|
name = "OpenAI"
|
||||||
|
requires_openai_auth = true
|
||||||
|
supports_websockets = true
|
||||||
|
wire_api = "responses"
|
||||||
|
```
|
||||||
|
|
||||||
|
每个字段都有作用:`requires_openai_auth = true` 让认证继续走 `auth.json` 里的 ChatGPT 登录、base_url 缺省回落官方 Codex 后端;`name = "OpenAI"` 让 Codex 的官方特性门控(web search、远程压缩等)继续命中;`supports_websockets = true` 补回 custom 条目默认丢失的能力;`wire_api = "responses"` 用官方 responses 协议。**净效果是:认证没变,只是桶名变了。**
|
||||||
|
|
||||||
|
**关键不变量:这段注入只能存在于 live `config.toml`,绝不写进数据库的存储配置。** 切换离开官方供应商、把 live 回写数据库时,CC Switch 会把这段注入精确剥离(只在形态与注入产物完全一致时才剥,第三方自定义的 `custom` 表原样保留)。正因如此,"关掉开关 + 切换一次"就能彻底还原 live,数据库里始终是你原本干净的官方配置——这是整个开关可逆性的基石。
|
||||||
|
|
||||||
|
### 3. 注入的两道拒绝闸(对应场景 C)
|
||||||
|
|
||||||
|
- `config.toml` 已有显式 `model_provider` → 不覆盖用户路由;
|
||||||
|
- 已存在形态不同的 `[model_providers.custom]` 表(可能带第三方 `base_url`)→ 拒绝注入,否则会把 ChatGPT OAuth 流量路由到错误后端。
|
||||||
|
|
||||||
|
拒绝注入时 live 不统一,迁移闸门(检查 live 的 `model_provider` 是否 trim 后等于 `custom`)判定 `live_not_unified` → 跳过迁移、保留意愿、等下次启动重试时再做。这是"安全延后",不是"失败丢数据"。
|
||||||
|
|
||||||
|
### 4. 会话三分类(决定迁移 / 还原边界)
|
||||||
|
|
||||||
|
- **A 类**:开启时迁入的存量官方会话——备份即账本,可精确还原回 `openai`;
|
||||||
|
- **B 类**:开启期间新建——不在任何备份、官方 / 第三方不可分,**永不自动搬动**(留 `custom`);
|
||||||
|
- **C 类**:开启前的纯第三方历史——绝不触碰。
|
||||||
|
|
||||||
|
### 5. 迁移 / 还原的安全性(数据不会被真正删除,保障来自哪里)
|
||||||
|
|
||||||
|
四层设计共同保证:在**正常与异常的所有路径**下,原始会话数据都不会被真正删除。
|
||||||
|
|
||||||
|
- **只改字段,不动正文**:迁移 / 还原只把会话元数据里的 `model_provider` 值在 `openai` 与 `custom` 之间切换,对话内容、`response_item`、`encrypted_content` 一律原样保留。
|
||||||
|
- **改写前必先复制备份**:jsonl 用文件复制、state DB 用 SQLite 完整副本,存进时间戳代际目录。迁移备份在 `codex-official-history-unify-v1/`,还原备份在独立的 `codex-official-history-unify-restore-v1/`,两者分开以保持账本纯净。
|
||||||
|
- **只移不删 + 原子写**:所有 jsonl 改写走"临时文件 + 整体替换",state DB 走事务化 `UPDATE`,全程没有任何删除会话或索引的动作。文件在任一时刻都是完整的。
|
||||||
|
- **悲观跳过 + 幂等可重试**:桶不一致时(`live_not_unified`)宁可不迁;一把进程锁串行化迁移与还原,避免"启动重试 / 保存后台任务 / 关闭还原"并发对同批文件双向改写;完成标记按 Codex 目录绑定、条件写入,防漏迁;还原用"在账本 + 当前仍 custom"双重条件,防误改。还原扫描全部备份代际取并集,多次开关循环后仍能还原早期迁入的会话;重复还原返回 `nothing_to_restore`,是幂等保护而非失败。
|
||||||
|
|
||||||
|
### 6. 跨后端 encrypted_content(对应场景 B)
|
||||||
|
|
||||||
|
会话内的推理密文只能被生成它的后端解密,上游 Codex by design 不支持跨后端解密。这是"续聊失败"的根因,与文件完整性无关——会话 `.jsonl` 完整躺在磁盘上、`encrypted_content` 也完好无损。换回原供应商续聊,或开新会话,都正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 参考链接
|
||||||
|
|
||||||
|
- [使用第三方 API 时保留 Codex 远程操作和官方插件:CC Switch 配置攻略](./codex-official-auth-preservation-guide-zh.md)
|
||||||
|
- [在 Codex 中使用 DeepSeek 这类 Chat 格式 API:CC Switch 路由攻略](./codex-deepseek-routing-guide-zh.md)
|
||||||
|
- CC Switch 用户手册中「Codex 应用增强」相关章节
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**给你的最后一句话**:你看到的"会话不见了 / 续聊失败",本质是**会话被换到了另一个历史列表(抽屉)里、或对方后端无法解密旧推理内容**,文件始终原封不动地躺在 `~/.codex/sessions/`(及 `state_5.sqlite`)里。关闭开关时勾选"按备份还原"即可把当初迁入的官方会话精确翻回官方列表;即便不还原,原始 `.jsonl` 文件和 `~/.cc-switch/backups/codex-official-history-unify-*/` 下的备份副本也都在——**数据绝不会真正丢失。**
|
||||||
@@ -105,7 +105,7 @@ Fixed multiple preserve-mode takeover paths that could clear or overwrite offici
|
|||||||
|
|
||||||
Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection.
|
Fixed cases where `modelCatalog` could be cleared during live backfill, active-provider editing, provider switching, and takeover shutdown restore. Snapshot backups preserve existing `model_catalog_json` pointers; backups rebuilt from providers regenerate catalog projections from the database source of truth; editing the active provider now prefers the database model catalog instead of trusting a live reverse-parse result that may have lost its projection.
|
||||||
|
|
||||||
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks [@Postroggy](https://github.com/Postroggy)).
|
Provider switching also now always refreshes the generated Codex model catalog JSON ([#3360](https://github.com/farion1231/cc-switch/pull/3360), thanks @Postroggy).
|
||||||
|
|
||||||
### Codex Chat Tools, Plugins, and Custom Tools Restored
|
### Codex Chat Tools, Plugins, and Custom Tools Restored
|
||||||
|
|
||||||
@@ -117,19 +117,19 @@ When Codex forwarding fails, CC Switch now returns JSON errors that include prov
|
|||||||
|
|
||||||
### Codex Native Balance / Coding Plan Credential Lookup
|
### Codex Native Balance / Coding Plan Credential Lookup
|
||||||
|
|
||||||
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks [@SiskonEmilia](https://github.com/SiskonEmilia)).
|
Fixed native balance and Coding Plan queries using credentials from the wrong app. Each app now resolves its own provider credentials instead of carrying authentication assumptions from another app surface into the query flow ([#3355](https://github.com/farion1231/cc-switch/pull/3355), thanks @SiskonEmilia).
|
||||||
|
|
||||||
### Codex CLI Discovery and Model Catalog Template Fallback
|
### Codex CLI Discovery and Model Catalog Template Fallback
|
||||||
|
|
||||||
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks [@chofuhoyu](https://github.com/chofuhoyu)).
|
Fixed a too-narrow Codex CLI discovery path for third-party Codex model catalog projection. The backend now searches common Codex CLI install locations across platforms, and falls back to a built-in GPT-5.5 model catalog template if no template can be found ([#3382](https://github.com/farion1231/cc-switch/pull/3382), thanks @chofuhoyu).
|
||||||
|
|
||||||
### Claude Desktop Official Provider Add Failure
|
### Claude Desktop Official Provider Add Failure
|
||||||
|
|
||||||
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks [@Eunknight](https://github.com/Eunknight)).
|
Fixed an error when adding the Claude Desktop Official provider ([#3405](https://github.com/farion1231/cc-switch/pull/3405), thanks @Eunknight).
|
||||||
|
|
||||||
### Kimi / Moonshot Tool-Thinking History Normalization
|
### Kimi / Moonshot Tool-Thinking History Normalization
|
||||||
|
|
||||||
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks [@Neon-Wang](https://github.com/Neon-Wang)).
|
Added Kimi / Moonshot to the Anthropic-compatible tool-thinking history normalizer. Later turns can now correctly replay reasoning and tool-call context, avoiding failures caused by history messages that do not match upstream requirements ([#3377](https://github.com/farion1231/cc-switch/pull/3377), thanks @Neon-Wang).
|
||||||
|
|
||||||
### Windows Tool Version Detection
|
### Windows Tool Version Detection
|
||||||
|
|
||||||
@@ -171,11 +171,11 @@ By enabling these features, users accept the related risks. CC Switch is not res
|
|||||||
|
|
||||||
Thanks to the following contributors for fixes in v3.16.1:
|
Thanks to the following contributors for fixes in v3.16.1:
|
||||||
|
|
||||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks [@Postroggy](https://github.com/Postroggy).
|
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): always update Codex model catalog JSON when switching providers, thanks @Postroggy.
|
||||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks [@SiskonEmilia](https://github.com/SiskonEmilia).
|
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): resolve native balance / Coding Plan credentials per app, thanks @SiskonEmilia.
|
||||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks [@Eunknight](https://github.com/Eunknight).
|
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): fix Claude Desktop Official provider add failure, thanks @Eunknight.
|
||||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks [@chofuhoyu](https://github.com/chofuhoyu).
|
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI multi-platform discovery and GPT-5.5 model template fallback, thanks @chofuhoyu.
|
||||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks [@Neon-Wang](https://github.com/Neon-Wang).
|
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot tool-thinking history normalization, thanks @Neon-Wang.
|
||||||
|
|
||||||
Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details.
|
Thanks also to everyone who reported Codex OAuth, model catalog, local routing takeover, and Chat Completions tool-call issues after v3.16.0. Many of these fixes came directly from real-world reproduction details.
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Codex がローカルルーティングのテイクオーバー状態にある
|
|||||||
|
|
||||||
live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。
|
live バックフィル、現在のプロバイダー編集、プロバイダー切り替え、テイクオーバー解除時の復元などで `modelCatalog` が空になる問題を修正しました。スナップショットバックアップは既存の `model_catalog_json` ポインターを保持します。プロバイダーから再構築されるバックアップは、データベースの信頼できる情報源からカタログ投影を再生成します。現在のプロバイダー編集時は、投影を失っている可能性のある live の逆解析結果ではなく、データベース内のモデルカタログを優先します。
|
||||||
|
|
||||||
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、[@Postroggy](https://github.com/Postroggy) に感謝)。
|
また、プロバイダー切り替え時には生成済みの Codex モデルカタログ JSON を常に更新するようになりました([#3360](https://github.com/farion1231/cc-switch/pull/3360)、@Postroggy に感謝)。
|
||||||
|
|
||||||
### Codex Chat ツール、プラグイン、カスタムツールの復元
|
### Codex Chat ツール、プラグイン、カスタムツールの復元
|
||||||
|
|
||||||
@@ -117,19 +117,19 @@ Codex の転送に失敗したとき、provider、model、endpoint、上流 HTTP
|
|||||||
|
|
||||||
### Codex ネイティブ残高 / Coding Plan の認証情報検索
|
### Codex ネイティブ残高 / Coding Plan の認証情報検索
|
||||||
|
|
||||||
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝)。
|
ネイティブ残高と Coding Plan の照会時に、別アプリの認証情報を誤って使う問題を修正しました。各 app は自分自身のプロバイダー認証情報を解析し、別のアプリ面の認証前提を照会フローへ持ち込まなくなりました([#3355](https://github.com/farion1231/cc-switch/pull/3355)、@SiskonEmilia に感謝)。
|
||||||
|
|
||||||
### Codex CLI 探索とモデルカタログテンプレートのフォールバック
|
### Codex CLI 探索とモデルカタログテンプレートのフォールバック
|
||||||
|
|
||||||
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、[@chofuhoyu](https://github.com/chofuhoyu) に感謝)。
|
サードパーティ Codex モデルカタログ投影における Codex CLI の探索パスが狭すぎる問題を修正しました。バックエンドは複数プラットフォームの一般的な Codex CLI インストール場所を探し、それでもテンプレートが見つからない場合は内蔵の GPT-5.5 モデルカタログテンプレートへフォールバックします([#3382](https://github.com/farion1231/cc-switch/pull/3382)、@chofuhoyu に感謝)。
|
||||||
|
|
||||||
### Claude Desktop Official プロバイダー追加失敗
|
### Claude Desktop Official プロバイダー追加失敗
|
||||||
|
|
||||||
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、[@Eunknight](https://github.com/Eunknight) に感謝)。
|
Claude Desktop Official プロバイダー追加時のエラーを修正しました([#3405](https://github.com/farion1231/cc-switch/pull/3405)、@Eunknight に感謝)。
|
||||||
|
|
||||||
### Kimi / Moonshot ツール思考履歴の正規化
|
### Kimi / Moonshot ツール思考履歴の正規化
|
||||||
|
|
||||||
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、[@Neon-Wang](https://github.com/Neon-Wang) に感謝)。
|
Kimi / Moonshot を Anthropic 互換ツール思考履歴 normalizer に追加しました。後続ターンで reasoning と tool-call コンテキストを正しく再生できるようになり、履歴メッセージの形が上流要件に合わず失敗する問題を避けます([#3377](https://github.com/farion1231/cc-switch/pull/3377)、@Neon-Wang に感謝)。
|
||||||
|
|
||||||
### Windows ツールバージョン検出
|
### Windows ツールバージョン検出
|
||||||
|
|
||||||
@@ -171,11 +171,11 @@ Codex は起動時に `model_catalog_json` を読み込みます。v3.16.1 で
|
|||||||
|
|
||||||
v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します:
|
v3.16.1 で修正を届けてくださった以下のコントリビューターに感謝します:
|
||||||
|
|
||||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、[@Postroggy](https://github.com/Postroggy) に感謝。
|
- [#3360](https://github.com/farion1231/cc-switch/pull/3360): Codex プロバイダー切り替え時にモデルカタログ JSON を常に更新、@Postroggy に感謝。
|
||||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、[@SiskonEmilia](https://github.com/SiskonEmilia) に感謝。
|
- [#3355](https://github.com/farion1231/cc-switch/pull/3355): ネイティブ残高 / Coding Plan 照会の認証情報を app ごとに解析、@SiskonEmilia に感謝。
|
||||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、[@Eunknight](https://github.com/Eunknight) に感謝。
|
- [#3405](https://github.com/farion1231/cc-switch/pull/3405): Claude Desktop Official プロバイダー追加エラーを修正、@Eunknight に感謝。
|
||||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、[@chofuhoyu](https://github.com/chofuhoyu) に感謝。
|
- [#3382](https://github.com/farion1231/cc-switch/pull/3382): Codex CLI の複数プラットフォーム探索と GPT-5.5 モデルテンプレートフォールバック、@chofuhoyu に感謝。
|
||||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、[@Neon-Wang](https://github.com/Neon-Wang) に感謝。
|
- [#3377](https://github.com/farion1231/cc-switch/pull/3377): Kimi / Moonshot ツール思考履歴の正規化、@Neon-Wang に感謝。
|
||||||
|
|
||||||
v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
v3.16.0 リリース後に Codex OAuth、モデルカタログ、ローカルルーティングのテイクオーバー、Chat Completions ツール呼び出しの問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ Codex / Claude / Gemini 的供应商切换与本地路由接管开关现在共
|
|||||||
|
|
||||||
修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。
|
修复 `modelCatalog` 在 live 回填、当前供应商编辑弹窗、供应商切换、关闭接管恢复等场景被清空的问题。快照备份会保留已有 `model_catalog_json` 指针;由供应商重建的备份会从数据库真相来源重新生成目录投影;编辑当前供应商时会优先使用数据库里的模型目录,而不是信任可能已经丢失投影的 live 反解结果。
|
||||||
|
|
||||||
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 [@Postroggy](https://github.com/Postroggy))。
|
同时,供应商切换现在会始终刷新生成的 Codex 模型目录 JSON([#3360](https://github.com/farion1231/cc-switch/pull/3360),感谢 @Postroggy)。
|
||||||
|
|
||||||
### Codex Chat 工具、插件和自定义工具恢复
|
### Codex Chat 工具、插件和自定义工具恢复
|
||||||
|
|
||||||
@@ -117,19 +117,19 @@ Codex 转发失败时,现在返回包含 provider、model、endpoint、上游
|
|||||||
|
|
||||||
### Codex 原生余额 / Coding Plan 查询凭据
|
### Codex 原生余额 / Coding Plan 查询凭据
|
||||||
|
|
||||||
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 [@SiskonEmilia](https://github.com/SiskonEmilia))。
|
修复原生余额与 Coding Plan 查询时跨 app 错用凭据的问题。现在每个 app 会解析自己的供应商凭据,不再把其他应用面的认证假设带进查询流程([#3355](https://github.com/farion1231/cc-switch/pull/3355),感谢 @SiskonEmilia)。
|
||||||
|
|
||||||
### Codex CLI 发现与模型目录模板兜底
|
### Codex CLI 发现与模型目录模板兜底
|
||||||
|
|
||||||
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 [@chofuhoyu](https://github.com/chofuhoyu))。
|
修复第三方 Codex 模型目录投影对 Codex CLI 发现路径过窄的问题。现在后端会在多平台常见安装位置寻找 Codex CLI,并在仍找不到模板时使用内置 GPT-5.5 模型目录模板兜底([#3382](https://github.com/farion1231/cc-switch/pull/3382),感谢 @chofuhoyu)。
|
||||||
|
|
||||||
### Claude Desktop 官方供应商添加失败
|
### Claude Desktop 官方供应商添加失败
|
||||||
|
|
||||||
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 [@Eunknight](https://github.com/Eunknight))。
|
修复添加 Claude Desktop 官方供应商时报错的问题([#3405](https://github.com/farion1231/cc-switch/pull/3405),感谢 @Eunknight)。
|
||||||
|
|
||||||
### Kimi / Moonshot 工具思考历史规范化
|
### Kimi / Moonshot 工具思考历史规范化
|
||||||
|
|
||||||
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 [@Neon-Wang](https://github.com/Neon-Wang))。
|
把 Kimi / Moonshot 加入 Anthropic 兼容工具思考历史 normalizer。后续轮次现在能正确重放 reasoning 与 tool-call 上下文,避免因为历史消息形态不符合上游要求而失败([#3377](https://github.com/farion1231/cc-switch/pull/3377),感谢 @Neon-Wang)。
|
||||||
|
|
||||||
### Windows 工具版本探测
|
### Windows 工具版本探测
|
||||||
|
|
||||||
@@ -171,11 +171,11 @@ Codex 在启动时读取 `model_catalog_json`。因此即使 v3.16.1 已修复
|
|||||||
|
|
||||||
感谢以下贡献者在 v3.16.1 中提交修复:
|
感谢以下贡献者在 v3.16.1 中提交修复:
|
||||||
|
|
||||||
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 [@Postroggy](https://github.com/Postroggy)。
|
- [#3360](https://github.com/farion1231/cc-switch/pull/3360):Codex 供应商切换时始终更新模型目录 JSON,感谢 @Postroggy。
|
||||||
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 [@SiskonEmilia](https://github.com/SiskonEmilia)。
|
- [#3355](https://github.com/farion1231/cc-switch/pull/3355):原生余额 / Coding Plan 查询按 app 解析凭据,感谢 @SiskonEmilia。
|
||||||
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 [@Eunknight](https://github.com/Eunknight)。
|
- [#3405](https://github.com/farion1231/cc-switch/pull/3405):修复 Claude Desktop 官方供应商添加报错,感谢 @Eunknight。
|
||||||
- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 [@chofuhoyu](https://github.com/chofuhoyu)。
|
- [#3382](https://github.com/farion1231/cc-switch/pull/3382):Codex CLI 多平台发现与 GPT-5.5 模型模板兜底,感谢 @chofuhoyu。
|
||||||
- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 [@Neon-Wang](https://github.com/Neon-Wang)。
|
- [#3377](https://github.com/farion1231/cc-switch/pull/3377):Kimi / Moonshot 工具思考历史规范化,感谢 @Neon-Wang。
|
||||||
|
|
||||||
也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
|
也感谢所有在 v3.16.0 发布后反馈 Codex OAuth、模型目录、本地路由接管和 Chat Completions 工具调用问题的用户。很多补丁都来自这些真实使用场景里的复现线索。
|
||||||
|
|
||||||
|
|||||||
@@ -261,32 +261,32 @@ By enabling these features, users accept the related risks. CC Switch is not res
|
|||||||
|
|
||||||
Thanks to the following contributors for the features and fixes in v3.16.2:
|
Thanks to the following contributors for the features and fixes in v3.16.2:
|
||||||
|
|
||||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks [@keithyt06](https://github.com/keithyt06).
|
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): add S3-compatible cloud storage sync, thanks @keithyt06.
|
||||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks [@nothingness0db](https://github.com/nothingness0db).
|
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): add OpenCode session usage sync, thanks @nothingness0db.
|
||||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks [@Eter365](https://github.com/Eter365).
|
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): add the ZenMux Token Plan provider, thanks @Eter365.
|
||||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks [@zhibisora](https://github.com/zhibisora).
|
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): add the CherryIN preset provider, thanks @zhibisora.
|
||||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks [@CSberlin](https://github.com/CSberlin).
|
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): add the Codex CLI reachability `GET /v1/models` endpoint, thanks @CSberlin.
|
||||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks [@allenxu09](https://github.com/allenxu09).
|
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): Usage Dashboard hero redesign, thanks @allenxu09.
|
||||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks [@Postroggy](https://github.com/Postroggy).
|
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): drop `tool_choice` when tools is empty, thanks @Postroggy.
|
||||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks [@LanternCX](https://github.com/LanternCX).
|
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): preserve Codex custom tool metadata in chat routing, thanks @LanternCX.
|
||||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks [@yeeyzy](https://github.com/yeeyzy).
|
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): always include `reasoning_tokens` in Chat→Responses, thanks @yeeyzy.
|
||||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks [@YongmaoLuo](https://github.com/YongmaoLuo).
|
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): skip backup / restore when live is already a proxy placeholder, thanks @YongmaoLuo.
|
||||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks [@Alexlangl](https://github.com/Alexlangl).
|
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): normalize the localhost listen address, thanks @Alexlangl.
|
||||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks [@Dearli666](https://github.com/Dearli666).
|
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): normalize Anthropic `system` messages, thanks @Dearli666.
|
||||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks [@lzcndm](https://github.com/lzcndm).
|
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): improve error message display in the proxy panel, thanks @lzcndm.
|
||||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks [@NiuBlibing](https://github.com/NiuBlibing).
|
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): raise the infinite-whitespace threshold 20 → 500, thanks @NiuBlibing.
|
||||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks [@YongmaoLuo](https://github.com/YongmaoLuo).
|
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): route the Zhipu quota query to the configured base URL, thanks @YongmaoLuo.
|
||||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks [@LaoYueHanNi](https://github.com/LaoYueHanNi).
|
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): adapt to the MiniMax new balance API and default pricing, thanks @LaoYueHanNi.
|
||||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks [@makoMakoGo](https://github.com/makoMakoGo).
|
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): fix the Zhipu Coding Plan presets and model probing for versioned endpoints, thanks @makoMakoGo.
|
||||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks [@steponeerror](https://github.com/steponeerror).
|
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): use a relative filename for the model catalog, thanks @steponeerror.
|
||||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks [@iAJue](https://github.com/iAJue).
|
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): fix the Windows tray icon residue after exit, thanks @iAJue.
|
||||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks [@ZhangNanNan1018](https://github.com/ZhangNanNan1018).
|
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): fix the Windows taskbar icon, thanks @ZhangNanNan1018.
|
||||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks [@Ninthless](https://github.com/Ninthless).
|
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): normalize Windows path separators to match subdirectory skill updates, thanks @Ninthless.
|
||||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks [@ZHLHZHU](https://github.com/ZHLHZHU).
|
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): disable macOS input auto-capitalization, thanks @ZHLHZHU.
|
||||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks [@xwil1](https://github.com/xwil1).
|
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): fix Codex VS Code session previews, thanks @xwil1.
|
||||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks [@Games55k](https://github.com/Games55k).
|
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): align the VS Code wording in the Chinese UI, thanks @Games55k.
|
||||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks [@makoMakoGo](https://github.com/makoMakoGo).
|
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): refresh the user manual to reflect current app support, thanks @makoMakoGo.
|
||||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks [@null-easy](https://github.com/null-easy).
|
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): fix README release-note links and sponsor markup, thanks @null-easy.
|
||||||
|
|
||||||
Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details.
|
Thanks also to everyone who reported Codex Chat routing, local proxy takeover, usage statistics, and platform compatibility issues after v3.16.1. Many of these fixes came directly from real-world reproduction details.
|
||||||
|
|
||||||
|
|||||||
@@ -261,32 +261,32 @@ Codex は起動時に `model_catalog_json` を読み込みます。本リリー
|
|||||||
|
|
||||||
v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
v3.16.2 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||||
|
|
||||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、[@keithyt06](https://github.com/keithyt06) に感謝。
|
- [#1351](https://github.com/farion1231/cc-switch/pull/1351): S3 互換クラウドストレージ同期を追加、@keithyt06 に感謝。
|
||||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、[@nothingness0db](https://github.com/nothingness0db) に感謝。
|
- [#3215](https://github.com/farion1231/cc-switch/pull/3215): OpenCode セッション用量同期を追加、@nothingness0db に感謝。
|
||||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、[@Eter365](https://github.com/Eter365) に感謝。
|
- [#2709](https://github.com/farion1231/cc-switch/pull/2709): ZenMux Token Plan プロバイダーを追加、@Eter365 に感謝。
|
||||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、[@zhibisora](https://github.com/zhibisora) に感謝。
|
- [#3643](https://github.com/farion1231/cc-switch/pull/3643): CherryIN プリセットプロバイダーを追加、@zhibisora に感謝。
|
||||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、[@CSberlin](https://github.com/CSberlin) に感謝。
|
- [#3818](https://github.com/farion1231/cc-switch/pull/3818): Codex CLI 到達性確認用の `GET /v1/models` エンドポイントを追加、@CSberlin に感謝。
|
||||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、[@allenxu09](https://github.com/allenxu09) に感謝。
|
- [#3426](https://github.com/farion1231/cc-switch/pull/3426): 用量ダッシュボードのヒーロー再設計、@allenxu09 に感謝。
|
||||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、[@Postroggy](https://github.com/Postroggy) に感謝。
|
- [#3640](https://github.com/farion1231/cc-switch/pull/3640): tools が空のとき `tool_choice` を破棄、@Postroggy に感謝。
|
||||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、[@LanternCX](https://github.com/LanternCX) に感謝。
|
- [#3644](https://github.com/farion1231/cc-switch/pull/3644): Chat ルーティングで Codex カスタムツールメタデータを保持、@LanternCX に感謝。
|
||||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、[@yeeyzy](https://github.com/yeeyzy) に感謝。
|
- [#3514](https://github.com/farion1231/cc-switch/pull/3514): Chat→Responses で常に `reasoning_tokens` を含める、@yeeyzy に感謝。
|
||||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。
|
- [#3689](https://github.com/farion1231/cc-switch/pull/3689): live がすでにプロキシプレースホルダーのときバックアップ / 復元をスキップ、@YongmaoLuo に感謝。
|
||||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、[@Alexlangl](https://github.com/Alexlangl) に感謝。
|
- [#3016](https://github.com/farion1231/cc-switch/pull/3016): localhost リッスンアドレスを正規化、@Alexlangl に感謝。
|
||||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、[@Dearli666](https://github.com/Dearli666) に感謝。
|
- [#3775](https://github.com/farion1231/cc-switch/pull/3775): Anthropic `system` メッセージを正規化、@Dearli666 に感謝。
|
||||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、[@lzcndm](https://github.com/lzcndm) に感謝。
|
- [#3656](https://github.com/farion1231/cc-switch/pull/3656): プロキシパネルのエラー表示を改善、@lzcndm に感謝。
|
||||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、[@NiuBlibing](https://github.com/NiuBlibing) に感謝。
|
- [#2647](https://github.com/farion1231/cc-switch/pull/2647): 無限空白検出のしきい値を 20 → 500 へ引き上げ、@NiuBlibing に感謝。
|
||||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、[@YongmaoLuo](https://github.com/YongmaoLuo) に感謝。
|
- [#3702](https://github.com/farion1231/cc-switch/pull/3702): 智譜の残量照会を構成済み base URL へルーティング、@YongmaoLuo に感謝。
|
||||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、[@LaoYueHanNi](https://github.com/LaoYueHanNi) に感謝。
|
- [#3518](https://github.com/farion1231/cc-switch/pull/3518): MiniMax の新残量 API と既定価格に対応、@LaoYueHanNi に感謝。
|
||||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。
|
- [#3524](https://github.com/farion1231/cc-switch/pull/3524): 智譜 Coding Plan プリセットとバージョン付き endpoint のモデル探索を修正、@makoMakoGo に感謝。
|
||||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、[@steponeerror](https://github.com/steponeerror) に感謝。
|
- [#3614](https://github.com/farion1231/cc-switch/pull/3614): モデルカタログを相対ファイル名に変更、@steponeerror に感謝。
|
||||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、[@iAJue](https://github.com/iAJue) に感謝。
|
- [#3797](https://github.com/farion1231/cc-switch/pull/3797): Windows 終了後のトレイアイコン残留を修正、@iAJue に感謝。
|
||||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、[@ZhangNanNan1018](https://github.com/ZhangNanNan1018) に感謝。
|
- [#3457](https://github.com/farion1231/cc-switch/pull/3457): Windows タスクバーアイコンを修正、@ZhangNanNan1018 に感謝。
|
||||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、[@Ninthless](https://github.com/Ninthless) に感謝。
|
- [#3430](https://github.com/farion1231/cc-switch/pull/3430): Windows のパス区切りを正規化してサブディレクトリスキルの更新に対応、@Ninthless に感謝。
|
||||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、[@ZHLHZHU](https://github.com/ZHLHZHU) に感謝。
|
- [#3626](https://github.com/farion1231/cc-switch/pull/3626): macOS の入力自動大文字化を無効化、@ZHLHZHU に感謝。
|
||||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、[@xwil1](https://github.com/xwil1) に感謝。
|
- [#3593](https://github.com/farion1231/cc-switch/pull/3593): Codex VS Code セッションプレビューを修正、@xwil1 に感謝。
|
||||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、[@Games55k](https://github.com/Games55k) に感謝。
|
- [#3228](https://github.com/farion1231/cc-switch/pull/3228): 中国語 UI の VS Code 表記を揃える、@Games55k に感謝。
|
||||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、[@makoMakoGo](https://github.com/makoMakoGo) に感謝。
|
- [#3411](https://github.com/farion1231/cc-switch/pull/3411): 現行のアプリ対応を反映してユーザーマニュアルを刷新、@makoMakoGo に感謝。
|
||||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、[@null-easy](https://github.com/null-easy) に感謝。
|
- [#3772](https://github.com/farion1231/cc-switch/pull/3772): README のリリースノートリンクとスポンサー表記を修正、@null-easy に感謝。
|
||||||
|
|
||||||
v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
v3.16.1 リリース後に Codex Chat ルーティング、ローカルプロキシのテイクオーバー、用量統計、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||||
|
|
||||||
|
|||||||
@@ -261,32 +261,32 @@ Codex 在启动时读取 `model_catalog_json`。即使本版已把模型目录
|
|||||||
|
|
||||||
感谢以下贡献者在 v3.16.2 中提交的功能与修复:
|
感谢以下贡献者在 v3.16.2 中提交的功能与修复:
|
||||||
|
|
||||||
- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 [@keithyt06](https://github.com/keithyt06)。
|
- [#1351](https://github.com/farion1231/cc-switch/pull/1351):新增 S3 兼容云存储同步,感谢 @keithyt06。
|
||||||
- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 [@nothingness0db](https://github.com/nothingness0db)。
|
- [#3215](https://github.com/farion1231/cc-switch/pull/3215):新增 OpenCode 会话用量同步,感谢 @nothingness0db。
|
||||||
- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 [@Eter365](https://github.com/Eter365)。
|
- [#2709](https://github.com/farion1231/cc-switch/pull/2709):新增 ZenMux Token Plan 供应商,感谢 @Eter365。
|
||||||
- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 [@zhibisora](https://github.com/zhibisora)。
|
- [#3643](https://github.com/farion1231/cc-switch/pull/3643):新增 CherryIN 预设供应商,感谢 @zhibisora。
|
||||||
- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 [@CSberlin](https://github.com/CSberlin)。
|
- [#3818](https://github.com/farion1231/cc-switch/pull/3818):新增 Codex CLI 探活用的 `GET /v1/models` 端点,感谢 @CSberlin。
|
||||||
- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 [@allenxu09](https://github.com/allenxu09)。
|
- [#3426](https://github.com/farion1231/cc-switch/pull/3426):用量看板 Hero 重新设计,感谢 @allenxu09。
|
||||||
- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 [@Postroggy](https://github.com/Postroggy)。
|
- [#3640](https://github.com/farion1231/cc-switch/pull/3640):空 tools 时丢弃 `tool_choice`,感谢 @Postroggy。
|
||||||
- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 [@LanternCX](https://github.com/LanternCX)。
|
- [#3644](https://github.com/farion1231/cc-switch/pull/3644):Chat 路由保留 Codex 自定义工具元数据,感谢 @LanternCX。
|
||||||
- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 [@yeeyzy](https://github.com/yeeyzy)。
|
- [#3514](https://github.com/farion1231/cc-switch/pull/3514):Chat→Responses 始终包含 `reasoning_tokens`,感谢 @yeeyzy。
|
||||||
- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。
|
- [#3689](https://github.com/farion1231/cc-switch/pull/3689):live 已是代理占位符时跳过备份 / 恢复,感谢 @YongmaoLuo。
|
||||||
- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 [@Alexlangl](https://github.com/Alexlangl)。
|
- [#3016](https://github.com/farion1231/cc-switch/pull/3016):归一化 localhost 监听地址,感谢 @Alexlangl。
|
||||||
- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 [@Dearli666](https://github.com/Dearli666)。
|
- [#3775](https://github.com/farion1231/cc-switch/pull/3775):规范化 Anthropic `system` 消息,感谢 @Dearli666。
|
||||||
- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 [@lzcndm](https://github.com/lzcndm)。
|
- [#3656](https://github.com/farion1231/cc-switch/pull/3656):改进代理面板错误信息展示,感谢 @lzcndm。
|
||||||
- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 [@NiuBlibing](https://github.com/NiuBlibing)。
|
- [#2647](https://github.com/farion1231/cc-switch/pull/2647):调高无限空白检测阈值 20 → 500,感谢 @NiuBlibing。
|
||||||
- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 [@YongmaoLuo](https://github.com/YongmaoLuo)。
|
- [#3702](https://github.com/farion1231/cc-switch/pull/3702):智谱配额查询按所配 base URL 路由,感谢 @YongmaoLuo。
|
||||||
- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 [@LaoYueHanNi](https://github.com/LaoYueHanNi)。
|
- [#3518](https://github.com/farion1231/cc-switch/pull/3518):适配 MiniMax 余额查询新接口与默认定价,感谢 @LaoYueHanNi。
|
||||||
- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。
|
- [#3524](https://github.com/farion1231/cc-switch/pull/3524):修复智谱 Coding Plan 预设与带版本号端点的模型探测,感谢 @makoMakoGo。
|
||||||
- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 [@steponeerror](https://github.com/steponeerror)。
|
- [#3614](https://github.com/farion1231/cc-switch/pull/3614):模型目录改用相对文件名,感谢 @steponeerror。
|
||||||
- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 [@iAJue](https://github.com/iAJue)。
|
- [#3797](https://github.com/farion1231/cc-switch/pull/3797):修复 Windows 退出后托盘图标残留,感谢 @iAJue。
|
||||||
- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 [@ZhangNanNan1018](https://github.com/ZhangNanNan1018)。
|
- [#3457](https://github.com/farion1231/cc-switch/pull/3457):修复 Windows 任务栏图标,感谢 @ZhangNanNan1018。
|
||||||
- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 [@Ninthless](https://github.com/Ninthless)。
|
- [#3430](https://github.com/farion1231/cc-switch/pull/3430):归一化 Windows 路径分隔符以匹配子目录技能更新,感谢 @Ninthless。
|
||||||
- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 [@ZHLHZHU](https://github.com/ZHLHZHU)。
|
- [#3626](https://github.com/farion1231/cc-switch/pull/3626):关闭 macOS 输入框自动大写,感谢 @ZHLHZHU。
|
||||||
- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 [@xwil1](https://github.com/xwil1)。
|
- [#3593](https://github.com/farion1231/cc-switch/pull/3593):修复 Codex VS Code 会话预览,感谢 @xwil1。
|
||||||
- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 [@Games55k](https://github.com/Games55k)。
|
- [#3228](https://github.com/farion1231/cc-switch/pull/3228):对齐中文界面 VS Code 文案,感谢 @Games55k。
|
||||||
- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 [@makoMakoGo](https://github.com/makoMakoGo)。
|
- [#3411](https://github.com/farion1231/cc-switch/pull/3411):刷新用户手册以反映当前应用支持,感谢 @makoMakoGo。
|
||||||
- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 [@null-easy](https://github.com/null-easy)。
|
- [#3772](https://github.com/farion1231/cc-switch/pull/3772):修复 README release note 链接与赞助商标记,感谢 @null-easy。
|
||||||
|
|
||||||
也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
也感谢所有在 v3.16.1 发布后反馈 Codex Chat 路由、本地代理接管、用量统计和平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# CC Switch v3.16.3
|
||||||
|
|
||||||
|
> 🎉 **CC Switch has passed 100,000 Stars!**
|
||||||
|
> Thank you to every user, contributor, and Star — you are the reason it has come this far. 🙏
|
||||||
|
|
||||||
|
> 💎 **This release was developed with help from the Claude Fable 5 model** — it helped untangle several critical, error-prone pieces of logic: the attribution chain that bills route-takeover traffic by the real upstream model, the metering and de-duplication of cache tokens on format-conversion paths, the in-app update restart deadlock, and the migration / restore invariants of Codex unified session history. This is also why this release adds a **Fable 5 Verified** badge to the About page.
|
||||||
|
|
||||||
|
> After v3.16.2 broadened data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model, fixing cache double-counting on format-conversion paths, counting Claude Code Workflow sub-agent usage (schema v11), and a round of redesign for the usage dashboard (dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries) — while also hardening a batch of local proxy and platform issues, adding a custom User-Agent override, a Codex unified session history toggle, and a Claude Fable 5 tier.
|
||||||
|
|
||||||
|
**[中文版 →](v3.16.3-zh.md) | [日本語版 →](v3.16.3-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Guides
|
||||||
|
|
||||||
|
This release adds a **Codex unified session history** toggle — it migrates / restores sessions, and if used without care it can make you think sessions were "lost," so it is well worth reading its guide first. This release also changes how usage is counted and reworks the dashboard quite a bit, so both are worth starting with:
|
||||||
|
|
||||||
|
- **[Codex Unified Session History: Feature Overview and Usage Guide](../guides/codex-unified-session-history-guide-en.md)**: what "unify / migrate / restore" actually changes, why your data is never truly lost, and how to verify and precisely restore sessions when you can't see them. **If you used this toggle or worry a session is gone, read this first.**
|
||||||
|
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources (proxy logs, session sync) and how the statistics are counted. This release adds dashboard-wide provider / model filters and surfaces the real pricing model for route-takeover traffic.
|
||||||
|
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: the custom User-Agent override, the Codex unified session history toggle, and other switches live in the provider form's advanced options and on the settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## Only Official Channels (Please Read)
|
||||||
|
>
|
||||||
|
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||||
|
>
|
||||||
|
> | Channel | Only Official |
|
||||||
|
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||||
|
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.16.3 is a maintenance update following v3.16.2. After the previous release concentrated on broadening data portability and usage observability, this release puts the focus on "making usage billing truly accurate" — billing by the real upstream model rather than whatever the upstream echoes back, fixing the cache-token double-counting on format-conversion paths (Chat / Responses / Gemini converted to Anthropic), folding Claude Code Workflow sub-agent usage into the local statistics, and persisting the actual pricing basis used by each record as schema v11. The usage dashboard was reworked along with it, adding dashboard-wide provider / model filters, a brand-icon toolbar, and more resilient quota queries (retry on failure plus keeping the last successful result).
|
||||||
|
|
||||||
|
In addition, this release hardens a batch of local proxy robustness issues (aggregating SSE responses returned under a mislabeled Content-Type, Codex `/responses` image rectification for text-only models, recovery of Codex OAuth credentials and takeover residue, duplicate YAML keys in Hermes config), reworks the provider configuration experience (a custom User-Agent override, a unified Codex advanced section, searchable and sortable presets, a Claude Fable 5 tier), adds a Codex unified session history toggle, and fixes the in-app update hang, the Codex upgrade that broke the install, duplicate macOS terminal windows, and more.
|
||||||
|
|
||||||
|
**Release date**: 2026-06-14
|
||||||
|
|
||||||
|
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **More accurate usage billing**: route-takeover traffic is now billed by the real upstream model (not the alias the upstream echoes back), format-conversion paths no longer count cache tokens into input twice, and Claude Code Workflow sub-agent usage is now counted — with the pricing basis persisted as schema v11.
|
||||||
|
- **Usage dashboard redesign**: provider / model filters are promoted from inside the request-log table up to dashboard-wide filters, the app filter switches to brand icons, and quota queries gain retry-on-failure plus "keep the last successful result" so a single network blip no longer turns cards red.
|
||||||
|
- **Custom User-Agent override**: providers can set a custom UA that applies consistently across forwarding, connectivity detection, and model listing, getting past coding-plan upstreams that gate on a UA whitelist (which is how the Codex "Kimi For Coding" preset was restored).
|
||||||
|
- **Codex unified session history**: a new opt-in toggle lets official Codex sessions share a single resume-history bucket with third-party sessions, with optional migration of existing sessions and precise ledger-based restore.
|
||||||
|
- **Proxy and platform hardening**: aggregating mislabeled SSE responses, Codex image rectification, takeover-residue recovery, Hermes YAML de-duplication; in-app updates no longer hang on "restarting", and Codex upgrades no longer break the install.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
### Custom User-Agent Override
|
||||||
|
|
||||||
|
Provider configs can now set a custom User-Agent that the proxy applies consistently across request forwarding, stream check, and model listing (`GET /v1/models`), so coding-plan upstreams that gate on UA no longer fail detection or return 403 while the proxy itself works. The Claude and Codex forms expose it in advanced settings with a curated presets dropdown (Claude Code / Kilo Code families that pass UA whitelists) and live non-blocking validation; stale custom UAs are dropped when switching to an official preset to avoid silently altering headers (#3671).
|
||||||
|
|
||||||
|
### Unified Codex Session History
|
||||||
|
|
||||||
|
Official Codex sessions can now share a single resume-history bucket with cc-switch third-party sessions via an opt-in toggle under Settings → Codex App Enhancements, so the resume picker no longer hides them from each other. When enabled, the live `config.toml` routes official runs through a shared `custom` model_provider that mirrors the built-in OpenAI provider (`auth.json` is untouched); the toggle is forward-only by default but the enable dialog offers a checkbox to migrate existing official sessions (with per-generation backups), and the disable dialog offers a precise ledger-based restore that only reverts sessions originally recorded as `openai` while leaving sessions created during the toggle untouched.
|
||||||
|
|
||||||
|
### Dashboard-Wide Provider / Model Filters
|
||||||
|
|
||||||
|
The provider and model filters move from inside the request-log table up to the top bar, applying globally to the hero summary, trend chart, request logs, and both stats tabs so you can scope the whole dashboard to a given source and model. Sources match by exact display name (so session placeholder rows like "Claude (Session)" are selectable) and models match by effective pricing model, with the model dropdown cascading from the selected source and both lists showing only options that have data in the current range.
|
||||||
|
|
||||||
|
### Refreshed Model Pricing Seed
|
||||||
|
|
||||||
|
Added pricing for 9 models including Claude Fable 5, Grok 4.3, Mistral Medium 3.5 / Small 4, and Qwen 3.7 Max/Plus, and corrected 28 existing prices against current official vendor list pricing (GLM, Grok, MiMo, Doubao, Kimi, MiniMax, Mistral, Qwen) so usage cost estimates are accurate. Each change updates the seed for fresh installs and adds a guarded repair for existing databases without clobbering user-edited rows.
|
||||||
|
|
||||||
|
### Claude Fable 5 Model Tier
|
||||||
|
|
||||||
|
Provider forms now expose `claude-fable-5` as a fourth model-mapping tier on both the Claude Code and Claude Desktop proxy paths, with a fable → opus → default fallback mirroring the official downgrade and the `fable-` prefix whitelisted for the Desktop 1.12603.1+ validator. A clarified four-language fallback hint warns that leaving a tier blank on third-party endpoints forwards the literal model name and 404s (#3980, #4026, #4049).
|
||||||
|
|
||||||
|
### Unity2.ai Partner Provider
|
||||||
|
|
||||||
|
Added Unity2.ai, an AI API relay partner, as a preset across all seven managed apps (Claude Code, Codex, Gemini, OpenCode, OpenClaw, Claude Desktop, Hermes), each carrying the referral signup link and partner promotion copy in all four locales. Codex uses the bare base URL (the gateway exposes `/responses` at root) while OpenCode / OpenClaw / Hermes use the `/v1` chat-completions endpoint with `gpt-5.5`.
|
||||||
|
|
||||||
|
### Kimi K2.7 Code Model
|
||||||
|
|
||||||
|
Added the `kimi-k2.7-code` model (in $0.95 / out $4.00 / cache-read $0.19 per 1M tokens, 256K context) and pointed all six official Moonshot Kimi presets (Claude Code, Codex, Claude Desktop, Hermes, OpenCode, OpenClaw) at it, renaming the OpenCode / OpenClaw presets to "Kimi K2.7 Code". The pricing seed applies on startup via the idempotent insert path, so existing users pick up the new pricing without a migration.
|
||||||
|
|
||||||
|
### Codex "Kimi For Coding" Preset Restored
|
||||||
|
|
||||||
|
Re-added the Codex "Kimi For Coding" preset (`openai_chat`, `kimi-for-coding`, 256K context) with thinking mode enabled by default; it was previously removed because the coding endpoint rejects Codex's default `codex-cli` User-Agent with 403. It now works via proxy takeover combined with the custom User-Agent override (set to a whitelisted UA such as `claude-cli/*`).
|
||||||
|
|
||||||
|
### Pricing-Model Audit in Request Detail
|
||||||
|
|
||||||
|
The request detail panel now shows the requested model and the pricing model when they differ from the response model, making route-takeover bills auditable directly from the usage UI.
|
||||||
|
|
||||||
|
### Preset Provider Search & Sorting
|
||||||
|
|
||||||
|
The provider preset selector gains a searchable, sorted list with an inline search box (toggled via a magnifier icon, dismissed on ESC or outside click). Buttons use a responsive grid with consistent sizing and default icons, and search matches only provider display/raw names so URL fragments and shared category labels no longer produce noisy matches (#3975, #4183).
|
||||||
|
|
||||||
|
### Claude Mythos 5 Pricing
|
||||||
|
|
||||||
|
Registered the `claude-mythos-5` model in the bundled model/pricing table (in $10 / out $50 per 1M tokens, cache read $1.00, cache write $12.50), so usage metering prices and displays it correctly (#4077).
|
||||||
|
|
||||||
|
### Fable 5 Verified Banner
|
||||||
|
|
||||||
|
The Settings About page now displays a Fable 5 Verified banner beside the app name and version, marking this as a special build, with the version badge centered under the app name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
### Claude Desktop Usage Folded Into Claude
|
||||||
|
|
||||||
|
The dashboard no longer shows a standalone "Claude Desktop" bucket, which only ever displayed a partial number (Desktop chat usage never passes through the proxy and its Code-tab sessions write into the shared `~/.claude/projects` tree). Desktop proxy traffic is now folded into the `claude` view for display while still recorded under its own `app_type` for route-takeover billing audit, with the real value visible in the request detail panel.
|
||||||
|
|
||||||
|
### Lightweight Provider Health Check
|
||||||
|
|
||||||
|
The provider health check no longer sends a real streaming model request (which many third-party providers blocked with 401/403/WAF, causing false negatives); it now performs a lightweight HTTP reachability probe of the provider `base_url`, treating any HTTP response as reachable and counting only DNS/connect/TLS/timeout as failure. The connectivity button is hidden for official providers (which use OAuth with an empty base URL and no reliable reachability target), the real-request confirmation dialog and test model/prompt fields are removed, and the degraded-latency threshold is set to 6s with an 8s timeout. The reachability check never resets the circuit breaker, so failover detection stays driven solely by real proxy traffic.
|
||||||
|
|
||||||
|
### Codex Advanced Options Section
|
||||||
|
|
||||||
|
The Codex provider form now folds local routing, model mapping, reasoning overrides, and custom User-Agent into a single collapsible advanced section mirroring the Claude form (auto-expanding when a UA is set or local routing is on). Custom User-Agent is now also configurable for native Responses providers, where it was previously reachable only with `openai_chat` routing enabled.
|
||||||
|
|
||||||
|
### Usage Toolbar Refresh and Layout
|
||||||
|
|
||||||
|
The app filter now renders brand icons (via ProviderIcon, with a grid icon for "All") instead of text tabs that wrapped awkwardly in narrow windows, and the usage hero shows the selected app's brand icon with Codex recolored to a neutral gray matching OpenAI's monochrome branding. The click-to-cycle refresh button becomes a Select with a localized "off" label, and the top-bar controls are compacted and aligned into consistent width groups with truncated long date-range labels.
|
||||||
|
|
||||||
|
### Faster About Panel Loading
|
||||||
|
|
||||||
|
The Settings About panel now loads progressively: the app version badge appears the instant it resolves instead of waiting for tool probes, each tool card updates the moment its own version check finishes (probes run concurrently rather than sequentially), and results are cached for the app session with a 10-minute TTL so reopening the About tab reuses cached values and revalidates stale ones in the background instead of re-probing all six tools every time.
|
||||||
|
|
||||||
|
### Volcengine Ark Coding Plan Promo
|
||||||
|
|
||||||
|
Updated the Volcengine Ark preset across all six apps with the new Coding Plan invite link (replacing the old Agent Plan / activity links) and refreshed the partner promotion copy in all four locales (two-month 75% off plus invite code 6J6FV5N2), correcting the product name from Agent Plan to Coding Plan.
|
||||||
|
|
||||||
|
### MiniMax Demoted to Regular Provider
|
||||||
|
|
||||||
|
Removed the gold partner star badge and the API-key promotion banner for MiniMax by dropping the `isPartner` flag from all its presets; it stays as a regular `cn_official` provider keeping its icon and theme. The promotion copy is kept dormant so the partnership can be re-enabled with a single line.
|
||||||
|
|
||||||
|
### LemonData Removed, SudoCode Demoted
|
||||||
|
|
||||||
|
Removed the LemonData provider preset entirely from all apps along with its promotion copy, icons, and sponsor listings, and demoted SudoCode from a partner to a regular `third_party` provider by dropping its `isPartner` flag and promotion copy (it keeps its icon).
|
||||||
|
|
||||||
|
### AtlasCloud Codex GLM 5.1 Context Window
|
||||||
|
|
||||||
|
Declared the 200,000-token context window for the `zai-org/glm-5.1` model in the AtlasCloud Codex preset, matching the other GLM 5.1 preset entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
### Route-Takeover Traffic Billed by the Real Upstream Model
|
||||||
|
|
||||||
|
When a request was routed to a different upstream (env model mapping, Claude Desktop routes, Copilot normalization, Codex chat override), the proxy used to attribute and price usage by whatever model the upstream echoed back, recording kimi/glm tokens as `claude-*` and overstating cost roughly 5–25×. The forwarder now captures the real outbound model, attributes usage by upstream-echo then outbound then client alias, persists the actual pricing basis on every row (schema v11), and keeps that basis through cost backfill and 30-day rollup pruning; Claude Desktop traffic is now logged under its own `app_type` so its pricing overrides apply.
|
||||||
|
|
||||||
|
### Usage Metering on Format-Conversion Proxy Paths
|
||||||
|
|
||||||
|
Audited and fixed token/cache accounting across the proxy's format-conversion paths (Chat, Responses, and Gemini converted to Anthropic). The proxy now records the actually returned model, injects `stream_options.include_usage` so OpenAI-compatible upstreams emit usage in streaming, excludes `cache_read` and `cache_creation` from input on Claude←OpenAI paths to stop double-billing cache tokens, subtracts cached Gemini prompt tokens, still records fully-cached requests, and skips synthetic all-zero usage that previously inflated request counts (#2774).
|
||||||
|
|
||||||
|
### In-App Update No Longer Hangs on Restart
|
||||||
|
|
||||||
|
Installing an update from within the app no longer freezes on the "restarting" screen, leaving the new version installed but requiring a manual force-quit. The download-install-restart chain now runs entirely in the backend (a new `install_update_and_restart` command) with platform-aware install ordering and single-instance-lock teardown before re-exec, instead of depending on the old WebView to keep running JS after the app bundle was already swapped; exit requests are also classified so restart requests fall through to Tauri's default flow rather than deadlocking on the window-state plugin mutex (#4069, #4074).
|
||||||
|
|
||||||
|
### Codex Upgrade No Longer Breaks the Install
|
||||||
|
|
||||||
|
Upgrading Codex from the Settings "About" tab no longer leaves it throwing "Missing optional dependency @openai/codex-…" errors. The upgrade chain previously ran `codex update` first, which on an npm install is a bare reinstall that reports success even when the per-platform binary fails to land; Codex is now removed from the self-update-first path and a runnable check triggers an uninstall+reinstall self-heal (scoped to npm-managed installs) that actually re-lands the missing platform binary.
|
||||||
|
|
||||||
|
### Codex OAuth Auth Token Preserved on Proxy Takeover
|
||||||
|
|
||||||
|
Enabling proxy takeover for a Codex provider no longer strips the `ANTHROPIC_AUTH_TOKEN` placeholder, which previously broke Claude Code's login on hot-switches, fresh installs, and configs already stripped by older releases. The placeholder is now injected unconditionally for managed (non-Copilot) Codex providers, including URL-only ones; GitHub Copilot behavior (API_KEY only) is unchanged (#3789, #3784).
|
||||||
|
|
||||||
|
### Takeover-Residue Recovery Across Config-Dir Switches
|
||||||
|
|
||||||
|
Restarting the app after changing the config directory while proxy takeover is active no longer leaves Claude/Codex/Gemini pointed at a dead local proxy. The old instance now restores the taken-over live files before restarting, the first-run import refuses to persist a takeover placeholder as a provider, and SSOT restore validates that the current provider's config is free of placeholders before writing it back (#4076).
|
||||||
|
|
||||||
|
### Mislabeled SSE Bodies in Format-Transform Fallback
|
||||||
|
|
||||||
|
Requests routed through Claude/Codex format conversion no longer fail with an opaque 422 "Failed to parse upstream response" when a MaaS gateway force-streams a `stream:false` request and returns an SSE body under a non-SSE Content-Type. The proxy now sniffs for SSE on parse failure, aggregates the chunks into a single JSON, and runs the existing converter so clients still get a valid non-stream response; remaining parse failures are enriched with content-type, encoding, and body-snippet diagnostics, and deflate decoding now tries zlib before raw (#2234).
|
||||||
|
|
||||||
|
### Duplicate YAML Keys in Hermes Config
|
||||||
|
|
||||||
|
Hermes config writes no longer accumulate duplicate top-level keys (e.g. `mcp_servers`) that caused "Failed to parse Hermes config as YAML: duplicate entry with key" errors. Section replacement now strips all stale occurrences from the remainder instead of degrading into appends, the dedup safety net handles both LF and CRLF line endings, and healing keeps the last (newest) occurrence to match Hermes's own last-wins PyYAML semantics (#3267, #3633, #2973, #2529, #3310, #3762).
|
||||||
|
|
||||||
|
### Usage Query Resilience and Error Clarity
|
||||||
|
|
||||||
|
Usage cards no longer flip to red on a single transient blip: queries now retry once and keep showing the last successful result for up to 10 minutes on network/timeout/5xx failures, while deterministic failures (auth, empty key, unknown provider, 4xx) surface immediately and clear the snapshot so a stale quota can't resurface after credentials change. Native balance/coding-plan/subscription timeouts were raised from 10s to 15s for slow cross-border endpoints, and coding-plan now returns explicit "API key is empty" / "Unknown coding plan provider" errors instead of a blank failure.
|
||||||
|
|
||||||
|
### Usage Script Provider Credential Resolution
|
||||||
|
|
||||||
|
Custom JS-script usage queries resolved `{{apiKey}}` / `{{baseUrl}}` by guessing env fields only, so providers that store credentials elsewhere (e.g. Codex's `auth.OPENAI_API_KEY` plus `config.toml` base_url) always got empty values and failed despite being fully configured. Script queries and the test/preview now reuse the same per-app credential resolver as the native balance path, with explicit non-empty script values still taking precedence (#1479).
|
||||||
|
|
||||||
|
### Claude Code Workflow Sub-Agent Usage Counted
|
||||||
|
|
||||||
|
Local (no-proxy) session-log usage accounting missed Claude Code Workflow sub-agent traffic, under-counting overall usage by roughly 4.1% (concentrated in workflow/subagent transcripts). The scanner now descends into the deeper `subagents/workflows/wf_*/` transcript directories, and the parser no longer drops billable assistant messages that lack a `stop_reason` but already incurred input/cache token cost; dedup is unchanged so no usage is double-counted.
|
||||||
|
|
||||||
|
### Codex Image Rectifier for /responses Text-Only Upstreams
|
||||||
|
|
||||||
|
Codex `/responses` requests carrying images and routed to text-only OpenAI-chat models (e.g. DeepSeek `deepseek-v4-flash`) no longer fail with HTTP 400 "unknown variant `image_url`". The media rectifier now also covers the Codex adapter, scanning the responses `input` for `input_image` blocks so it can proactively strip images for known text-only models and reactively retry with images replaced on upstream image-unsupported errors.
|
||||||
|
|
||||||
|
### Zhipu Coding-Plan Quota Window Mislabeling
|
||||||
|
|
||||||
|
The Zhipu coding-plan view no longer swaps the 5-hour and weekly quota buckets in the final hours of each weekly cycle. The two windows are now classified by the explicit `unit` field (3 = 5-hour, 6 = weekly) instead of by sorting reset-time ascending, which mislabeled them exactly when users check their weekly quota most; the old reset-time heuristic remains as a fallback (#3036).
|
||||||
|
|
||||||
|
### Duplicate Provider Terminal Sessions on macOS
|
||||||
|
|
||||||
|
Launching a provider terminal on macOS no longer opens an extra empty window alongside the command session; Terminal.app uses `launch` (not `activate`) on cold start and Ghostty uses an initial-command so a single session opens, with a fallback retained if the AppleScript path fails (#4156).
|
||||||
|
|
||||||
|
### Claude Desktop Model-Mapping Placeholders
|
||||||
|
|
||||||
|
The Claude Desktop model-mapping form previously showed mismatched example brands across the menu display name and request model columns (DeepSeek vs Kimi), implying a display name maps to an unrelated model. Both placeholders are now derived from each row's role so they stay brand-consistent, with the lightweight Haiku tier using a flash example.
|
||||||
|
|
||||||
|
### Popovers Behind Fullscreen Panels
|
||||||
|
|
||||||
|
Popovers and tooltips such as the provider preset search no longer render behind fullscreen panels and appear unresponsive on click; their z-index is raised above the fullscreen overlay while staying below modal dialogs.
|
||||||
|
|
||||||
|
### ToggleRow Icon Shrinking
|
||||||
|
|
||||||
|
Toggle row icons no longer shrink or distort when paired with long descriptions, keeping the icon at a fixed size next to multi-line text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Release Notes Contributor Mentions
|
||||||
|
|
||||||
|
Restored contributor mentions in the v3.16.1 and v3.16.2 release notes across all three locales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
### Pricing Database schema v11 Auto-Migration
|
||||||
|
|
||||||
|
This release adds a `pricing_model` column to `proxy_request_logs` and rebuilds the rollup by `request_model` + `pricing_model`, migrating automatically on startup with no manual action required. Historical rows have their cost frozen at write time and are not recalculated (rows with `app_type="claude"` mix native and converted sources); only real but previously un-priced takeover rows stay at zero cost until pricing is supplied and then backfilled.
|
||||||
|
|
||||||
|
### Model Mapping Adds a Fourth Tier (Fable 5)
|
||||||
|
|
||||||
|
The Claude Code and Claude Desktop model mappings now have four tiers (Sonnet / Opus / Fable / Haiku). Older three-tier providers pick up the `claude-fable-5` tier after being reopened and saved; leaving that tier blank means it inherits Sonnet. Note: leaving any tier blank on third-party endpoints forwards the literal model name of that tier and may 404, so fill it in as needed.
|
||||||
|
|
||||||
|
### The "Kimi For Coding" Preset Needs Proxy Takeover + a Whitelisted UA
|
||||||
|
|
||||||
|
The restored Codex "Kimi For Coding" preset is still rejected with 403 if used with the default `codex-cli` User-Agent. To use it, enable proxy takeover and set the custom User-Agent in the provider's advanced options to a whitelisted UA (such as `claude-cli/*`).
|
||||||
|
|
||||||
|
### Provider Health Check Semantics Changed
|
||||||
|
|
||||||
|
The health check changed from "send a real model request" to "HTTP reachability probe". Note that reachable ≠ usable: a host that returns 403 is reachable but may be broken for real traffic. Failover decisions remain driven solely by real proxy traffic and are unaffected by the health check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Notice
|
||||||
|
|
||||||
|
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||||
|
|
||||||
|
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||||
|
|
||||||
|
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||||
|
|
||||||
|
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||||
|
|
||||||
|
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Thanks to the following contributors for the features and fixes in v3.16.3:
|
||||||
|
|
||||||
|
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): preserve Codex OAuth auth token on takeover, thanks @codeasier.
|
||||||
|
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): fix model / input-token recording on Completions→Anthropic, thanks @LaoYueHanNi.
|
||||||
|
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): fix the deadlock on relaunch after an in-app update, thanks @thisTom.
|
||||||
|
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): fix duplicate provider terminal sessions on macOS, thanks @thisTom.
|
||||||
|
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): fix duplicate YAML keys in the Hermes config, thanks @que3sui.
|
||||||
|
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): fix usage script provider credential resolution, thanks @pa001024.
|
||||||
|
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): add preset search and sorting, thanks @Nastem.
|
||||||
|
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): adjust the preset-provider button appearance and search-box position, thanks @WangJiati.
|
||||||
|
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): add claude-mythos-5 model pricing, thanks @osscv.
|
||||||
|
|
||||||
|
Thanks also to everyone who reported usage billing, local proxy robustness, Codex upgrade, and platform compatibility issues after v3.16.2. Many of these fixes came directly from real-world reproduction details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Install
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
| System | Minimum Version | Architecture |
|
||||||
|
| ------- | ------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 and later | x64 |
|
||||||
|
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | See table below | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.16.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||||
|
| `CC-Switch-v3.16.3-Windows-Portable.zip` | Portable build, unzip and run |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| -------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||||
|
|
||||||
|
Homebrew install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| Distribution | Recommended Format | Install Command |
|
||||||
|
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||||
|
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# CC Switch v3.16.3
|
||||||
|
|
||||||
|
> 🎉 **CC Switch が 100,000 Star を突破しました!**
|
||||||
|
> すべてのユーザー・コントリビューター・Star をくださった方々に感謝します —— 皆さんのおかげでここまで来ました。🙏
|
||||||
|
|
||||||
|
> 💎 **本リリースは Claude Fable 5 モデルの協力のもとで開発されました**——重要かつ間違えやすいロジックの整理を手伝ってくれました: ルーティングテイクオーバー時に本物の上流モデルで課金する帰属チェーン、形式変換経路でのキャッシュ token の計上と重複排除、アプリ内更新の再起動デッドロック、そして Codex 統一セッション履歴の移行 / 復元の不変条件です。本リリースの「バージョン情報」ページに **Fable 5 Verified** バッジを新設したのもこのためです。
|
||||||
|
|
||||||
|
> v3.16.2 でデータの可搬性と使用量の可観測性を広げたのに続き、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——本物の上流モデルで課金し、形式変換経路でのキャッシュの二重計上を修正し、Claude Code Workflow のサブ agent の使用量を統計に取り込み(schema v11)、使用量ダッシュボードを一通り刷新しました(全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会)。あわせて一連のローカルプロキシとプラットフォームの問題を補強し、カスタム User-Agent オーバーライド、Codex 統一セッション履歴のトグル、Claude Fable 5 階層を新設しました。
|
||||||
|
|
||||||
|
**[English →](v3.16.3-en.md) | [中文版 →](v3.16.3-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 利用ガイド
|
||||||
|
|
||||||
|
本リリースでは **Codex 統一セッション履歴** のトグルを新設しました——セッションの移行 / 復元を伴い、操作を誤ると「セッションが消えた」と誤解しやすいため、まずこのガイドを読むことを強くおすすめします。また使用量統計の数え方とダッシュボードにも多くの調整を加えたので、あわせて以下をご覧ください:
|
||||||
|
|
||||||
|
- **[Codex セッション履歴の統一: 機能紹介と利用ガイド](../guides/codex-unified-session-history-guide-ja.md)**: 「統一 / 移行 / 復元」が実際に何を変えるのか、なぜデータが本当に失われないのか、そしてセッションが見えないときの自己点検と正確な復元の方法を解説します。**このトグルを使った、またはセッションが消えたと心配な方は、まずこちらをお読みください。**
|
||||||
|
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソース(プロキシログ、セッション同期)と集計の仕組みを確認できます。本リリースで全体に効くプロバイダー / モデルフィルタを追加し、ルーティングテイクオーバー時の本物の課金モデルを表示するようにしました。
|
||||||
|
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: カスタム User-Agent オーバーライド、Codex 統一セッション履歴などのトグルは、プロバイダーフォームの高度なオプションと設定ページにあります。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## 唯一の公式チャネル(必ずお読みください)
|
||||||
|
>
|
||||||
|
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||||
|
>
|
||||||
|
> | チャネル | 唯一の公式 |
|
||||||
|
> | ------------ | ------------------------------------------------------------------------------ |
|
||||||
|
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.16.3 は v3.16.2 に続くメンテナンスアップデートです。前リリースではデータの可搬性と使用量の可観測性の拡張に集中しましたが、本リリースは「使用量の課金を本当に正確にする」ことに重きを置いています——上流が返すエイリアスではなく本物の上流モデルで課金し、形式変換(Chat / Responses / Gemini を Anthropic へ)経路でのキャッシュ token の二重計上を修正し、Claude Code Workflow のサブ agent の使用量をローカル統計に取り込み、schema v11 で各レコードが実際に使用した課金根拠を永続化しました。使用量ダッシュボードもこれにあわせて一通り刷新し、全体に効くプロバイダー / モデルフィルタ、ブランドアイコンのツールバー、より安定した残量照会(失敗時の再試行 + 前回成功した結果の保持)を追加しました。
|
||||||
|
|
||||||
|
さらに本リリースでは、一連のローカルプロキシの堅牢性に関する問題(Content-Type が誤ってラベル付けされた SSE レスポンスの集約、Codex `/responses` のテキスト専用モデル向け画像整流、Codex OAuth 認証情報とテイクオーバー残留の復元、Hermes 設定の重複 YAML キー)を補強し、プロバイダー設定まわりを作り直し(カスタム User-Agent オーバーライド、Codex フォームの高度なオプションへの統合、プリセット検索とソート、Claude Fable 5 階層)、Codex 統一セッション履歴のトグルを新設し、アプリ内更新のハング、Codex のアップグレードによるインストール破損、macOS の重複ターミナルウィンドウなどの問題を修正しました。
|
||||||
|
|
||||||
|
**リリース日**: 2026-06-14
|
||||||
|
|
||||||
|
**Stats**: 59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ハイライト
|
||||||
|
|
||||||
|
- **使用量の課金がより正確に**: ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金するようになり(上流が返すエイリアスではなく)、形式変換経路でキャッシュ token を input に二重計上しなくなり、Claude Code Workflow のサブ agent の使用量も統計に取り込みました——schema v11 で課金根拠を永続化します。
|
||||||
|
- **使用量ダッシュボードの刷新**: プロバイダー / モデルフィルタをリクエストログテーブルから全体フィルタへ引き上げ、アプリフィルタをブランドアイコンに変更し、残量照会に失敗時の再試行と「前回成功した結果の保持」を追加して、一度のネットワークのゆらぎでカードが赤くならないようにしました。
|
||||||
|
- **カスタム User-Agent オーバーライド**: プロバイダーにカスタム UA を設定でき、転送・接続性チェック・モデル一覧の 3 か所で一貫して有効になり、UA ホワイトリストで制限する Coding Plan 上流を通過できます(これにより Codex「Kimi For Coding」プリセットを復活させました)。
|
||||||
|
- **Codex 統一セッション履歴**: 公式 Codex セッションとサードパーティセッションが同じ resume 履歴バケットを共有できる任意のトグルを新設し、既存セッションの任意移行と台帳に基づく精密な復元を備えます。
|
||||||
|
- **プロキシとプラットフォームの補強**: 誤ラベルの SSE レスポンスの集約、Codex 画像整流、テイクオーバー残留の復元、Hermes YAML 重複排除。アプリ内更新が「再起動中」でハングしなくなり、Codex のアップグレードでインストールを壊さなくなりました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加機能
|
||||||
|
|
||||||
|
### カスタム User-Agent オーバーライド
|
||||||
|
|
||||||
|
プロバイダー設定でカスタム User-Agent を設定できるようになり、プロキシがリクエスト転送、接続性チェック、モデル一覧(`GET /v1/models`)の 3 つの経路で一貫して適用します。これにより、UA ホワイトリストで制限する Coding Plan 上流で「検出は失敗 / モデル一覧は 403 なのにプロキシ本体は正常に動く」という不整合が起きなくなります。Claude と Codex のフォームはいずれも高度なオプションでこのフィールドを公開し、厳選した UA プリセットのドロップダウン(Claude Code / Kilo Code など UA ホワイトリストを通過できるファミリー)とリアルタイムで非ブロッキングな形式検証を備えます。公式プリセットへ切り替えると残っていたカスタム UA は破棄され、リクエストヘッダーを密かに変更しないようにします([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
|
||||||
|
|
||||||
|
### Codex 統一セッション履歴
|
||||||
|
|
||||||
|
任意のトグル(設定 → Codex アプリ拡張)を新設し、公式 Codex セッションと CC Switch のサードパーティセッションが同じ resume 履歴バケットを共有できるようにしました。resume セレクタが両者を互いに隠さなくなります。有効化すると、live の `config.toml` は公式の実行を、内蔵 OpenAI プロバイダーをミラーした共有 `custom` model_provider へルーティングします(`auth.json` は変更しません)。デフォルトでは今後のセッションにのみ有効です。有効化ダイアログには既存の公式セッションを共有バケットへ移行できるチェックボックスがあり(世代ごとのバックアップ付き)、無効化ダイアログにはバックアップ台帳に基づく精密な復元が用意されています——バックアップ内で `openai` として記録されたセッションのみを巻き戻し、有効化中に新規作成されたセッションは決して変更しません。
|
||||||
|
|
||||||
|
### 使用量ダッシュボードの全体プロバイダー / モデルフィルタ
|
||||||
|
|
||||||
|
プロバイダーフィルタとモデルフィルタを、リクエストログテーブルの内部からトップバーへ引き上げ、Hero サマリー、トレンドグラフ、リクエストログ、2 つの統計タブ全体に効くようにしました。ダッシュボード全体を特定のソースとモデルで絞り込めます。ソースは表示名で厳密に一致するため「Claude (Session)」のようなセッションのプレースホルダー行も選択でき、モデルは有効な課金モデルで一致し、モデルのドロップダウンは選択したソースに応じてカスケードし、どちらの一覧も現在の期間にデータがある選択肢のみを表示します。
|
||||||
|
|
||||||
|
### モデル価格シードの刷新
|
||||||
|
|
||||||
|
`seed_model_pricing` の全件価格点検を実施しました: 9 個のモデルの価格を新規追加し(Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus などを含む)、各ベンダー公式の定価に合わせて既存価格 28 か所を訂正し(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen)、使用量コストの見積りをより正確にしました。各変更はシード(新規インストールに影響)を更新すると同時に、`repair_current_model_pricing` に旧→新のガードを 1 件追加します(既存データベースを修復し、ユーザーが手動で編集した行は上書きしません)。
|
||||||
|
|
||||||
|
### Claude Fable 5 モデル階層
|
||||||
|
|
||||||
|
プロバイダーフォームは、Claude Code と Claude Desktop の両プロキシ経路で `claude-fable-5` を 4 つ目のモデルマッピング階層として公開するようになりました。フォールバックチェーンは fable → opus → default で、公式の降格と一致し、Claude Desktop 1.12603.1+ の検証器で `fable-` プレフィックスを許可しました。4 言語のフォールバックヒントも明確化しました: サードパーティの endpoint である階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になります([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
|
||||||
|
|
||||||
|
### Unity2.ai パートナープロバイダー
|
||||||
|
|
||||||
|
Unity2.ai(AI API 中継のパートナー)をプリセットとして追加し、管理対象の 7 アプリすべて(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes)をカバーしました。各プリセットには紹介登録リンクを付け、4 言語でパートナー宣伝文を補いました。Codex は素の base URL を使用し(このゲートウェイはルートパスに `/responses` を公開)、OpenCode / OpenClaw / Hermes は `/v1` chat-completions の endpoint を使用し、`gpt-5.5` を既定モデルとします。
|
||||||
|
|
||||||
|
### Kimi K2.7 Code モデル
|
||||||
|
|
||||||
|
`kimi-k2.7-code` モデル(入力 $0.95 / 出力 $4.00 / キャッシュ読み取り $0.19、100 万 token あたり、256K コンテキスト)を新規追加し、6 つの公式 Moonshot Kimi プリセットすべて(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)をこれに向けました。OpenCode / OpenClaw のプリセットは「Kimi K2.7 Code」へ改名しました。価格シードは起動時の冪等な挿入経路で有効になるため、既存ユーザーは移行なしで新価格を取得できます。
|
||||||
|
|
||||||
|
### Codex「Kimi For Coding」プリセットの復活
|
||||||
|
|
||||||
|
Codex「Kimi For Coding」プリセット(`openai_chat`、`kimi-for-coding`、256K コンテキスト)を再追加し、思考モードをデフォルトで有効にしました。以前これを削除したのは、このコーディング endpoint が Codex 既定の `codex-cli` User-Agent を 403 で拒否するためでしたが、現在はプロキシテイクオーバー + カスタム User-Agent オーバーライド(ホワイトリストの UA、例 `claude-cli/*` に設定)を使えば正常に使えます。
|
||||||
|
|
||||||
|
### リクエスト詳細での課金モデル監査
|
||||||
|
|
||||||
|
リクエスト詳細パネルは、「リクエストされたモデル」「課金モデル」がレスポンスのモデルと一致しないときにそれらをすべて表示するようになり、ルーティングテイクオーバーが生む請求を使用量画面から直接照合できます。
|
||||||
|
|
||||||
|
### プリセットプロバイダーの検索とソート
|
||||||
|
|
||||||
|
プリセットプロバイダーのセレクタが、検索・ソート可能な一覧になり、インライン検索ボックスを備えました(虫眼鏡アイコンで切り替え、ESC または外側クリックで畳む)。ボタンはレスポンシブグリッドに変わってサイズが統一され、既定アイコンを表示します。検索はプロバイダーの表示名 / 生の名前のみに一致するため、URL の断片や共有のカテゴリラベルがノイズ一致を生まなくなります([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
|
||||||
|
|
||||||
|
### Claude Mythos 5 の価格
|
||||||
|
|
||||||
|
内蔵のモデル / 価格表に `claude-mythos-5` モデル(入力 $10 / 出力 $50、100 万 token あたり;キャッシュ読み取り $1.00、キャッシュ書き込み $12.50)を登録し、使用量統計が正しく課金・表示できるようにしました([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
|
||||||
|
|
||||||
|
### Fable 5 Verified バッジ
|
||||||
|
|
||||||
|
設定の「バージョン情報」ページが、アプリ名とバージョンの隣に Fable 5 Verified バッジを表示し、これが特別ビルドであることを示すようになりました。バージョンバッジもアプリ名の下に中央揃えしました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 変更
|
||||||
|
|
||||||
|
### Claude Desktop の使用量を Claude に折りたたみ
|
||||||
|
|
||||||
|
ダッシュボードは独立した「Claude Desktop」バケットを表示しなくなりました——これは常に不完全な数字しか表示できませんでした(Desktop のチャット使用量はそもそもプロキシを経由せず、その Code タブのセッションは内蔵の Claude Code ランタイムが共有の `~/.claude/projects` ディレクトリへ書き込んでいるだけです)。Desktop のプロキシトラフィックは表示上 `claude` に折りたたまれますが、記帳層はルーティングテイクオーバー課金の監査のために引き続き自身の `app_type` で記録し、本物の値はリクエスト詳細パネルで確認できます。
|
||||||
|
|
||||||
|
### 軽量化したプロバイダーヘルスチェック
|
||||||
|
|
||||||
|
プロバイダーヘルスチェックは、本物のストリーミングモデルリクエストを送らなくなりました(多くのサードパーティプロバイダーが 401/403/WAF でブロックし、利用不可の誤検知を生むため)。代わりにプロバイダーの `base_url` へ軽量な HTTP 到達性プローブを 1 回行います: あらゆる HTTP レスポンスを到達可能とみなし、DNS / 接続 / TLS / タイムアウトのみを失敗とします。公式プロバイダー(OAuth を使用し、base_url が意図的に空で、信頼できる到達性ターゲットがない)は接続性チェックのボタンを隠します。従来の「本物のリクエストを送る」確認ダイアログ、テストモデル / プロンプトのフィールドは削除し、劣化レイテンシのしきい値を 6s、タイムアウトを 8s としました。この到達性チェックは決してサーキットブレーカーをリセットしません——到達可能 ≠ 利用可能(403 を返す host は到達可能でも、本物のトラフィックには壊れています)。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動されます。
|
||||||
|
|
||||||
|
### Codex の高度なオプション領域の統合
|
||||||
|
|
||||||
|
Codex プロバイダーフォームは、ローカルルーティング、モデルマッピング、推論オーバーライド、カスタム User-Agent を展開可能な高度なオプション領域に折りたたみ、Claude フォームと揃えました(UA が設定されているか、ローカルルーティングが有効なときは自動展開)。カスタム User-Agent はネイティブ Responses プロバイダーでも設定できるようになりました。以前は `openai_chat` ルーティングを有効にしたときにしか触れられませんでした。
|
||||||
|
|
||||||
|
### 使用量ツールバーとレイアウトの刷新
|
||||||
|
|
||||||
|
アプリフィルタはブランドアイコン(ProviderIcon 経由、「すべて」はグリッドアイコン)で描画するように変更し、狭いウィンドウで折り返すと見栄えの悪かったテキストタブを置き換えました。使用量 Hero も選択したアプリのブランドアイコンを表示し、Codex のテーマ色をエメラルドからニュートラルグレーへ変更して、OpenAI のモノクロブランドに合わせました。クリックで循環切り替えしていた更新ボタンは、ローカライズした「オフ」ラベルを持つドロップダウン選択に変更し、トップバーのコントロールも圧縮して幅のグループを揃え、長すぎる日付範囲のラベルは省略表示するようにしました。
|
||||||
|
|
||||||
|
### バージョン情報パネルの読み込みを高速化
|
||||||
|
|
||||||
|
設定の「バージョン情報」パネルが段階的に読み込むようになりました: アプリのバージョンバッジは解決した瞬間に表示され、ツールのプローブを待たなくなります。各ツールカードは自身のバージョン検出が完了した時点で即座に更新されます(プローブは直列ではなく並行に実行)。プローブ結果はアプリのセッション中、10 分の TTL 付きでキャッシュされるため、「バージョン情報」タブを再度開くとキャッシュ値を再利用し、期限切れの項目だけをバックグラウンドで再検証します。毎回 6 つのツールすべてを再プローブすることはなくなりました。
|
||||||
|
|
||||||
|
### 火山方舟 Coding Plan の宣伝更新
|
||||||
|
|
||||||
|
火山方舟(Volcengine Ark)プリセットを 6 アプリすべてで新しい Coding Plan 招待リンクへ更新し(旧 Agent Plan / キャンペーンリンクを置き換え)、4 言語でパートナー宣伝文を刷新しました(2 か月 75% 割引 + 招待コード 6J6FV5N2)。製品名も Agent Plan から Coding Plan へ訂正しました。
|
||||||
|
|
||||||
|
### MiniMax を通常プロバイダーへ降格
|
||||||
|
|
||||||
|
MiniMax の金色のパートナースター印と API key 宣伝バナーを削除し(全プリセットから `isPartner` フラグを除去)、引き続き通常の `cn_official` プロバイダーとしてアイコンとテーマを保持します。宣伝文は休眠状態のまま残し、必要であれば 1 行で提携関係を再有効化できます。
|
||||||
|
|
||||||
|
### LemonData を削除、SudoCode を降格
|
||||||
|
|
||||||
|
LemonData プロバイダープリセットを完全に削除し(宣伝文、アイコン、スポンサー項目もあわせて)、SudoCode をパートナーから通常の `third_party` プロバイダーへ降格しました(`isPartner` フラグと宣伝文を外し、アイコンは保持)。
|
||||||
|
|
||||||
|
### AtlasCloud Codex GLM 5.1 のコンテキストウィンドウ
|
||||||
|
|
||||||
|
AtlasCloud Codex プリセットの `zai-org/glm-5.1` モデルに 200,000 token のコンテキストウィンドウを宣言し、ほかの GLM 5.1 プリセット項目に揃えました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修正
|
||||||
|
|
||||||
|
### ルーティングテイクオーバーのトラフィックを本物の上流モデルで課金
|
||||||
|
|
||||||
|
リクエストが別の上流へルーティングされた場合(env モデルマッピング、Claude Desktop ルーティング、Copilot 正規化、Codex chat オーバーライド)、プロキシは以前、上流が返したモデルで帰属・課金していたため、kimi / glm の token を `claude-*` として記録・課金し、コストが約 5〜25 倍に過大評価されていました。現在は転送器が本物のアウトバウンドモデルを捕捉し、「上流が返した値 → アウトバウンドモデル → クライアントのエイリアス」の順で帰属し、各行に実際に使用した課金根拠を永続化します(schema v11)。この根拠はコストのバックフィルと 30 日 rollup のプルーニングまで一貫して使われます。Claude Desktop のトラフィックも自身の `app_type` で記録されるようになり、その価格オーバーライドが正しく効くようになりました。
|
||||||
|
|
||||||
|
### 形式変換経路での使用量計上
|
||||||
|
|
||||||
|
プロキシの各形式変換経路(Chat、Responses、Gemini を Anthropic へ)での token / キャッシュの計上を監査・修正しました。プロキシは実際に返ったモデルを記録し、`stream_options.include_usage` を注入して OpenAI 互換の上流がストリーミング時に usage を吐くようにし、Claude←OpenAI 経路では `cache_read` と `cache_creation` を input から除外してキャッシュ token の二重計上を防ぎ、Gemini のキャッシュ済みプロンプト token を差し引き、完全にキャッシュヒットしたリクエストも引き続き記録し、過去にリクエスト数を水増ししていた合成の全ゼロ usage をスキップするようになりました([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
|
||||||
|
|
||||||
|
### アプリ内更新がハングしなくなった
|
||||||
|
|
||||||
|
アプリ内から更新をインストールするとき、「再起動中」の画面でハングしなくなりました——以前は新版がインストール済みなのに、手動で強制終了せざるを得ない状況が起きていました。ダウンロード—インストール—再起動の一連の流れは、完全にバックエンドで実行するようになり(`install_update_and_restart` コマンドを新設)、プラットフォームごとにインストール順序を決め、再実行の前にまず単一インスタンスロックを破棄します。アプリパッケージがすでに置き換えられた後に古い WebView が JS を走らせ続けることに依存しなくなりました。終了リクエストも分類して、再起動リクエストが Tauri の既定フローに落ちるようにし、ウィンドウ状態プラグインのミューテックスでデッドロックしないようにしました([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
|
||||||
|
|
||||||
|
### Codex のアップグレードがインストールを壊さなくなった
|
||||||
|
|
||||||
|
設定の「バージョン情報」ページから Codex をアップグレードしても、「Missing optional dependency @openai/codex-…」エラーを投げなくなりました。アップグレードのチェーンは以前まず `codex update` を実行しますが、これは npm インストール下では実質的に素の再インストールであり、対応するプラットフォームのバイナリがインストールされていなくても成功を報告していました。現在は Codex を「self-update 優先」経路から除外し、runnable 検出が「アンインストール + 再インストール」の自己修復を駆動するようにしました(npm 管理のインストールに限定)。これが、欠けたプラットフォームバイナリを本当に補える唯一の修正です。
|
||||||
|
|
||||||
|
### テイクオーバー時に Codex OAuth 認証情報を保持
|
||||||
|
|
||||||
|
Codex プロバイダーでプロキシテイクオーバーを有効にするとき、`ANTHROPIC_AUTH_TOKEN` プレースホルダーを剥がさなくなりました——以前これはホットスイッチ、新規インストール、そして旧バージョンがすでに剥がしてしまった live 設定で、Claude Code のログインを壊していました。現在は管理対象(非 Copilot)の Codex プロバイダーに対して、URL のみのプロバイダーを含め無条件にこのプレースホルダーを注入します。GitHub Copilot の挙動(API_KEY のみ)は変わりません([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
|
||||||
|
|
||||||
|
### 設定ディレクトリをまたぐ切り替えでのテイクオーバー残留の復元
|
||||||
|
|
||||||
|
プロキシテイクオーバーが有効なときに設定ディレクトリを変更してアプリを再起動しても、Claude / Codex / Gemini を失効したローカルプロキシに向けたままにしなくなりました。現在は旧インスタンスが再起動の前にテイクオーバーされた live ファイルを先に復元し、初回実行のインポートはテイクオーバープレースホルダーをプロバイダーとして永続化することを拒否し、SSOT の復元も書き戻す前に、現在のプロバイダーの設定にプレースホルダーが含まれないことを検証します([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
|
||||||
|
|
||||||
|
### 形式変換フォールバックでの誤ラベル SSE レスポンスの集約
|
||||||
|
|
||||||
|
Claude / Codex の形式変換を経たリクエストで、MaaS ゲートウェイが `stream:false` のリクエストを強制的にストリーミングし、非 SSE の Content-Type で SSE レスポンスボディを返したとき、難解な 422「Failed to parse upstream response」で失敗しなくなりました。プロキシは解析失敗時に SSE かどうかを検出し、チャンクを単一の JSON に集約してから既存の変換器を走らせ、クライアントが引き続き有効な非ストリーミングレスポンスを受け取れるようにします。残った解析失敗には content-type、エンコーディング、レスポンスボディの抜粋などの診断情報を付け、deflate のデコードも先に zlib を、次に素のストリームを試すように変更しました([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
|
||||||
|
|
||||||
|
### Hermes 設定の重複 YAML キー
|
||||||
|
|
||||||
|
Hermes 設定の書き込みは、重複したトップレベルキー(`mcp_servers` など)を累積しなくなりました。これは「Failed to parse Hermes config as YAML: duplicate entry with key」エラーを引き起こしていました。セクションの置換は、追加へ退化する代わりに、残りのテキストから古いコピーをすべて取り除くようになりました。重複排除のセーフティネットは LF と CRLF の行末を両方処理します。修復時には最後(最新)のコピーを保持し、Hermes 自身の PyYAML ベースの「後勝ち」セマンティクスに揃えます([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
|
||||||
|
|
||||||
|
### 使用量照会の堅牢性とエラーの明確さ
|
||||||
|
|
||||||
|
使用量カードは、一度の瞬間的なゆらぎだけで赤くならなくなりました: 照会は一度再試行し、ネットワーク / タイムアウト / 5xx のような一時的な失敗下では前回成功した結果を最大 10 分間表示し続けます。一方、確定的な失敗(認証、空の key、未知のプロバイダー、4xx)は即座に表面化してスナップショットをクリアし、認証情報の変更後に古い残量が再び現れるのを防ぎます。ネイティブ残量 / Coding Plan / サブスクリプション照会のタイムアウトは、応答の遅い国際 endpoint に合わせて 10s から 15s へ引き上げました。Coding Plan も空白の失敗ではなく、明確な「API key is empty」/「Unknown coding plan provider」エラーを返すようになりました。
|
||||||
|
|
||||||
|
### 使用量スクリプトのプロバイダー認証情報の解決
|
||||||
|
|
||||||
|
カスタム JS スクリプトの使用量照会は、以前 env フィールドを推測して `{{apiKey}}` / `{{baseUrl}}` を解決していたため、認証情報を別の場所に置くアプリ(Codex の `auth.OPENAI_API_KEY` に加え `config.toml` の base_url など)は、プロバイダーが完全に設定されていても常に空の値となり失敗していました。スクリプト照会とそのテスト / プレビューは、ネイティブ残量経路と同じアプリごとの認証情報リゾルバを再利用するようになり、スクリプト内で明示的に記入した非空の値は引き続き優先されます([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
|
||||||
|
|
||||||
|
### Claude Code Workflow サブ agent の使用量集計
|
||||||
|
|
||||||
|
ローカル(プロキシなし)のセッションログ使用量集計は、以前 Claude Code Workflow のサブ agent のトラフィックを取りこぼしており、全体の使用量が約 4.1% 過小評価されていました(workflow / subagent のセッションレコードに集中)。スキャナーはさらに一段深い `subagents/workflows/wf_*/` のレコードディレクトリまで掘り下げるようになり、パーサーも `stop_reason` を欠いているがすでに input / キャッシュ token のコストを生んだ assistant メッセージを捨てなくなりました。重複排除のロジックは変わらないため、重複計上はしません。
|
||||||
|
|
||||||
|
### Codex `/responses` のテキスト専用モデル向け画像整流
|
||||||
|
|
||||||
|
画像を伴い、テキストのみ対応の OpenAI-chat モデル(DeepSeek `deepseek-v4-flash` など)へルーティングされた Codex `/responses` リクエストが、HTTP 400「unknown variant `image_url`」で失敗しなくなりました。メディア整流器が Codex アダプターもカバーするようになり、responses の `input` 内の `input_image` ブロックをスキャンします。これにより、既知のテキスト専用モデルに対しては画像をプロアクティブに剥がし、上流が「画像非対応」を報告したときにも画像を置き換えて再試行できます。
|
||||||
|
|
||||||
|
### 智譜 Coding Plan のクォータウィンドウの誤ラベル
|
||||||
|
|
||||||
|
智譜 Coding Plan ビューは、各週周期の最後の数時間で 5 時間ウィンドウと週ウィンドウを取り違えてラベル付けしなくなりました。両ウィンドウは今や明示的な `unit` フィールドで分類され(3 = 5 時間、6 = 週)、リセット時刻の昇順ソートに頼らなくなりました——後者はユーザーが週クォータを最も照会するタイミングで、ちょうど両者を取り違えてラベル付けしていました。フィールドが欠けているときは引き続き従来のリセット時刻ヒューリスティックへフォールバックします([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
|
||||||
|
|
||||||
|
### macOS の重複プロバイダーターミナルウィンドウ
|
||||||
|
|
||||||
|
macOS でプロバイダーターミナルを起動するとき、コマンドセッションの隣に空のウィンドウをもう 1 つ開かなくなりました。Terminal.app はコールドスタート時に `activate` ではなく `launch` を使い、Ghostty は初期コマンドを使うことで、単一のセッションだけを開きます。AppleScript 経路が失敗したときのフォールバックも残してあります([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
|
||||||
|
|
||||||
|
### Claude Desktop モデルマッピングのプレースホルダー
|
||||||
|
|
||||||
|
Claude Desktop モデルマッピングフォームは、以前「メニュー表示名」と「リクエストモデル」の 2 列で一貫しないブランド例(DeepSeek vs Kimi)を使い、ある表示名が無関係なモデルへマッピングされるかのように見せていました。現在は両方のプレースホルダーが各行のロールから導かれ、ブランドの一貫性を保ち、軽量な Haiku 階層は flash の例を使います。
|
||||||
|
|
||||||
|
### ポップオーバーが全画面パネルに隠れる
|
||||||
|
|
||||||
|
プロバイダープリセット検索のようなポップオーバーやツールチップが、全画面パネルの後ろに描画されてクリックが効かないように見える問題がなくなりました。これらの z-index を全画面オーバーレイの上に引き上げつつ、モーダルダイアログよりは低く保ちました。
|
||||||
|
|
||||||
|
### ToggleRow アイコンの縮み
|
||||||
|
|
||||||
|
トグル行のアイコンは、長い説明と並んでも縮んだり歪んだりしなくなり、複数行のテキストの隣でアイコンが固定サイズを保ちます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ドキュメント
|
||||||
|
|
||||||
|
### Release Notes の貢献者謝辞の復元
|
||||||
|
|
||||||
|
v3.16.1 と v3.16.2 の release notes の貢献者謝辞を 3 言語で復元しました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アップグレード時の注意
|
||||||
|
|
||||||
|
### 価格ライブラリ schema v11 の自動移行
|
||||||
|
|
||||||
|
本リリースは `proxy_request_logs` に `pricing_model` 列を新設し、`request_model` + `pricing_model` で rollup を再構築しました。起動時に自動移行し、手動操作は不要です。過去の行のコストは書き込み時に確定済みで再計算されません(`app_type="claude"` の行はネイティブと変換の 2 種類のソースが混在しています)。本物だが当時課金されなかったテイクオーバー行のみがゼロコストのまま残り、価格が補われた後にバックフィルされます。
|
||||||
|
|
||||||
|
### モデルマッピングに 4 つ目の階層(Fable 5)を追加
|
||||||
|
|
||||||
|
Claude Code と Claude Desktop のモデルマッピングは 4 階層(Sonnet / Opus / Fable / Haiku)になりました。従来の 3 階層プロバイダーは、再度開いて保存すると `claude-fable-5` 階層が補われます。この階層を空のままにすると Sonnet を継承します。注意: サードパーティの endpoint でいずれかの階層を空のままにすると、その階層のモデル名がそのまま透過されて 404 になる可能性があるため、必要に応じて記入してください。
|
||||||
|
|
||||||
|
### 「Kimi For Coding」プリセットはプロキシテイクオーバー + ホワイトリスト UA が必要
|
||||||
|
|
||||||
|
復活した Codex「Kimi For Coding」プリセットは、既定の `codex-cli` User-Agent をそのまま使うと依然 403 になります。利用するには、プロキシテイクオーバーを有効にし、プロバイダーの高度なオプションでカスタム User-Agent をホワイトリストの UA(`claude-cli/*` など)に設定してください。
|
||||||
|
|
||||||
|
### プロバイダーヘルスチェックのセマンティクス変更
|
||||||
|
|
||||||
|
ヘルスチェックは「本物のモデルリクエストを送る」から「HTTP 到達性プローブ」へ変わりました。到達可能 ≠ 利用可能であることにご注意ください: 403 を返す host は到達可能でも、本物のトラフィックには壊れている可能性があります。フェイルオーバーの判定は引き続き本物のプロキシトラフィックのみで駆動され、ヘルスチェックの影響を受けません。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## リスク通知
|
||||||
|
|
||||||
|
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||||
|
|
||||||
|
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||||
|
|
||||||
|
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||||
|
|
||||||
|
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。
|
||||||
|
|
||||||
|
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 謝辞
|
||||||
|
|
||||||
|
v3.16.3 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||||
|
|
||||||
|
- [#3789](https://github.com/farion1231/cc-switch/pull/3789): テイクオーバー時に Codex OAuth 認証情報を保持、@codeasier に感謝。
|
||||||
|
- [#2774](https://github.com/farion1231/cc-switch/pull/2774): Completions を Anthropic へ変換する際に実際に返ったモデルを記録せず input token の計算が誤っていた問題を修正、@LaoYueHanNi に感謝。
|
||||||
|
- [#4069](https://github.com/farion1231/cc-switch/pull/4069): アプリ内更新後の再起動デッドロックを修正、@thisTom に感謝。
|
||||||
|
- [#4156](https://github.com/farion1231/cc-switch/pull/4156): macOS の重複プロバイダーターミナルウィンドウを修正、@thisTom に感謝。
|
||||||
|
- [#3267](https://github.com/farion1231/cc-switch/pull/3267): Hermes 設定の重複 YAML キーを修正、@que3sui に感謝。
|
||||||
|
- [#1479](https://github.com/farion1231/cc-switch/pull/1479): 使用量スクリプトのプロバイダー認証情報の解決を修正、@pa001024 に感謝。
|
||||||
|
- [#3975](https://github.com/farion1231/cc-switch/pull/3975): プリセットプロバイダーの検索とソートを追加、@Nastem に感謝。
|
||||||
|
- [#4183](https://github.com/farion1231/cc-switch/pull/4183): プリセットプロバイダーボタンの外観と検索ボックスの位置を調整、@WangJiati に感謝。
|
||||||
|
- [#4077](https://github.com/farion1231/cc-switch/pull/4077): claude-mythos-5 モデルの価格を追加、@osscv に感謝。
|
||||||
|
|
||||||
|
v3.16.2 リリース後に使用量の課金、ローカルプロキシの堅牢性、Codex のアップグレード、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くの修正は、実際の利用シーンから得られた再現情報に基づいています。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
| システム | 最低バージョン | アーキテクチャ |
|
||||||
|
| -------- | ------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 以降 | x64 |
|
||||||
|
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 下表を参照 | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| ---------------------------------------- | -------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.3-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||||
|
| `CC-Switch-v3.16.3-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| -------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||||
|
|
||||||
|
Homebrew インストール:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||||
|
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||||
|
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
# CC Switch v3.16.3
|
||||||
|
|
||||||
|
> 🎉 **CC Switch 突破 100,000 Star!**
|
||||||
|
> 感谢每一位用户、贡献者与 Star —— 是你们让它走到这里。🙏
|
||||||
|
|
||||||
|
> 💎 **本版由 Claude Fable 5 模型协助开发**——它帮忙梳理清楚了多处关键且容易出错的逻辑:路由接管时按真实上游模型计费的归因链、格式转换路径上缓存 token 的计量与去重、应用内更新的重启死锁,以及 Codex 统一会话历史的迁移 / 还原不变量。这也是本版在「关于」页新增 **Fable 5 Verified** 标识的由来。
|
||||||
|
|
||||||
|
> 在 v3.16.2 拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」——按真实上游模型计费、修正格式转换路径上的缓存双算、把 Claude Code Workflow 子 agent 的用量纳入统计(schema v11),并对用量看板做了一轮改版(全局供应商 / 模型筛选、品牌图标工具栏、更稳的额度查询);同时加固了一批本地代理与平台问题,新增自定义 User-Agent 覆盖、Codex 统一会话历史开关与 Claude Fable 5 档位。
|
||||||
|
|
||||||
|
**[English →](v3.16.3-en.md) | [日本語版 →](v3.16.3-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用攻略
|
||||||
|
|
||||||
|
本版新增了 **Codex 统一会话历史** 开关——它涉及会话的迁移 / 还原,操作不当时容易让人误以为"会话丢了",强烈建议先读这篇攻略;用量统计的口径和看板这一版也做了较多调整,一并附上:
|
||||||
|
|
||||||
|
- **[Codex 统一会话历史:功能介绍与使用攻略](../guides/codex-unified-session-history-guide-zh.md)**:讲清"统一 / 迁移 / 还原"到底改了什么、为什么数据不会真正丢失,以及看不到会话时如何自查与精确还原。**用过这个开关、或担心会话丢失,请务必先读。**
|
||||||
|
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源(代理日志、会话同步)与统计口径,本版新增了全局的供应商 / 模型筛选,并把路由接管的真实计价模型展示了出来。
|
||||||
|
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:自定义 User-Agent 覆盖、Codex 统一会话历史等开关都在供应商表单的高级选项与设置页里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## 唯一官方渠道声明(请务必阅读)
|
||||||
|
>
|
||||||
|
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||||
|
>
|
||||||
|
> | 类别 | 唯一官方 |
|
||||||
|
> | -------- | ------------------------------------------------------------------------------ |
|
||||||
|
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.16.3 是 v3.16.2 之后的一版维护更新。在上一版集中拓宽数据可携带性与用量观测之后,这一版把重心放在「让用量计费真正准确」这件事上——按真实上游模型计费而非上游回显、修正格式转换(Chat / Responses / Gemini 转 Anthropic)路径上的缓存 token 双算、把 Claude Code Workflow 子 agent 的用量纳入本地统计,并以 schema v11 持久化每条记录实际使用的定价依据;用量看板也随之做了一轮改版,新增全局的供应商 / 模型筛选、品牌图标工具栏,以及更稳的额度查询(失败重试 + 保留上次成功结果)。
|
||||||
|
|
||||||
|
此外,本版还加固了一批本地代理的稳健性问题(错标 Content-Type 的 SSE 响应聚合、Codex `/responses` 文本模型图像整流、Codex OAuth 凭据与接管残留的恢复、Hermes 配置重复 YAML 键),重做了供应商配置体验(自定义 User-Agent 覆盖、Codex 表单统一进高级选项、预设搜索与排序、Claude Fable 5 档位),新增 Codex 统一会话历史开关,并修复了应用内更新卡死、Codex 升级损坏安装、macOS 重复终端窗口等问题。
|
||||||
|
|
||||||
|
**发布日期**:2026-06-14
|
||||||
|
|
||||||
|
**更新规模**:59 commits | 130 files changed | +10,223 / -4,232 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点内容
|
||||||
|
|
||||||
|
- **用量计费更准**:路由接管的流量现在按真实上游模型计费(而非上游回显的别名),格式转换路径不再把缓存 token 重复计入 input,Claude Code Workflow 子 agent 的用量也纳入了统计——以 schema v11 持久化定价依据。
|
||||||
|
- **用量看板改版**:供应商 / 模型筛选从请求日志表提升为全局筛选,应用筛选改用品牌图标,额度查询加入失败重试与「保留上次成功结果」,单次网络抖动不再让卡片变红。
|
||||||
|
- **自定义 User-Agent 覆盖**:供应商可设置自定义 UA,并在转发、连通性检测、模型列表三处一致生效,绕过按 UA 白名单放行的 Coding Plan 上游(借此恢复了 Codex「Kimi For Coding」预设)。
|
||||||
|
- **Codex 统一会话历史**:新增可选开关,让官方 Codex 会话与第三方会话共享同一份 resume 历史桶,附带可选的存量迁移与按账本精确还原。
|
||||||
|
- **代理与平台加固**:错标 SSE 响应聚合、Codex 图像整流、接管残留恢复、Hermes YAML 去重;应用内更新不再卡在「重启中」,Codex 升级不再把安装弄坏。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新功能
|
||||||
|
|
||||||
|
### 自定义 User-Agent 覆盖
|
||||||
|
|
||||||
|
供应商配置现在可以设置自定义 User-Agent,并由代理在请求转发、连通性检测和模型列表(`GET /v1/models`)三条路径上一致应用,因此按 UA 白名单放行的 Coding Plan 上游不会再出现「检测失败 / 模型列表 403、但代理本身却能正常工作」的不一致。Claude 和 Codex 表单都在高级选项里暴露该字段,配有精选的 UA 预设下拉(Claude Code / Kilo Code 等能通过 UA 白名单的家族)和实时、非阻塞的格式校验;切换到官方预设时会丢弃残留的自定义 UA,避免悄悄改动请求头([#3671](https://github.com/farion1231/cc-switch/pull/3671))。
|
||||||
|
|
||||||
|
### Codex 统一会话历史
|
||||||
|
|
||||||
|
新增一个可选开关(设置 → Codex 应用增强),让官方 Codex 会话与 CC Switch 的第三方会话共享同一份 resume 历史桶,resume 选择器不再把两者互相隐藏。开启后,live 的 `config.toml` 会把官方运行路由到一个镜像内建 OpenAI 供应商的共享 `custom` model_provider(`auth.json` 不动)。默认只对未来会话生效;开启弹窗提供一个勾选项,可把已有官方会话迁入共享桶(含逐代备份),关闭弹窗则提供按备份账本精确还原——只回退备份中记录为 `openai` 的会话,开启期间新建的会话永不被改动。
|
||||||
|
|
||||||
|
### 用量看板全局供应商 / 模型筛选
|
||||||
|
|
||||||
|
供应商和模型筛选从请求日志表内部提升到了顶栏,对 Hero 汇总、趋势图、请求日志和两个统计页签全局生效,可以把整个看板按某个来源和模型缩小范围。来源按展示名精确匹配(因此像「Claude (Session)」这样的会话占位行也可选),模型按有效计价模型匹配,模型下拉会随所选来源级联,且两个列表只列出当前时间范围内有数据的选项。
|
||||||
|
|
||||||
|
### 模型定价种子刷新
|
||||||
|
|
||||||
|
对 `seed_model_pricing` 做了一次全量核价:新增 9 个模型的定价(含 Claude Fable 5、Grok 4.3、Mistral Medium 3.5 / Small 4、Qwen 3.7 Max/Plus 等),并按各厂商官方 list 价订正了 28 处既有价格(GLM、Grok、MiMo、Doubao、Kimi、MiniMax、Mistral、Qwen),让用量成本估算更准确。每处改动都同时更新种子(影响全新安装)并向 `repair_current_model_pricing` 加一条旧→新守卫(修复存量数据库,且不覆盖用户手改过的行)。
|
||||||
|
|
||||||
|
### Claude Fable 5 模型档位
|
||||||
|
|
||||||
|
供应商表单现在在 Claude Code 和 Claude Desktop 两条代理路径上都暴露 `claude-fable-5` 作为第四个模型映射档位,回落链为 fable → opus → default,与官方降级一致,并为 Claude Desktop 1.12603.1+ 的校验器放行了 `fable-` 前缀。四语回落提示也做了澄清:在第三方端点上把某一档留空,会原样透传该档的字面模型名并 404([#3980](https://github.com/farion1231/cc-switch/issues/3980)、[#4026](https://github.com/farion1231/cc-switch/issues/4026)、[#4049](https://github.com/farion1231/cc-switch/issues/4049))。
|
||||||
|
|
||||||
|
### Unity2.ai 合作伙伴供应商
|
||||||
|
|
||||||
|
新增 Unity2.ai(一个 AI API 中转合作伙伴)作为预设,覆盖全部 7 个受管应用(Claude Code、Codex、Gemini、OpenCode、OpenClaw、Claude Desktop、Hermes),每个预设都带上推广注册链接,并在四种语言里补充了合作伙伴推广文案。Codex 使用裸 base URL(该网关在根路径暴露 `/responses`),OpenCode / OpenClaw / Hermes 使用 `/v1` chat-completions 端点并以 `gpt-5.5` 为预设模型。
|
||||||
|
|
||||||
|
### Kimi K2.7 Code 模型
|
||||||
|
|
||||||
|
新增 `kimi-k2.7-code` 模型(输入 $0.95 / 输出 $4.00 / 缓存读取 $0.19,每百万 token,256K 上下文),并把全部 6 个官方 Moonshot Kimi 预设(Claude Code、Codex、Claude Desktop、Hermes、OpenCode、OpenClaw)指向它,OpenCode / OpenClaw 预设更名为「Kimi K2.7 Code」。定价种子通过启动时的幂等插入路径生效,存量用户无需迁移即可获得新价。
|
||||||
|
|
||||||
|
### 恢复 Codex「Kimi For Coding」预设
|
||||||
|
|
||||||
|
重新加入 Codex「Kimi For Coding」预设(`openai_chat`、`kimi-for-coding`、256K 上下文),默认开启思考模式。此前它被移除是因为该编程端点会以 403 拒绝 Codex 默认的 `codex-cli` User-Agent;现在借助代理接管 + 自定义 User-Agent 覆盖(设为 `claude-cli/*` 等白名单 UA)即可正常使用。
|
||||||
|
|
||||||
|
### 请求详情的计价模型审计
|
||||||
|
|
||||||
|
请求详情面板现在会在「请求的模型」「计价模型」与响应模型不一致时把它们都显示出来,让路由接管产生的账单可以直接在用量界面里核对。
|
||||||
|
|
||||||
|
### 预设供应商搜索与排序
|
||||||
|
|
||||||
|
预设供应商选择器现在是一个可搜索、可排序的列表,配有内联搜索框(点放大镜图标切换,按 ESC 或点击外部收起)。按钮改为响应式网格、尺寸统一并显示默认图标,搜索只匹配供应商的展示名 / 原始名,因此 URL 片段和共享的分类标签不会再产生噪声匹配([#3975](https://github.com/farion1231/cc-switch/pull/3975)、[#4183](https://github.com/farion1231/cc-switch/pull/4183))。
|
||||||
|
|
||||||
|
### Claude Mythos 5 定价
|
||||||
|
|
||||||
|
在内置模型 / 定价表里登记 `claude-mythos-5` 模型(输入 $10 / 输出 $50,每百万 token;缓存读取 $1.00、缓存写入 $12.50),让用量统计能正确计价并展示([#4077](https://github.com/farion1231/cc-switch/pull/4077))。
|
||||||
|
|
||||||
|
### Fable 5 Verified 标识
|
||||||
|
|
||||||
|
设置「关于」页现在会在应用名与版本旁展示 Fable 5 Verified 标识,标明这是一个特别构建,版本徽标也居中到了应用名下方。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
### Claude Desktop 用量折叠进 Claude
|
||||||
|
|
||||||
|
看板不再展示独立的「Claude Desktop」分桶——它一直只能显示一个不完整的数字(Desktop 聊天用量根本不经过代理,而其 Code 页签的会话只是内嵌的 Claude Code 运行时写进共享的 `~/.claude/projects` 目录)。Desktop 的代理流量现在在展示上折叠进 `claude`,但记账层仍按它自己的 `app_type` 记录以便路由接管计费审计,真实值可在请求详情面板看到。
|
||||||
|
|
||||||
|
### 轻量化供应商健康检查
|
||||||
|
|
||||||
|
供应商健康检查不再发送真实的流式模型请求(很多第三方供应商会以 401/403/WAF 拦截,造成误报不可用),改为对供应商 `base_url` 做一次轻量的 HTTP 可达性探测:任何 HTTP 响应都视为可达,只有 DNS / 连接 / TLS / 超时才算失败。官方供应商(使用 OAuth、base_url 故意为空、没有可靠的可达性目标)会隐藏连通性按钮,原先「发送真实请求」的确认弹窗以及测试模型 / 提示词字段都被移除,降级延迟阈值设为 6s、超时 8s。该可达性检查永不重置熔断器——可达不等于可用(403 的 host 可达,但对真实流量是坏的),失败转移仍只由真实代理流量驱动。
|
||||||
|
|
||||||
|
### Codex 高级选项区整合
|
||||||
|
|
||||||
|
Codex 供应商表单现在把本地路由、模型映射、推理覆盖和自定义 User-Agent 折叠进一个可展开的高级选项区,与 Claude 表单一致(设置了 UA 或开启本地路由时自动展开)。自定义 User-Agent 现在对原生 Responses 供应商也可配置,此前它只有在开启 `openai_chat` 路由时才能触及。
|
||||||
|
|
||||||
|
### 用量工具栏与布局刷新
|
||||||
|
|
||||||
|
应用筛选改用品牌图标(经 ProviderIcon,「全部」用网格图标)渲染,取代在窄窗口下换行难看的文字页签;用量 Hero 也会显示所选应用的品牌图标,并把 Codex 的主题色从翠绿改为中性灰,贴合 OpenAI 的单色品牌。点击循环切换的刷新按钮改成了带本地化「关闭」标签的下拉选择,顶栏控件也压缩并对齐成统一的宽度分组,过长的日期范围标签做了截断处理。
|
||||||
|
|
||||||
|
### 关于面板加载更快
|
||||||
|
|
||||||
|
设置「关于」面板现在渐进式加载:应用版本徽标在解析完成的瞬间就显示,不再等待工具探测;每张工具卡片在自己的版本检测完成时立即更新(探测并发执行而非串行);探测结果在应用会话期内缓存并带 10 分钟 TTL,因此再次打开「关于」页签会复用缓存值、并在后台对过期项重新校验,而不是每次都把 6 个工具全部重探一遍。
|
||||||
|
|
||||||
|
### 火山方舟 Coding Plan 推广更新
|
||||||
|
|
||||||
|
把火山方舟(Volcengine Ark)预设在全部 6 个应用里更新到新的 Coding Plan 邀请链接(替换旧的 Agent Plan / 活动链接),并在四种语言里刷新了合作伙伴推广文案(两个月 75% 折扣 + 邀请码 6J6FV5N2),把产品名从 Agent Plan 订正为 Coding Plan。
|
||||||
|
|
||||||
|
### MiniMax 降为普通供应商
|
||||||
|
|
||||||
|
移除 MiniMax 的金色合作伙伴星标和 API key 推广横幅(从所有预设里删掉 `isPartner` 标志),它继续作为常规 `cn_official` 供应商保留图标与主题。推广文案保持休眠状态,必要时一行即可重新启用合作关系。
|
||||||
|
|
||||||
|
### 移除 LemonData、SudoCode 降级
|
||||||
|
|
||||||
|
彻底移除 LemonData 供应商预设(连同其推广文案、图标和赞助商条目),并把 SudoCode 从合作伙伴降为常规 `third_party` 供应商(去掉 `isPartner` 标志和推广文案,保留图标)。
|
||||||
|
|
||||||
|
### AtlasCloud Codex GLM 5.1 上下文窗口
|
||||||
|
|
||||||
|
为 AtlasCloud Codex 预设里的 `zai-org/glm-5.1` 模型声明 200,000 token 的上下文窗口,与其他 GLM 5.1 预设条目对齐。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
### 路由接管流量按真实上游模型计费
|
||||||
|
|
||||||
|
当请求被路由到了不同的上游(env 模型映射、Claude Desktop 路由、Copilot 归一化、Codex chat 覆盖)时,代理过去会按上游回显的模型来归因和计价,把 kimi / glm 的 token 记成、并按 `claude-*` 计价,成本被高估约 5–25 倍。现在转发器会捕获真实的出站模型,按「上游回显 → 出站模型 → 客户端别名」的顺序归因,并在每行持久化实际使用的定价依据(schema v11),该依据会贯穿成本回填和 30 天 rollup 裁剪;Claude Desktop 流量现在也记在它自己的 `app_type` 下,使其定价覆盖能正确生效。
|
||||||
|
|
||||||
|
### 格式转换路径的用量计量
|
||||||
|
|
||||||
|
审计并修复了代理各条格式转换路径(Chat、Responses、Gemini 转 Anthropic)上的 token / 缓存计量。代理现在会记录实际返回的模型,注入 `stream_options.include_usage` 让 OpenAI 兼容上游在流式时吐出 usage,在 Claude←OpenAI 路径上把 `cache_read` 和 `cache_creation` 从 input 中排除以阻止缓存 token 双计费,扣减 Gemini 的缓存提示 token,仍记录完全命中缓存的请求,并跳过过去会虚增请求数的合成全零 usage([#2774](https://github.com/farion1231/cc-switch/pull/2774))。
|
||||||
|
|
||||||
|
### 应用内更新不再卡死
|
||||||
|
|
||||||
|
从应用内安装更新时不再卡在「重启中」界面——过去会出现新版已装好、却必须手动强制退出的情况。下载—安装—重启整条链路现在完全在后端执行(新增 `install_update_and_restart` 命令),按平台决定安装顺序,并在重新执行前先销毁单实例锁,而不再依赖旧 WebView 在应用包已被替换之后继续跑 JS;退出请求也做了分类,让重启请求落到 Tauri 默认流程,而不是在窗口状态插件的互斥锁上死锁([#4069](https://github.com/farion1231/cc-switch/pull/4069)、[#4074](https://github.com/farion1231/cc-switch/pull/4074))。
|
||||||
|
|
||||||
|
### Codex 升级不再损坏安装
|
||||||
|
|
||||||
|
从设置「关于」页升级 Codex 不再让它抛出「Missing optional dependency @openai/codex-…」错误。升级链此前会先跑 `codex update`,而它在 npm 安装下其实是一次裸的重装、即便对应平台的二进制没装上也会报告成功;现在 Codex 已从「优先 self-update」路径里移除,并由一个 runnable 检测触发「卸载 + 重装」自愈(仅限 npm 管理的安装),这是唯一能真正补回缺失平台二进制的修复。
|
||||||
|
|
||||||
|
### 接管时保留 Codex OAuth 凭据
|
||||||
|
|
||||||
|
为 Codex 供应商开启代理接管时不再剥掉 `ANTHROPIC_AUTH_TOKEN` 占位符——此前这会在热切换、全新安装、以及被旧版本已剥过的 live 配置上破坏 Claude Code 的登录。现在对受管(非 Copilot)的 Codex 供应商无条件注入该占位符,包括只有 URL 的供应商;GitHub Copilot 的行为(仅 API_KEY)不变([#3789](https://github.com/farion1231/cc-switch/pull/3789)、[#3784](https://github.com/farion1231/cc-switch/issues/3784))。
|
||||||
|
|
||||||
|
### 跨配置目录切换的接管残留恢复
|
||||||
|
|
||||||
|
在代理接管激活时更改配置目录后重启应用,不再把 Claude / Codex / Gemini 留在指向已失效的本地代理上。现在旧实例会在重启前先还原被接管的 live 文件,首次运行的导入会拒绝把接管占位符当作供应商持久化,SSOT 还原也会在写回前校验当前供应商的配置里不含占位符([#4076](https://github.com/farion1231/cc-switch/pull/4076))。
|
||||||
|
|
||||||
|
### 格式转换兜底里错标的 SSE 响应聚合
|
||||||
|
|
||||||
|
经 Claude / Codex 格式转换的请求,当 MaaS 网关把一个 `stream:false` 的请求强制流式、并以非 SSE 的 Content-Type 返回 SSE 响应体时,不再以一句晦涩的 422「Failed to parse upstream response」失败。代理现在会在解析失败时嗅探 SSE、把分片聚合成单个 JSON 再跑既有转换器,让客户端仍能拿到有效的非流式响应;剩余的解析失败会附带 content-type、编码和响应体片段等诊断信息,deflate 解码也改为先尝试 zlib 再尝试裸流([#2234](https://github.com/farion1231/cc-switch/pull/2234))。
|
||||||
|
|
||||||
|
### Hermes 配置重复 YAML 键
|
||||||
|
|
||||||
|
Hermes 配置写入不再累积重复的顶层键(如 `mcp_servers`),那会导致「Failed to parse Hermes config as YAML: duplicate entry with key」错误。区段替换现在会从剩余文本里清除所有过期副本,而不是退化成追加;去重保护层同时处理 LF 和 CRLF 行尾;修复时保留最后(最新)的那份副本,与 Hermes 自身基于 PyYAML 的「后者胜」语义一致([#3267](https://github.com/farion1231/cc-switch/pull/3267)、[#3633](https://github.com/farion1231/cc-switch/issues/3633)、[#2973](https://github.com/farion1231/cc-switch/issues/2973)、[#2529](https://github.com/farion1231/cc-switch/issues/2529)、[#3310](https://github.com/farion1231/cc-switch/issues/3310)、[#3762](https://github.com/farion1231/cc-switch/issues/3762))。
|
||||||
|
|
||||||
|
### 用量查询韧性与错误清晰度
|
||||||
|
|
||||||
|
用量卡片不再因为单次瞬时抖动就变红:查询现在会重试一次,并在网络 / 超时 / 5xx 这类瞬时失败下继续展示上次成功的结果最多 10 分钟;而确定性失败(鉴权、空 key、未知供应商、4xx)会立即暴露并清空快照,避免凭据变更后陈旧额度又冒出来。原生余额 / Coding Plan / 订阅查询的超时从 10s 提高到 15s 以适配跨境慢端点,Coding Plan 也会返回明确的「API key is empty」/「Unknown coding plan provider」错误,而不是一句空白的失败。
|
||||||
|
|
||||||
|
### 用量脚本供应商凭据解析
|
||||||
|
|
||||||
|
自定义 JS 脚本的用量查询此前只靠猜测 env 字段来解析 `{{apiKey}}` / `{{baseUrl}}`,因此凭据存放在别处的应用(如 Codex 的 `auth.OPENAI_API_KEY` 加 `config.toml` 里的 base_url)总是拿到空值、即便供应商已完整配置也会失败。脚本查询及其测试 / 预览现在复用与原生余额路径相同的按应用凭据解析器,脚本里显式填写的非空值仍然优先([#1479](https://github.com/farion1231/cc-switch/pull/1479))。
|
||||||
|
|
||||||
|
### Claude Code Workflow 子 agent 用量统计
|
||||||
|
|
||||||
|
本地(无代理)的会话日志用量统计此前漏掉了 Claude Code Workflow 子 agent 的流量,整体用量被低估约 4.1%(集中在 workflow / subagent 的会话记录里)。扫描器现在会深入更深一层的 `subagents/workflows/wf_*/` 记录目录,解析器也不再丢弃那些缺少 `stop_reason`、但已经产生 input / 缓存 token 成本的 assistant 消息;去重逻辑不变,因此不会重复计数。
|
||||||
|
|
||||||
|
### Codex `/responses` 文本模型图像整流
|
||||||
|
|
||||||
|
携带图片、且被路由到只支持文本的 OpenAI-chat 模型(如 DeepSeek `deepseek-v4-flash`)的 Codex `/responses` 请求,不再以 HTTP 400「unknown variant `image_url`」失败。媒体整流器现在也覆盖 Codex 适配器,会扫描 responses 的 `input` 里的 `input_image` 块,从而既能为已知的纯文本模型主动剥掉图片,也能在上游报「不支持图片」时把图片替换后重试。
|
||||||
|
|
||||||
|
### 智谱 Coding Plan 配额窗口误标
|
||||||
|
|
||||||
|
智谱 Coding Plan 视图不再在每个周周期的最后几个小时把 5 小时窗口和周窗口标反。两个窗口现在按显式的 `unit` 字段分类(3 = 5 小时、6 = 周),而不再靠按重置时间升序排序——后者恰好在用户最常查周额度的时候把两者标反;当字段缺失时仍回退到旧的重置时间启发式([#3036](https://github.com/farion1231/cc-switch/pull/3036))。
|
||||||
|
|
||||||
|
### macOS 重复供应商终端窗口
|
||||||
|
|
||||||
|
在 macOS 上启动供应商终端时不再在命令会话旁多开一个空窗口;Terminal.app 在冷启动时改用 `launch`(而非 `activate`),Ghostty 使用初始命令,从而只打开单个会话,并在 AppleScript 路径失败时保留回退方案([#4156](https://github.com/farion1231/cc-switch/pull/4156))。
|
||||||
|
|
||||||
|
### Claude Desktop 模型映射占位符
|
||||||
|
|
||||||
|
Claude Desktop 模型映射表单此前在「菜单展示名」和「请求模型」两列用了不一致的示例品牌(DeepSeek vs Kimi),暗示一个展示名会映射到不相关的模型。现在两个占位符都由每行的角色派生,从而保持品牌一致,轻量的 Haiku 档使用 flash 示例。
|
||||||
|
|
||||||
|
### 弹层被全屏面板遮挡
|
||||||
|
|
||||||
|
像供应商预设搜索这样的弹层和提示气泡不再渲染到全屏面板后面、看起来点了没反应;它们的 z-index 被提到全屏遮罩之上,同时仍低于模态对话框。
|
||||||
|
|
||||||
|
### ToggleRow 图标被挤压
|
||||||
|
|
||||||
|
开关行的图标在配上长描述时不再被压缩或变形,让图标在多行文字旁保持固定大小。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
### Release Notes 贡献者致谢恢复
|
||||||
|
|
||||||
|
恢复了 v3.16.1 与 v3.16.2 release notes 在三种语言里的贡献者致谢。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级提醒
|
||||||
|
|
||||||
|
### 定价库 schema v11 自动迁移
|
||||||
|
|
||||||
|
本版给 `proxy_request_logs` 新增了 `pricing_model` 列、并按 `request_model` + `pricing_model` 重建了 rollup,启动时自动迁移、无需手动操作。历史行的成本在写入时已冻结、不会重算(`app_type="claude"` 的行混合了原生与转换两类来源);只有真实但当时未计价的接管行会保持零成本、待定价补齐后再回填。
|
||||||
|
|
||||||
|
### 模型映射新增第四档(Fable 5)
|
||||||
|
|
||||||
|
Claude Code 与 Claude Desktop 的模型映射现在是四档(Sonnet / Opus / Fable / Haiku)。老的三档供应商在重新打开并保存后会补上 `claude-fable-5` 档;该档留空表示继承 Sonnet。注意:在第三方端点上把任意一档留空,会原样透传该档的字面模型名并可能 404,请按需填写。
|
||||||
|
|
||||||
|
### 「Kimi For Coding」预设需要代理接管 + 白名单 UA
|
||||||
|
|
||||||
|
恢复的 Codex「Kimi For Coding」预设直接用默认的 `codex-cli` User-Agent 仍会被 403。要使用它,请开启代理接管,并在供应商高级选项里把自定义 User-Agent 设为白名单 UA(如 `claude-cli/*`)。
|
||||||
|
|
||||||
|
### 供应商健康检查语义变化
|
||||||
|
|
||||||
|
健康检查从「发送真实模型请求」改为「HTTP 可达性探测」。请注意可达 ≠ 可用:一个返回 403 的 host 是可达的,但对真实流量可能是坏的。失败转移的判定仍只由真实代理流量驱动,不受健康检查影响。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||||
|
|
||||||
|
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||||
|
|
||||||
|
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||||
|
|
||||||
|
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||||
|
|
||||||
|
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
感谢以下贡献者在 v3.16.3 中提交的功能与修复:
|
||||||
|
|
||||||
|
- [#3789](https://github.com/farion1231/cc-switch/pull/3789):接管时保留 Codex OAuth 凭据,感谢 @codeasier。
|
||||||
|
- [#2774](https://github.com/farion1231/cc-switch/pull/2774):修复 Completions 转 Anthropic 时不记录实际返回模型、input token 计算错误,感谢 @LaoYueHanNi。
|
||||||
|
- [#4069](https://github.com/farion1231/cc-switch/pull/4069):修复应用内更新后重启死锁,感谢 @thisTom。
|
||||||
|
- [#4156](https://github.com/farion1231/cc-switch/pull/4156):修复 macOS 重复供应商终端窗口,感谢 @thisTom。
|
||||||
|
- [#3267](https://github.com/farion1231/cc-switch/pull/3267):修复 Hermes 配置重复 YAML 键,感谢 @que3sui。
|
||||||
|
- [#1479](https://github.com/farion1231/cc-switch/pull/1479):修复用量脚本供应商凭据解析,感谢 @pa001024。
|
||||||
|
- [#3975](https://github.com/farion1231/cc-switch/pull/3975):新增预设供应商搜索与排序,感谢 @Nastem。
|
||||||
|
- [#4183](https://github.com/farion1231/cc-switch/pull/4183):调整预设供应商按钮外观与搜索框位置,感谢 @WangJiati。
|
||||||
|
- [#4077](https://github.com/farion1231/cc-switch/pull/4077):新增 claude-mythos-5 模型定价,感谢 @osscv。
|
||||||
|
|
||||||
|
也感谢所有在 v3.16.2 发布后反馈用量计费、本地代理稳健性、Codex 升级与平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 系统 | 最低版本 | 架构 |
|
||||||
|
| ------- | -------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 及以上 | x64 |
|
||||||
|
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 见下表 | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||||
|
| `CC-Switch-v3.16.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| -------------------------------- | --------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||||
|
|
||||||
|
Homebrew 安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.3-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.3-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| 发行版 | 推荐格式 | 安装方式 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||||
|
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# CC Switch v3.16.4
|
||||||
|
|
||||||
|
> After v3.16.3 made usage billing accurate, this release shifts the focus to polishing the Codex proxy chain and enriching the usage / pricing tooling — migrating Chinese providers to native Responses, decoupling the upstream-format selector from model mapping, decompressing zstd request / error bodies, and a batch of tool-call and OAuth-through-proxy fixes — while also adding local proxy request overrides, an in-app recovery screen when the database version is too new, native Windows ARM64 builds, and a wave of preset and branding updates (SubRouter, OpenCode Go, the CTok→ETok rename, the Kimi brand refresh, and a prime-partner badge).
|
||||||
|
|
||||||
|
**[中文版 →](v3.16.4-zh.md) | [日本語版 →](v3.16.4-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage Guides
|
||||||
|
|
||||||
|
This release is mostly polish and expansion, with the new capabilities landing mainly in the usage dashboard and the provider form's advanced options. The following docs are worth reading alongside it:
|
||||||
|
|
||||||
|
- **[Can't see custom models in the Codex desktop app?](../guides/codex-desktop-custom-model-visibility-en.md)**: many users report that their configured third-party / custom models do not show up in the Codex desktop app's model picker. This is the Codex desktop app's **own upstream gating behavior** (it gates the model picker by official login state), not a CC Switch local-config problem, and **this release (v3.16.4) does not change it**. The doc explains the cause and the available mitigation (keep official login + route takeover).
|
||||||
|
- **[Usage Statistics](../user-manual/en/4-proxy/4.4-usage.md)**: understand the Usage Dashboard's data sources and how the statistics are counted. This release adds bulk import of model pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, and a live end time for custom date ranges.
|
||||||
|
- **[Settings](../user-manual/en/1-getting-started/1.5-settings.md)**: local proxy request overrides (custom headers / request body), the Codex upstream-format selector, the local routing toggle, and more live in the provider form's advanced options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## Only Official Channels (Please Read)
|
||||||
|
>
|
||||||
|
> CC Switch is a **fully free and open-source** desktop app, and we **do not charge users any fees**. Please only obtain the software through the official channels listed below:
|
||||||
|
>
|
||||||
|
> | Channel | Only Official |
|
||||||
|
> | ------------------ | ------------------------------------------------------------------------------ |
|
||||||
|
> | Website | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | Source | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | Downloads | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | Author | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | Report an Imposter | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **Any "CC Switch" website or client that asks you for payment, top-ups, or login credentials is fake.** If you have been tricked into paying, stop the transaction immediately and file a report through GitHub Issues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
CC Switch v3.16.4 is a maintenance update following v3.16.3. This release tightens the Codex proxy chain — switching several Chinese providers that have native OpenAI Responses endpoints to the native format (dropping the Responses→Chat route-takeover conversion), promoting "upstream format" out of the "local routing" toggle into its own control, adding decompression for zstd request and error response bodies, and fixing a string of tool-call and "OAuth module bypassing the global proxy" issues.
|
||||||
|
|
||||||
|
Alongside that, this release enriches the usage and pricing tooling (import pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, a live end time for custom date ranges, GLM-5.2 and Doubao Seed 2.1 pricing), adds a batch of proxy and resilience capabilities (custom header / request-body overrides, an in-app recovery screen when the database version is too new, native Windows ARM64 builds), and brings a wave of preset and branding updates (SubRouter and OpenCode Go subscriptions, the CTok→ETok rename, the Kimi brand refresh and prime-partner badge, and a Kimi K2.7 Code sponsor banner).
|
||||||
|
|
||||||
|
**Release date**: 2026-06-27
|
||||||
|
|
||||||
|
**Stats**: 53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- **Native Responses for Chinese Codex providers**: Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, and MiniMax (domestic / international) now connect directly to their native Responses endpoints instead of going through the Responses→Chat format-conversion takeover, for a shorter and more stable chain.
|
||||||
|
- **Local proxy request overrides**: providers can configure custom header and request-body overrides, applied by the local proxy when forwarding, with interception validation that blocks protected security headers.
|
||||||
|
- **In-app recovery screen for a too-new database**: when the SQLite version is newer than the current app supports, you no longer get stuck in a native dialog where "retry just fails again"; instead you are guided to a recovery screen that can upgrade the app in one click.
|
||||||
|
- **Richer usage / pricing tooling**: bulk import of model pricing from models.dev, AK/SK usage queries for Volcengine Ark Coding / Agent Plan, a live end time for custom date ranges, and pricing for GLM-5.2 and Doubao Seed 2.1.
|
||||||
|
- **New presets and branding updates**: added SubRouter and OpenCode Go subscription presets, renamed CTok to ETok, refreshed the Kimi brand mark, and added a prime-partner heart badge to the official Kimi presets.
|
||||||
|
- **Native Windows ARM64 builds**: release artifacts now include native ARM64 builds, so ARM Windows devices no longer depend on x64 emulation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
### In-App Recovery Screen for a Too-New Database
|
||||||
|
|
||||||
|
When the SQLite `user_version` is newer than the current app's supported `SCHEMA_VERSION` (for example after downgrading to an older release, or because a third-party client wrote the file), startup used to die in a native "retry / quit" dialog — where "retry" just fails again. The app now routes to a dedicated recovery screen: when an update is available it offers a one-click "Upgrade App" button (download + install + restart, with a progress bar), and when none is available it explains that even the latest version cannot read this database. The "too new" check runs before any write to the database, so the app never runs DDL against a database it cannot understand; a native close in recovery mode exits cleanly (the tray has not been created yet). ([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||||
|
|
||||||
|
### Local Proxy Request Overrides (Custom Headers and Request Body)
|
||||||
|
|
||||||
|
Provider configs can now define custom header and request-body overrides that the local proxy applies when forwarding, exposed via new fields in the Claude and Codex provider forms. Input is validated against a protected-header list that blocks overriding security-sensitive headers. ([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||||
|
|
||||||
|
### Volcengine Ark Coding / Agent Plan Usage Queries
|
||||||
|
|
||||||
|
The usage panel can now query Volcengine Ark's Coding Plan and Agent Plan quotas. Because the Ark control-plane OpenAPI (`open.volcengineapi.com`) requires an account-level AccessKey signature rather than an inference API key, the usage script gains a dedicated AK/SK input area with a clickable link straight to the Volcengine IAM key-management console (`https://console.volcengine.com/iam/keymanage`); the proxy implements Volcengine Signature V4 (an AWS SigV4 variant: a fixed canonical-header order, the `HMAC-SHA256` algorithm, and the `ark` service scope). It first probes `GetAFPUsage` (the Agent Plan's 5-hour / weekly / monthly quotas) to auto-detect the plan and falls back to `GetCodingPlanUsage`, parsing the window label from the `Level` field (with a guard for `ResetTimestamp <= 0`), and adds the `monthly` tier label across the usage footer, the tray menu, and all four locales.
|
||||||
|
|
||||||
|
### Import Model Pricing from models.dev
|
||||||
|
|
||||||
|
The "Add Pricing" panel gains an "Import from models.dev" button: it fetches `https://models.dev/api.json`, supports full-text search across the entire catalog, and imports the selected entries through the same `update_model_pricing` path as manual entry. Imported model ids are normalized by the backend's `clean_model_id_for_pricing` rules (strip the provider prefix, lowercase, truncate the `:` suffix, map `@` to `-`, drop the `[1m]` marker) so the persisted rows actually match cost-attribution queries. A companion fix changes "backfill zero-cost over a range" to match in Rust by raw model alias (route prefixes, `:free` variants, date suffixes) rather than by exact SQL string match, so newly priced alias rows are priced immediately instead of waiting for the next startup backfill (fixes [#4017](https://github.com/farion1231/cc-switch/issues/4017)). ([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||||
|
|
||||||
|
### Native Windows ARM64 Builds
|
||||||
|
|
||||||
|
Release artifacts now include native Windows ARM64 builds, so ARM Windows devices can grab the matching native build instead of relying on x64 emulation. The release matrix now also runs each platform independently (fail-fast disabled), so a job that fails for a missing secret (e.g. macOS signing in a fork) no longer cancels its still-running siblings. ([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||||
|
|
||||||
|
### Live End Time for Custom Date Ranges
|
||||||
|
|
||||||
|
The custom date-range picker gains a "follow the current time as the end time" checkbox; when enabled, the end time becomes read-only and tracks now, so usage data always reflects the live consumption from the chosen start to the present moment. This is especially useful within the Coding Plan's 5-hour quota window. `liveEndTime` is now part of the React Query cache key, so a live range and a fixed range with the same endpoint no longer share the same stale cache entry. ([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||||
|
|
||||||
|
### Source File Name in the Session Detail Header
|
||||||
|
|
||||||
|
The session detail header now shows the session log's file name next to the project directory (hover for the full path, click to copy), so you can locate and open the underlying JSONL file directly from the UI. For long file names without spaces, such as the ~70-character Codex rollout names, it truncates at `max-w-[200px]` to avoid overflowing into the action buttons in a narrow window. ([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||||
|
|
||||||
|
### Unmanaged-Skill Hint on the Import Button
|
||||||
|
|
||||||
|
The Skills import button in the top bar now shows a green dot and a tooltip when there are unmanaged Skills on disk available to import, so you can tell at a glance that a Skill on disk hasn't been brought under management yet. The scan runs once on mount and is shared across navigations (30s `staleTime` + `keepPreviousData`) to avoid redundant disk IO.
|
||||||
|
|
||||||
|
### OpenCode Go Subscription Presets
|
||||||
|
|
||||||
|
Added the OpenCode Go (`opencode.ai/zen/go`) preset, covering Claude, Codex, and OpenCode, using a paste-ready bare API key (no OAuth). The Codex preset uses `openai_chat` conversion with a GLM / Kimi / DeepSeek / MiMo model catalog (and without a static `codexChatReasoning`, inferring each model's capabilities), while OpenCode points at `/zen/go/v1` via `@ai-sdk/openai-compatible`. All four OpenCode Go presets — Claude, Claude Desktop, Codex, and OpenCode — carry the referral link and in-app promotion copy; the promotion banner now shows on `partnerPromotionKey` alone (no longer bound to `isPartner`), so a preset can surface a referral promotion without earning the gold paid-partner star (which incidentally brings the existing MiniMax promotion back into view).
|
||||||
|
|
||||||
|
### Prime-Partner Preset Badge and Sorting
|
||||||
|
|
||||||
|
The first-party Moonshot Kimi presets (Kimi / Kimi For Coding / Kimi K2.7 Code) are now marked as prime partners: instead of the gold star they render a solid gold heart (no badge border) and, in the default (Original) sort, float to just after the official-category presets and before the rest. The grouping is done with a three-way partition that keeps each group's internal order, and an official preset that is also marked prime-partner stays only in the official group.
|
||||||
|
|
||||||
|
### GLM-5.2 and Doubao Seed 2.1 Pricing
|
||||||
|
|
||||||
|
The seed model pricing now includes GLM-5.2 ([#4385](https://github.com/farion1231/cc-switch/pull/4385)) and Doubao Seed 2.1 Pro / Turbo, so these models' usage is priced correctly instead of being recorded at zero cost. Doubao prices use Volcengine's official list pricing (converted at roughly 7.14); `cache_creation` stays at 0 because Doubao bills cache storage by time rather than by token writes, and the existing 2.0 rows are retained for historical accounting.
|
||||||
|
|
||||||
|
### Kimi For Coding Auto-Compact Window
|
||||||
|
|
||||||
|
The Kimi For Coding preset now defaults `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to 262144, matching Kimi's official documentation, and exposes it via `templateValues` so users can customize the value for future models or performance tuning. ([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||||
|
|
||||||
|
### SubRouter Partner Provider
|
||||||
|
|
||||||
|
Added SubRouter (`subrouter.ai`, an AI relay aggregator that lets one key reach many models across many providers) as a preset covering all seven managed apps — an Anthropic-format endpoint for Claude Code / Claude Desktop / OpenClaw / Hermes, an OpenAI-compatible `/v1` endpoint (`gpt-5.5`) for Codex and OpenCode, and a Gemini-compatible `/v1beta` endpoint (`gemini-3.5-flash`) for Gemini CLI — with its own brand icon, a gold partner star, four-language promotion copy, and a referral signup link prefilled to the API-key registration page (`?aff=l3ri`). ([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
### Chinese Codex Providers Use the Native Responses API
|
||||||
|
|
||||||
|
Several Chinese providers (Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax domestic / international) now expose native OpenAI Responses endpoints, so their Codex presets switch to `apiFormat: "openai_responses"`, connecting directly to the upstream instead of going through the Responses→Chat route-takeover conversion. Dropping the no-longer-needed `codexChatReasoning` and `modelCatalog` also keeps the "local routing mapping" toggle unchecked by default. SiliconFlow-hosted MiniMax stays on `openai_chat` because that is a third-party endpoint, not MiniMax's own base_url. The remaining chat-based providers also refreshed stale model ids (GLM 5.1→5.2, StepFun 3.5-flash-2603→3.7-flash, Ling 2.5-1T→2.6-1T).
|
||||||
|
|
||||||
|
### Upstream-Format Selector Decoupled from the Model-Mapping Toggle
|
||||||
|
|
||||||
|
The Codex provider form previously bound Chat format conversion and route takeover (model mapping) to the same toggle, which meant a provider offering a native Responses API couldn't use model mapping without forcing Chat Completions conversion. "Upstream format" (Chat Completions / Responses) is now a separate, always-visible selector, while the local routing toggle only controls the advanced subsection (the model-mapping catalog, plus reasoning capabilities when the format is Chat). Its initial state is derived from whether a saved catalog exists, adding no new persisted field; the four-language (zh / en / ja / zh-TW) `codexConfig` copy was rewritten to match.
|
||||||
|
|
||||||
|
### Doubao Seed 2.1 Pro Preset
|
||||||
|
|
||||||
|
The DouBaoSeed preset now points to `doubao-seed-2-1-pro` (replacing `doubao-seed-2-0-code-preview-latest`) across all six clients (claude, claude-desktop, codex, opencode, openclaw, hermes), updates the display name to "Doubao Seed 2.1 Pro", and corrects the OpenClaw cost fields from 0.002 / 0.006 to 0.84 / 4.2 USD per million tokens to match the new model.
|
||||||
|
|
||||||
|
### CTok Renamed to ETok
|
||||||
|
|
||||||
|
Following the vendor's domain, endpoint, and trademark rename, all user-facing branding migrates from CTok to ETok (`ctok.ai`→`etok.ai`, `api.ctok.ai`→`api.etok.ai`, plus the internal id, display name, icon, and README partner banner), across every client preset. The Codex history-migration whitelist still keeps `ctok` as a legacy id alongside the new `etok`, so existing users' local session history stays correctly bucketed after the rename.
|
||||||
|
|
||||||
|
### Kimi Preset Naming Unified
|
||||||
|
|
||||||
|
The Kimi presets that OpenCode and OpenClaw previously labeled "Kimi K2.7 Code" are renamed to "Kimi" to match the other apps (OpenCode's provider display name is renamed too); the model label still keeps "Kimi K2.7 Code" because it describes the actual model.
|
||||||
|
|
||||||
|
### JSON Editor Dark Mode
|
||||||
|
|
||||||
|
The CodeMirror `JsonEditor` in the usage-script dialog, the provider form, and the universal provider form now follows the app theme via `useDarkMode()`, switching to the `oneDark` editor theme instead of staying light while the rest of the app is already dark. ([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||||
|
|
||||||
|
### More Compact "Add Provider" Header and Footer Hint
|
||||||
|
|
||||||
|
The "Add Provider" dialog tightens the vertical spacing from the title to the tabs and from the tabs to the cards from 24px to 12px, and adds an always-visible fixed footer hint guiding users to fill in the fields below after choosing a preset. `FullScreenPanel` gains an optional `contentClassName` prop so the padding override applies only to this panel without affecting other panels that share it.
|
||||||
|
|
||||||
|
### Theme-Adaptive Kimi Mark
|
||||||
|
|
||||||
|
The inline Kimi placeholder mark is replaced with the vendor's refreshed mark. The K glyph uses `currentColor` so it follows the theme text color (dark in light mode, white in dark mode), while the brand accent color is fixed to the new `#1783FF`, with the metadata fallback color aligned accordingly.
|
||||||
|
|
||||||
|
### Removed the Fable 5 Verified Banner
|
||||||
|
|
||||||
|
The Settings About page no longer shows the Fable 5 Verified commemorative banner that 3.16.3 added beside the app name to mark a special build; the banner image and its marker are removed, and the About panel returns to the standard version-badge layout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
### Copilot / Codex OAuth Requests Now Honor the Global Proxy
|
||||||
|
|
||||||
|
`CopilotAuthManager` and `CodexOAuthManager` hardcoded `Client::new()` at construction, so their auth flows (token exchange, fetching the `/models` list, determining model vendor, device-code and OAuth refresh requests) ignored the configured global proxy and connected directly to the target services. On Copilot, a direct connection made `/models` return 0 Claude models, breaking live model resolution, and the upstream rejected requests with `400 model_not_supported`. Both managers now pull from the shared client on each request (`crate::proxy::http_client::get()`), honoring the global proxy URL and supporting runtime hot reload. Fixes [#2016](https://github.com/farion1231/cc-switch/issues/2016) and [#2931](https://github.com/farion1231/cc-switch/issues/2931). ([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||||
|
|
||||||
|
### Decompressing Compressed Request and Error Bodies
|
||||||
|
|
||||||
|
Codex Desktop sends zstd-compressed request bodies when authenticating to the Codex backend, which broke local proxy routing because the handlers parsed the raw compressed bytes directly with `serde_json`. The proxy now decompresses the request body before JSON parsing (gzip / br / deflate, plus the newly added zstd support, including stacked encodings like `gzip, zstd`), across three Codex handlers, and strips the stale `content-encoding` / `content-length` / `transfer-encoding` request headers so the forwarder regenerates them. Upstream non-2xx error bodies are decompressed the same way, so compressed rate-limit and auth details are no longer dropped and hidden from the client. Fixes [#3764](https://github.com/farion1231/cc-switch/issues/3764) and [#3696](https://github.com/farion1231/cc-switch/issues/3696). ([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||||
|
|
||||||
|
### DeepSeek Endpoint 400 with `thinking: disabled`
|
||||||
|
|
||||||
|
DeepSeek's Anthropic-compatible endpoint rejects requests where `thinking.type=disabled` coexists with an effort parameter, returning HTTP 400, which broke Claude Code 2.1.166+ sub-agents (Workflow / Dynamic Workflow) that hardcode `thinking: disabled`. Rather than overriding the client's intent, the proxy now strips the conflicting `output_config.effort` / `reasoning_effort` parameters for the official DeepSeek endpoint, since sub-agents don't need to surface reasoning anyway. ([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||||
|
|
||||||
|
### Reverted Hoisting Anthropic system Messages
|
||||||
|
|
||||||
|
Reverted the [#3775](https://github.com/farion1231/cc-switch/pull/3775) change that hoisted `role=system` messages on Anthropic-compatible providers from `messages[]` up to the top-level `system` field. The DeepSeek endpoint natively accepts inline system messages, and the rewrite changed the request prefix; keeping messages in place preserves the prompt prefix and avoids a suspected cache-hit-rate regression (see [#4297](https://github.com/farion1231/cc-switch/issues/4297)). The unrelated Windows test fix and the tool-thinking-history normalization from #3775 are retained.
|
||||||
|
|
||||||
|
### Chat Tool Calls Missing Function Names
|
||||||
|
|
||||||
|
Some upstreams send empty or missing function names in streaming tool-call deltas, which used to produce invalid Codex Chat output items (or an `unknown_tool` fallback). Accumulated tool-call state is no longer overwritten by an empty delta, and tool calls that never receive a `call_id` and a valid name are skipped at finalization, across the streaming, non-streaming, and legacy `function_call` paths. ([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||||
|
|
||||||
|
### Restore Cached Codex Tool-Call Fields
|
||||||
|
|
||||||
|
When Codex makes a follow-up Chat request that references a `previous_response_id`, its `function_call` items may carry only the `call_id`. History enhancement previously backfilled only `reasoning` / `reasoning_content`, leaving the function's `name`, `arguments`, `status`, and other fields empty; it now restores all cached tool-call fields from history so the call can be correctly reconstructed for the Chat upstream. ([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||||
|
|
||||||
|
### Duplicate Codex base_url Entries in config.toml
|
||||||
|
|
||||||
|
Writing Codex's `base_url` into `config.toml` previously replaced or removed only one matching assignment per section, so a section that already contained multiple `base_url` lines kept the extras and accumulated duplicates. `setCodexBaseUrl` now collapses all matches in the target section or at the top level (replacing the first, removing the rest), and the TOML `base_url` regex now handles escaped quotes. ([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||||
|
|
||||||
|
### History Migration Probes the CODEX_SQLITE_HOME State DB
|
||||||
|
|
||||||
|
Codex session-history migration previously scanned only `~/.codex/state_5.sqlite` and the `sqlite_home` location in `config.toml`, so when Codex's SQLite state was relocated via the `CODEX_SQLITE_HOME` environment variable, the state DB was never scanned and its threads stayed in the old provider bucket. The `codex_state_db_paths` helper shared by both the third-party and unified-session migrations now falls back to `CODEX_SQLITE_HOME` (the `sqlite_home` in `config` still takes precedence).
|
||||||
|
|
||||||
|
### Provider Terminal Honors the User Shell
|
||||||
|
|
||||||
|
Launching a provider terminal on macOS / Linux previously hardcoded `bash`, so zsh / fish users' rc files weren't loaded. The launcher now detects the user's default shell from `$SHELL` (falling back to `/bin/zsh` on macOS, `/bin/bash` on Linux) and execs into it with the clean-start flag, while the launch script itself now uses POSIX `sh` for portability (e.g. fish, and NixOS where `/bin/sh` may not exist). ([#4140](https://github.com/farion1231/cc-switch/pull/4140), fixes [#1546](https://github.com/farion1231/cc-switch/issues/1546))
|
||||||
|
|
||||||
|
### Claude MCP Paths Honor the Custom Config Directory
|
||||||
|
|
||||||
|
When a custom Claude config directory is configured, MCP server reads and writes now resolve to the MCP file under that directory instead of the default location, isolating MCP state per profile. The old "copy on access" migration of the legacy file was removed in favor of resolving the override path directly. ([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||||
|
|
||||||
|
### Preset Results Clickable After Search
|
||||||
|
|
||||||
|
After searching in the "Add Provider" preset selector, results briefly couldn't be clicked or selected. The `requestAnimationFrame` `select()` that fought the input and swallowed the first character (e.g. "gateway" → "ateway") was removed, input auto-focus on the open-and-click path was restored, and pressing Ctrl/Cmd+F while the search box is already open now refocuses it. The provider list's typing guard was also narrowed to the Ctrl/Cmd+F branch so Escape can still close the search panel. ([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||||
|
|
||||||
|
### Skills Browsing and Provider Card Display Fixes
|
||||||
|
|
||||||
|
Fixed several display and interaction issues: repository management actions stay available while browsing skills.sh, and refresh stays available when a repository returns empty results; overly long provider names and website URLs on provider cards now truncate instead of overflowing; the OMO model-variant dropdown truncates the selected label with a full-text tooltip; and Select menu items show a checkmark on the currently selected item. ([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||||
|
|
||||||
|
### Reset Scroll When Switching Settings Tabs
|
||||||
|
|
||||||
|
Switching tabs in the Settings dialog used to keep the previous tab's scroll position, sometimes landing halfway down the new tab; the scroll container now resets to the top whenever the active tab changes. ([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
### Kimi Pinned Sponsor Banner
|
||||||
|
|
||||||
|
The pinned sponsor banner at the top of all four README languages (en / zh / ja / de) is now Kimi K2.7 Code, replacing the previous MiniMax M2.7 banner. The copy reflects the K2.7 Code release (a coding-oriented agentic model with thinking-token usage down roughly 30% from K2.6), the banner is now served from in-repo assets (`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`) instead of the Moonshot CDN, and it carries a clickable call to action pointing at the `aff=cc-switch` Moonshot console.
|
||||||
|
|
||||||
|
### Codex Unified Session History Guide
|
||||||
|
|
||||||
|
Added a three-language (zh / en / ja) guide explaining what the unified Codex session history toggle's enable-time migration (when enabled) and ledger-based restore (when disabled) actually do, why session data is never truly deleted (only re-tagged + auto-backed-up), and how to verify whether files really are on disk or were just filed into another provider's drawer. It includes a symptom table for the common "my sessions are gone" misunderstanding and disk-verification commands for macOS / Linux / Windows, and is linked as the first item in the v3.16.3 release notes' "Usage Guides".
|
||||||
|
|
||||||
|
### Simplified Homebrew Install Instructions
|
||||||
|
|
||||||
|
The install guide no longer asks users to run `brew tap farion1231/ccswitch` before `brew install --cask cc-switch`; this deprecated tap step is removed from the en / ja / zh user manuals, and the cask now installs directly. ([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||||
|
|
||||||
|
### Star-History Global Ranking Badge
|
||||||
|
|
||||||
|
Added a star-history global ranking badge next to the existing Trendshift badge across all four README languages, with light / dark theme variants.
|
||||||
|
|
||||||
|
### Volcengine Ark Coding Plan Activity Link
|
||||||
|
|
||||||
|
The "developers in mainland China click here" link in the ByteDance / Volcengine Ark sponsor entry now points to Volcengine's `ai618` activity page, replacing the previous `codingplan` referral URL, across all four README languages.
|
||||||
|
|
||||||
|
### CCSub Sponsor Banner Vector Asset
|
||||||
|
|
||||||
|
Replaced the low-resolution `ccsub.jpg` sponsor logo with the vector `ccsub.svg`, letterboxed from 2046x648 to 2046x850 (roughly 2.406:1) so it matches the other sponsor-table banners and renders at the same 62px height. All four README languages point to the new asset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade Notes
|
||||||
|
|
||||||
|
### Chinese Codex Providers' Native Responses Migration
|
||||||
|
|
||||||
|
This release switches the Codex presets of several Chinese providers with native Responses endpoints (Qwen / DashScope, Xiaomi MiMo, Volcengine Doubao, Meituan LongCat, MiniMax domestic / international) to `openai_responses` and removes their `modelCatalog`. Existing providers already configured from these presets are unaffected and keep their configuration as-is; if you want to switch to native Responses (dropping the format-conversion takeover), re-pick the preset once and save. SiliconFlow-hosted MiniMax stays on `openai_chat` and is not part of this migration.
|
||||||
|
|
||||||
|
### Recovery from a Too-New Database
|
||||||
|
|
||||||
|
If you opened the database with a higher version of CC Switch and then switched back to an older version, the older version will enter the new "database version too new" recovery screen on startup and guide you to upgrade to a version that can read the database. This is expected behavior — upgrading to the latest version restores normal operation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risk Notice
|
||||||
|
|
||||||
|
This release continues the risk notices from previous versions for reverse-proxy-style features.
|
||||||
|
|
||||||
|
**Codex OAuth reverse proxy**: using a ChatGPT subscription's Codex OAuth through a reverse proxy may violate OpenAI's terms of service. See the [v3.13.0 release notes](v3.13.0-en.md#️-risk-notice) for details.
|
||||||
|
|
||||||
|
**Codex third-party provider Chat routing**: when CC Switch local proxy converts and forwards Codex requests to third-party providers, each provider may have different requirements for billing, compliance, and data retention. Read the target provider's terms before use.
|
||||||
|
|
||||||
|
**Claude Desktop third-party provider proxy switching**: when CC Switch's built-in proxy gateway forwards Claude Desktop requests to third-party providers, you must also follow the target provider's billing, compliance, and data-retention terms.
|
||||||
|
|
||||||
|
By enabling these features, users accept the related risks. CC Switch is not responsible for account restrictions, warnings, or service suspensions caused by using these features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Thanks to the following contributors for the features and fixes in v3.16.4:
|
||||||
|
|
||||||
|
- [#3817](https://github.com/farion1231/cc-switch/pull/3817): decompress the request body before forwarding and add zstd support, thanks @chenx-dust.
|
||||||
|
- [#4583](https://github.com/farion1231/cc-switch/pull/4583): fix the Copilot / Codex OAuth modules bypassing the global proxy and causing Claude model 400s, thanks @zymouse.
|
||||||
|
- [#4589](https://github.com/farion1231/cc-switch/pull/4589): add local proxy request overrides (custom headers and request body), thanks @mfzzf.
|
||||||
|
- [#4575](https://github.com/farion1231/cc-switch/pull/4575): add an in-app recovery screen for a too-new database version, thanks @SaladDay.
|
||||||
|
- [#4556](https://github.com/farion1231/cc-switch/pull/4556): wire dark mode into the JsonEditor in several places, thanks @TanKimzeg.
|
||||||
|
- [#4438](https://github.com/farion1231/cc-switch/pull/4438): add a live end time for custom date ranges, thanks @arichyx.
|
||||||
|
- [#3950](https://github.com/farion1231/cc-switch/pull/3950): add Windows ARM64 release support, thanks @MOON-DREAM-STARS.
|
||||||
|
- [#4401](https://github.com/farion1231/cc-switch/pull/4401): add CLAUDE_CODE_AUTO_COMPACT_WINDOW to the Kimi For Coding preset, thanks @cyijun.
|
||||||
|
- [#4323](https://github.com/farion1231/cc-switch/pull/4323): fix the Skills management and model-config interaction display, thanks @thisTom.
|
||||||
|
- [#3431](https://github.com/farion1231/cc-switch/pull/3431): align Claude MCP paths to the custom config directory, thanks @makoMakoGo.
|
||||||
|
- [#4159](https://github.com/farion1231/cc-switch/pull/4159): skip Chat tool calls missing function names, thanks @hueifeng.
|
||||||
|
- [#4385](https://github.com/farion1231/cc-switch/pull/4385): add glm-5.2 pricing, thanks @arichyx.
|
||||||
|
- [#4079](https://github.com/farion1231/cc-switch/pull/4079): support importing model pricing from models.dev, thanks @kingcanfish.
|
||||||
|
- [#4315](https://github.com/farion1231/cc-switch/pull/4315): fix preset results not being clickable / selectable after search, thanks @RuixeWolf.
|
||||||
|
- [#4316](https://github.com/farion1231/cc-switch/pull/4316): prevent duplicate Codex base_url entries, thanks @jeffwcx.
|
||||||
|
- [#4140](https://github.com/farion1231/cc-switch/pull/4140): make the provider terminal honor the user shell, thanks @zkforge.
|
||||||
|
- [#4113](https://github.com/farion1231/cc-switch/pull/4113): show the source file name in the session detail header, thanks @xu-song.
|
||||||
|
- [#4160](https://github.com/farion1231/cc-switch/pull/4160): restore cached Codex tool-call fields, thanks @chen-985211.
|
||||||
|
- [#4239](https://github.com/farion1231/cc-switch/pull/4239): strip the effort parameter when thinking:disabled on DeepSeek endpoints, thanks @maskshell.
|
||||||
|
- [#4165](https://github.com/farion1231/cc-switch/pull/4165): reset scroll when switching settings tabs, thanks @Muleizhang.
|
||||||
|
- [#4319](https://github.com/farion1231/cc-switch/pull/4319): remove the deprecated Homebrew tap step, thanks @tianpeng-dev.
|
||||||
|
- [#4522](https://github.com/farion1231/cc-switch/pull/4522): add the SubRouter provider preset, thanks @abingyyds.
|
||||||
|
|
||||||
|
Thanks also to everyone who reported Codex proxy chain, usage billing, local proxy robustness, and platform compatibility issues after the v3.16.3 release. Many of these patches came directly from real-world reproduction clues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Download & Install
|
||||||
|
|
||||||
|
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) and download the build for your system.
|
||||||
|
|
||||||
|
### System Requirements
|
||||||
|
|
||||||
|
| System | Minimum Version | Architecture |
|
||||||
|
| ------- | ------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 and later | x64 / ARM64 |
|
||||||
|
| macOS | macOS 12 (Monterey)+ | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | See table below | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| ---------------------------------------- | ------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.16.4-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||||
|
| `CC-Switch-v3.16.4-Windows-Portable.zip` | Portable build, unzip and run |
|
||||||
|
|
||||||
|
Windows ARM64 devices should pick the artifact whose file name carries the `arm64` tag.
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
| -------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.dmg` | **Recommended** - DMG installer, drag to Applications |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.zip` | Unzip and drag to Applications, Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.tar.gz` | For Homebrew install and auto-update |
|
||||||
|
|
||||||
|
Homebrew install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
Upgrade:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux assets are available for both **x86_64** and **ARM64** (`aarch64`). Choose the file whose architecture tag matches your machine's `uname -m` output:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| Distribution | Recommended Format | Install Command |
|
||||||
|
| --------------------------------------- | ------------------ | --------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` or `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` or `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | Make executable and run directly, or use AUR |
|
||||||
|
| Other distributions / unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# CC Switch v3.16.4
|
||||||
|
|
||||||
|
> v3.16.3 で「使用量の課金を正確にする」ことに取り組んだのに続き、本リリースは Codex プロキシ経路の磨き込みと、使用量 / 価格ツールの拡充に重きを置いています——国産プロバイダーのネイティブ Responses への移行、上流の形式セレクタとモデルマッピングの分離、zstd リクエスト / エラーボディの展開、そしてツール呼び出しと OAuth がプロキシを経由するようにする一連の修正です。あわせて、ローカルプロキシのリクエストオーバーライド、データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面、ネイティブ Windows ARM64 ビルドを新設し、一連のプリセットとブランドの更新(SubRouter、OpenCode Go、CTok→ETok の改名、Kimi のブランド刷新と prime-partner バッジ)を届けます。
|
||||||
|
|
||||||
|
**[English →](v3.16.4-en.md) | [中文版 →](v3.16.4-zh.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 利用ガイド
|
||||||
|
|
||||||
|
本リリースは磨き込みと拡張が中心で、新しい機能の多くは使用量パネルとプロバイダーフォームの高度なオプションに収まっています。以下のドキュメントとあわせてご覧ください:
|
||||||
|
|
||||||
|
- **[Codex デスクトップでカスタムモデルが見えない?](../guides/codex-desktop-custom-model-visibility-ja.md)**: Codex デスクトップアプリで、設定したサードパーティ / カスタムモデルが見えないというフィードバックが少なくありません。これは Codex デスクトップアプリ**上流自身のゲーティング挙動**(公式ログイン状態に応じてモデルセレクタを通す)であり、CC Switch のローカル設定の問題ではありません。**本リリース(v3.16.4)でこの点に変更はありません**。ドキュメントでは原因と、使える緩和策(公式ログインの保持 + ルーティングテイクオーバー)を解説しています。
|
||||||
|
- **[使用量統計](../user-manual/ja/4-proxy/4.4-usage.md)**: 使用量ダッシュボードのデータソースと集計の仕組みを確認できます。本リリースでは models.dev からのモデル価格一括インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲の「リアルタイム終了時刻」を追加しました。
|
||||||
|
- **[設定](../user-manual/ja/1-getting-started/1.5-settings.md)**: ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダー / リクエストボディ)、Codex の上流形式セレクタやローカルルーティングのトグルは、いずれもプロバイダーフォームの高度なオプションにあります。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## 唯一の公式チャネル(必ずお読みください)
|
||||||
|
>
|
||||||
|
> CC Switch は**完全に無料・オープンソース**のデスクトップアプリで、**ユーザーから料金を徴収することはありません**。本ソフトウェアは下記の公式チャネルからのみ入手してください:
|
||||||
|
>
|
||||||
|
> | チャネル | 唯一の公式 |
|
||||||
|
> | ------------ | ------------------------------------------------------------------------------ |
|
||||||
|
> | 公式サイト | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | ソースコード | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | ダウンロード | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | 偽サイト通報 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **料金請求・チャージ・認証情報の提供を求める「CC Switch」サイトやクライアントはすべて偽物です。** 支払いを誘導された場合は直ちに操作を中止し、GitHub Issues からご報告ください。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
CC Switch v3.16.4 は v3.16.3 に続くメンテナンスアップデートです。本リリースは Codex プロキシ経路まわりを一通り締め直しました——ネイティブの OpenAI Responses endpoint を備える複数の国産プロバイダーをネイティブ形式へ切り替え(Responses→Chat のルーティングテイクオーバー変換を省く)、「上流形式」を「ローカルルーティング」トグルから独立させ、zstd のリクエストとエラーレスポンスボディの展開を補い、ツール呼び出しと「OAuth モジュールがグローバルプロキシをバイパスする」一連の問題を修正しました。
|
||||||
|
|
||||||
|
あわせて本リリースでは使用量と価格のツールを拡充し(models.dev からの価格インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲のリアルタイム終了時刻、GLM-5.2 と Doubao Seed 2.1 の価格)、一連のプロキシと堅牢性の機能を新設し(カスタムリクエストヘッダー / リクエストボディのオーバーライド、データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面、ネイティブ Windows ARM64 ビルド)、一連のプリセットとブランドの更新(SubRouter と OpenCode Go のサブスクリプション、CTok→ETok の改名、Kimi のブランド刷新と prime-partner バッジ、Kimi K2.7 Code スポンサーバナー)を届けます。
|
||||||
|
|
||||||
|
**リリース日**: 2026-06-27
|
||||||
|
|
||||||
|
**Stats**: 53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ハイライト
|
||||||
|
|
||||||
|
- **国産 Codex プロバイダーがネイティブ Responses を使用**: 千問 / 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax(国内 / 国際)が、それぞれのネイティブ Responses endpoint に直結するようになり、Responses→Chat の形式変換テイクオーバーを経由しなくなりました。経路が短く、より安定します。
|
||||||
|
- **ローカルプロキシのリクエストオーバーライド**: プロバイダーにカスタムリクエストヘッダーとリクエストボディのオーバーライドを設定でき、ローカルプロキシが転送時に適用します。保護対象のセキュリティ関連リクエストヘッダーにはブロック検証を行います。
|
||||||
|
- **データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面**: SQLite のバージョンが現在のアプリのサポート範囲より新しいとき、「再試行しても再び失敗するだけ」のネイティブダイアログで詰まらず、ワンクリックでアプリを更新できるリカバリ画面へ案内します。
|
||||||
|
- **より充実した使用量 / 価格ツール**: models.dev からのモデル価格一括インポート、火山方舟 Coding / Agent Plan の AK/SK 使用量照会、カスタム日付範囲の「リアルタイム終了時刻」、そして GLM-5.2 と Doubao Seed 2.1 の価格。
|
||||||
|
- **新しいプリセットとブランド更新**: SubRouter と OpenCode Go のサブスクリプションプリセットを追加し、CTok を ETok へ改名し、Kimi のブランドアイコンを刷新し、公式 Kimi プリセットに prime-partner のハートバッジを付けました。
|
||||||
|
- **ネイティブ Windows ARM64 ビルド**: 配布物にネイティブ ARM64 版を追加し、ARM アーキテクチャの Windows デバイスは x64 エミュレーションに頼る必要がなくなりました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 追加機能
|
||||||
|
|
||||||
|
### データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面
|
||||||
|
|
||||||
|
SQLite の `user_version` が現在のアプリのサポートする `SCHEMA_VERSION` より新しいとき(旧版へダウングレードした、あるいはサードパーティクライアントがこのファイルを書いた場合など)、これまでは起動時にネイティブの「再試行 / 終了」ダイアログで詰まっていました——しかし「再試行」は再び失敗するだけです。現在はアプリが専用のリカバリ画面へ案内します: 利用可能な更新があればワンクリックの「アプリを更新」ボタン(ダウンロード + インストール + 再起動、プログレスバー付き)を提供し、利用可能な更新がない場合は最新版であってもこのデータベースを読めない旨を案内します。この「バージョンが新しすぎる」チェックは、あらゆる書き込み動作の前に行われるため、アプリが読めないデータベースに対して DDL を実行することは決してありません。リカバリモードでのネイティブな終了はクリーンに終了します(この時点ではトレイがまだ作成されていません)。([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||||
|
|
||||||
|
### ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダーとリクエストボディ)
|
||||||
|
|
||||||
|
プロバイダー設定で、カスタムリクエストヘッダーとリクエストボディのオーバーライドを定義できるようになり、ローカルプロキシが転送時に適用します。Claude と Codex のプロバイダーフォームの新しいフィールドから公開します。入力は検証を経て、その中にセキュリティに敏感なリクエストヘッダーの上書きを防ぐ保護対象リクエストヘッダーのリストを含みます。([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||||
|
|
||||||
|
### 火山方舟 Coding / Agent Plan 使用量照会
|
||||||
|
|
||||||
|
使用量パネルから火山方舟(Volcengine Ark)の Coding Plan と Agent Plan のクォータを照会できるようになりました。方舟のコントロールプレーン OpenAPI(`open.volcengineapi.com`)が要求するのは推論 API key ではなくアカウント単位の AccessKey 署名であるため、使用量スクリプトに独立した AK/SK 入力欄を新設し、火山 IAM のキー管理コンソール(`https://console.volcengine.com/iam/keymanage`)へ直接飛べるクリック可能なリンクを添えました。プロキシは火山署名 V4(AWS SigV4 の変種: 固定された canonical header 順、`HMAC-SHA256` アルゴリズム、`ark` サービス scope)を実装しています。まず `GetAFPUsage`(Agent Plan の 5 時間 / 週 / 月クォータ)をプローブしてプランを自動判定し、失敗した場合は `GetCodingPlanUsage` へフォールバックして `Level` フィールドからウィンドウラベルを解析し(`ResetTimestamp <= 0` にはガードを設けます)、あわせて使用量フッター、トレイメニュー、4 言語に `monthly` 階層のラベルを補いました。
|
||||||
|
|
||||||
|
### models.dev からのモデル価格インポート
|
||||||
|
|
||||||
|
「価格を追加」パネルに「models.dev からインポート」ボタンを新設しました: `https://models.dev/api.json` を取得し、カタログ全体の全文検索に対応し、選択した項目を手入力と同じ `update_model_pricing` 経路でインポートします。インポートされた model id は、バックエンドの `clean_model_id_for_pricing` ルール(プロバイダープレフィックスの除去、小文字化、`:` サフィックスの切り捨て、`@` を `-` へマッピング、`[1m]` マーカーの除去)で正規化されるため、保存される行がコスト帰属クエリと本当にマッチするようになります。あわせて、「範囲ごとのゼロコストバックフィル」を、精密な SQL 文字列マッチではなく Rust 側で元の model エイリアス(ルーティングプレフィックス、`:free` 変種、日付サフィックス)でマッチするように修正したため、新しい価格のエイリアス行が次回起動時のバックフィルを待たず即座に課金されるようになりました([#4017](https://github.com/farion1231/cc-switch/issues/4017) を修正)。([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||||
|
|
||||||
|
### ネイティブ Windows ARM64 ビルド
|
||||||
|
|
||||||
|
配布物にネイティブの Windows ARM64 制品が含まれるようになり、ARM アーキテクチャの Windows デバイスは対応するネイティブビルドを入手でき、x64 エミュレーションに頼る必要がなくなりました。リリースマトリクスも各プラットフォームが独立して走るように変更し(fail-fast を無効化)、あるジョブがキー欠如で失敗しても(fork での macOS 署名など)、まだ完了していない同列のジョブをまとめてキャンセルしないようにしました。([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||||
|
|
||||||
|
### カスタム日付範囲のリアルタイム終了時刻
|
||||||
|
|
||||||
|
カスタム日付範囲セレクタに「終了時刻を現在時刻に追従」チェックボックスを新設しました。有効にすると終了時刻は読み取り専用になり、今この瞬間に自動追従するため、使用量データは選択した起点から現在までのリアルタイムの消費を常に反映します。これは Coding Plan の 5 時間クォータウィンドウで特に有用です。`liveEndTime` は React Query のキャッシュキーに取り込んだため、リアルタイム範囲と終点が同じ固定範囲が同一の古いキャッシュ項目を共有することはなくなりました。([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||||
|
|
||||||
|
### セッション詳細ヘッダーにソースファイル名を表示
|
||||||
|
|
||||||
|
セッション詳細ヘッダーが、プロジェクトディレクトリの隣にセッションログのファイル名を表示するようになりました(ホバーで完全パスを確認、クリックでコピー)。これにより、画面から直接、基礎となる JSONL ファイルを特定して開けます。~70 文字の Codex rollout のような空白を含まない長いファイル名は `max-w-[200px]` で切り詰め、狭いウィンドウで操作ボタン領域へあふれ出るのを防ぎます。([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||||
|
|
||||||
|
### インポートボタンの未管理 Skill ヒント
|
||||||
|
|
||||||
|
トップバーの Skills インポートボタンが、ローカルにインポート可能な未管理の Skill が存在するとき、緑のドットとヒントを表示するようになり、ディスク上の Skill がまだ管理対象になっていないことが一目で分かります。このスキャンはマウント時に一度実行され、複数のナビゲーションをまたいで共有され(30s の `staleTime` + `keepPreviousData`)、ディスク IO の重複を避けます。
|
||||||
|
|
||||||
|
### OpenCode Go サブスクリプションプリセット
|
||||||
|
|
||||||
|
OpenCode Go(`opencode.ai/zen/go`)プリセットを追加し、Claude、Codex、OpenCode をカバーし、そのまま貼り付けられる素の API key(OAuth なし)を使用します。Codex プリセットは `openai_chat` 変換を使い、GLM / Kimi / DeepSeek / MiMo のモデルカタログを備え(静的な `codexChatReasoning` は付けず、モデルごとに能力を推論します)、OpenCode は `@ai-sdk/openai-compatible` 経由で `/zen/go/v1` を指します。4 つの OpenCode Go プリセット——Claude、Claude Desktop、Codex、OpenCode——にはいずれも紹介リンクとアプリ内宣伝文を付けました。宣伝バナーは `partnerPromotionKey` だけで表示できるようになり(`isPartner` への紐付けを解除)、あるプリセットが金色の有料パートナースターを得ずに紹介宣伝を表示できるようになりました(これにより既存の MiniMax 宣伝も再表示されます)。
|
||||||
|
|
||||||
|
### Prime-Partner プリセットバッジとソート
|
||||||
|
|
||||||
|
第一方 Moonshot Kimi プリセット(Kimi / Kimi For Coding / Kimi K2.7 Code)が prime partner としてマークされるようになりました: 金色のスターは表示せず、塗りつぶしの金色ハート(バッジ枠なし)を描画し、既定(Original)ソートでは公式カテゴリプリセットの後、その他より前に浮かびます。グルーピングは 3 方向の partition で実装し、各グループは内部順序を保ち、prime-partner としてもマークされた公式プリセットは公式グループにのみ残ります。
|
||||||
|
|
||||||
|
### GLM-5.2 と Doubao Seed 2.1 の価格
|
||||||
|
|
||||||
|
シードモデル価格に GLM-5.2([#4385](https://github.com/farion1231/cc-switch/pull/4385))と Doubao Seed 2.1 Pro / Turbo を追加し、これらのモデルの使用量がゼロコストではなく正しく課金されるようにしました。Doubao の価格は火山公式の定価を採用し(約 7.14 のレートで換算)、`cache_creation` は 0 のままです。Doubao はキャッシュストレージを token 書き込みではなく時間で課金するためで、既存の 2.0 行も過去の記帳のために残します。
|
||||||
|
|
||||||
|
### Kimi For Coding 自動圧縮ウィンドウ
|
||||||
|
|
||||||
|
Kimi For Coding プリセットが `CLAUDE_CODE_AUTO_COMPACT_WINDOW` を既定で 262144 に設定するようになり、Kimi 公式ドキュメントと一致させ、`templateValues` 経由で公開して、将来のモデルや性能チューニングのためにユーザーがこの値をカスタマイズできるようにしました。([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||||
|
|
||||||
|
### SubRouter パートナープロバイダー
|
||||||
|
|
||||||
|
SubRouter(`subrouter.ai`、1 つの key で複数モデル・複数プロバイダーにアクセスできる AI 中継アグリゲーター)をプリセットとして追加し、管理対象の 7 アプリすべてをカバーしました——Claude Code / Claude Desktop / OpenClaw / Hermes 向けには Anthropic 形式 endpoint、Codex と OpenCode 向けには OpenAI 互換の `/v1` endpoint(`gpt-5.5`)、Gemini CLI 向けには Gemini 互換の `/v1beta` endpoint(`gemini-3.5-flash`)——自前のブランドアイコン、金色のパートナースター、4 言語の宣伝文、そして API key の登録ページへ事前入力された紹介登録リンク(`?aff=l3ri`)を備えます。([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 変更
|
||||||
|
|
||||||
|
### 国産 Codex プロバイダーがネイティブ Responses API を使用
|
||||||
|
|
||||||
|
複数の国産プロバイダー(千問 / DashScope 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax 国内 / 国際)がネイティブの OpenAI Responses endpoint を公開したため、それらの Codex プリセットを `apiFormat: "openai_responses"` へ切り替え、Responses→Chat のルーティングテイクオーバー変換を経由せず上流に直結するようにしました。不要になった `codexChatReasoning` と `modelCatalog` を外したことで、「ローカルルーティングマッピング」トグルも既定で未選択のままになります。SiliconFlow がホストする MiniMax は `openai_chat` のままです。これは MiniMax 自身の base_url ではなくサードパーティの endpoint だからです。引き続き chat を使う他のプロバイダーも、古くなった model id を更新しました(GLM 5.1→5.2、StepFun 3.5-flash-2603→3.7-flash、Ling 2.5-1T→2.6-1T)。
|
||||||
|
|
||||||
|
### 上流形式セレクタとモデルマッピングトグルの分離
|
||||||
|
|
||||||
|
Codex プロバイダーフォームは以前、Chat 形式変換とルーティングテイクオーバー(モデルマッピング)を同じトグルに束ねていたため、ネイティブ Responses API を提供するプロバイダーが Chat Completions 変換を強制せずにモデルマッピングを使うことができませんでした。現在は「上流形式」(Chat Completions / Responses)が独立して常に見えるセレクタになり、ローカルルーティングトグルは高度なサブ領域(モデルマッピングカタログ、および形式が Chat のときの推論能力)の制御だけを担います。その初期状態は保存済みカタログの有無から導かれ、永続化フィールドは増やしません。`codexConfig` の 4 言語(zh / en / ja / zh-TW)の文言もあわせて書き直しました。
|
||||||
|
|
||||||
|
### Doubao Seed 2.1 Pro プリセット
|
||||||
|
|
||||||
|
DouBaoSeed プリセットが、6 つのクライアントすべて(claude、claude-desktop、codex、opencode、openclaw、hermes)で `doubao-seed-2-1-pro` を指すようになり(`doubao-seed-2-0-code-preview-latest` を置き換え)、表示名を「Doubao Seed 2.1 Pro」に更新し、OpenClaw のコストフィールドを新モデルに合わせて 0.002 / 0.006 から 0.84 / 4.2 ドル毎 100 万 token へ訂正しました。
|
||||||
|
|
||||||
|
### CTok を ETok へ改名
|
||||||
|
|
||||||
|
ベンダーによるドメイン・endpoint・商標の改名に合わせ、ユーザーに見えるブランドをすべて CTok から ETok へ移行しました(`ctok.ai`→`etok.ai`、`api.ctok.ai`→`api.etok.ai`、および内部 id、表示名、アイコン、README パートナーバナー)。各クライアントプリセットを網羅します。Codex 履歴移行のホワイトリストでは、改名後も既存ユーザーのローカルセッション履歴が正しく分類されるよう、旧 id の `ctok` を新しい `etok` と並存させたまま残します。
|
||||||
|
|
||||||
|
### Kimi プリセットの命名統一
|
||||||
|
|
||||||
|
OpenCode と OpenClaw で以前「Kimi K2.7 Code」とマークされていた Kimi プリセットを、他のアプリと一致する「Kimi」へ改名しました(OpenCode のプロバイダー表示名もあわせて改名)。モデルラベルは引き続き「Kimi K2.7 Code」のままです。これは実際のモデルを表しているためです。
|
||||||
|
|
||||||
|
### JSON エディタのダークモード
|
||||||
|
|
||||||
|
使用量スクリプトのダイアログ、プロバイダーフォーム、ユニバーサルプロバイダーフォーム内の CodeMirror `JsonEditor` が、`useDarkMode()` を通じてアプリのテーマに追従し、`oneDark` エディタテーマへ切り替わるようになり、アプリの他の部分がすでにダークなのにライトのままになることがなくなりました。([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||||
|
|
||||||
|
### よりコンパクトな「プロバイダーを追加」のタイトルとフッターヒント
|
||||||
|
|
||||||
|
「プロバイダーを追加」ダイアログで、タイトルからタブ、タブからカードへの縦方向の間隔を 24px から 12px へ詰め、プリセットを選んだ後に下のフィールドを記入するよう案内する、常に見える固定フッターヒントを新設しました。`FullScreenPanel` には任意の `contentClassName` プロパティを追加し、パディングの上書きをこのパネルだけに作用させ、これを共有する他のパネルに影響しないようにしました。
|
||||||
|
|
||||||
|
### テーマ追従の Kimi アイコン
|
||||||
|
|
||||||
|
インラインの Kimi プレースホルダーマーカーを、ベンダーが刷新したアイコンへ置き換えました。K 字形は `currentColor` を使うため、テーマのテキスト色に追従し(ライトモードは濃く、ダークモードは白く)、ブランドのアクセント色は新しい `#1783FF` に固定し、メタデータのフォールバック色もそれに合わせました。
|
||||||
|
|
||||||
|
### Fable 5 Verified 記念バナーの削除
|
||||||
|
|
||||||
|
設定の「バージョン情報」ページが、3.16.3 で特別ビルドを示すためにアプリ名の隣に付けていた Fable 5 Verified 記念バナーを表示しなくなりました。バナー画像とそのマーカーを削除し、「バージョン情報」パネルは標準のバージョンバッジレイアウトに戻りました。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修正
|
||||||
|
|
||||||
|
### Copilot / Codex OAuth リクエストがグローバルプロキシに従うように
|
||||||
|
|
||||||
|
`CopilotAuthManager` と `CodexOAuthManager` は構築時に `Client::new()` をハードコードしていたため、それらの認証フロー(token の交換、`/models` リストの取得、model vendor の判定、device-code と OAuth のリフレッシュリクエスト)が設定済みのグローバルプロキシを無視し、対象サービスへ直結していました。Copilot では、直結により `/models` が Claude モデルを 0 個返し、live のモデル解決が失効し、上流が `400 model_not_supported` でリクエストを拒否していました。現在は両 manager が、リクエストのたびに共有クライアント(`crate::proxy::http_client::get()`)からその場で取得するように変更され、グローバルプロキシ URL に従い、ランタイムのホット更新にも対応します。[#2016](https://github.com/farion1231/cc-switch/issues/2016)、[#2931](https://github.com/farion1231/cc-switch/issues/2931) を修正。([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||||
|
|
||||||
|
### 圧縮されたリクエストボディとエラーボディの展開
|
||||||
|
|
||||||
|
Codex Desktop は Codex バックエンドへ認証するとき zstd 圧縮のリクエストボディを送ります。これがローカルプロキシのルーティングを壊していました。ハンドラーが生の圧縮バイトをそのまま `serde_json` で解析していたためです。プロキシは現在、JSON 解析の前にリクエストボディを展開し(gzip / br / deflate に加え、新たに zstd に対応、`gzip, zstd` のような積み重ねエンコーディングを含む)、3 つの Codex ハンドラーをカバーし、古くなった `content-encoding` / `content-length` / `transfer-encoding` リクエストヘッダーを剥がして転送器に再生成させます。上流の非 2xx のエラーボディも同様に展開されるため、圧縮されたレート制限や認証の詳細がクライアントに対して破棄・隠蔽されることがなくなりました。[#3764](https://github.com/farion1231/cc-switch/issues/3764)、[#3696](https://github.com/farion1231/cc-switch/issues/3696) を修正。([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||||
|
|
||||||
|
### DeepSeek endpoint で `thinking: disabled` のときの 400 エラー
|
||||||
|
|
||||||
|
DeepSeek の Anthropic 互換 endpoint は、`thinking.type=disabled` と effort パラメータが共存するリクエストを HTTP 400 で拒否します。これは Claude Code 2.1.166+ で `thinking: disabled` をハードコードするサブ agent(Workflow / Dynamic Workflow)を壊していました。プロキシは現在、クライアントの意図を上書きするのではなく、公式 DeepSeek endpoint に対しては競合する `output_config.effort` / `reasoning_effort` パラメータを剥がします。サブ agent はそもそも推論の表示を必要としないためです。([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||||
|
|
||||||
|
### Anthropic system メッセージの引き上げをロールバック
|
||||||
|
|
||||||
|
Anthropic 互換プロバイダーの `role=system` メッセージを `messages[]` からトップレベルの `system` フィールドへ引き上げる [#3775](https://github.com/farion1231/cc-switch/pull/3775) の変更をロールバックしました。DeepSeek endpoint はそもそもインラインの system メッセージをネイティブに受け付けますが、この書き換えはリクエストのプレフィックスを変えてしまいました。メッセージを元の位置に保つことで prompt プレフィックスを保持し、一見キャッシュヒット率の後退と思われる現象を回避します([#4297](https://github.com/farion1231/cc-switch/issues/4297) を参照)。#3775 由来の、無関係な Windows テスト修正と tool-thinking-history の正規化は残します。
|
||||||
|
|
||||||
|
### Chat ツール呼び出しの関数名欠落
|
||||||
|
|
||||||
|
一部の上流は、ストリーミングのツール呼び出し増分で空の、または欠落した関数名を送ります。これは以前、無効な Codex Chat の出力項(または `unknown_tool` フォールバック)を生んでいました。現在は累積したツール呼び出し状態が空の増分で上書きされることがなくなり、最後まで `call_id` と有効な名前を得られなかったツール呼び出しは最終化フェーズでスキップされます。ストリーミング、非ストリーミング、旧版 `function_call` の 3 経路をカバーします。([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||||
|
|
||||||
|
### Codex のキャッシュされたツール呼び出しフィールドの復元
|
||||||
|
|
||||||
|
Codex が `previous_response_id` を参照する後続の Chat リクエストを発行するとき、その `function_call` 項が `call_id` だけを携える場合があります。履歴拡張は以前 `reasoning` / `reasoning_content` だけをバックフィルし、関数の `name`、`arguments`、`status` などのフィールドを空のまま残していました。現在は履歴からキャッシュされたツール呼び出しフィールドをすべて復元し、その呼び出しを Chat 上流向けに正しく再構築できるようにします。([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||||
|
|
||||||
|
### config.toml 内の重複した Codex base_url 項
|
||||||
|
|
||||||
|
Codex の `base_url` を `config.toml` へ書き込むとき、以前は各セクションで一致する代入を 1 つだけ置換または削除していたため、すでに複数行の `base_url` を含むセクションでは余分な項が残り、重複が累積していました。`setCodexBaseUrl` は現在、対象セクションまたはトップレベルの一致をすべて折りたたみ(最初の 1 つを置換し、残りを削除)、TOML の `base_url` 正規表現もエスケープされた引用符を処理します。([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||||
|
|
||||||
|
### 履歴移行が CODEX_SQLITE_HOME の状態 DB をプローブ
|
||||||
|
|
||||||
|
Codex セッション履歴の移行は、以前 `~/.codex/state_5.sqlite` と `config.toml` の `sqlite_home` の場所だけをスキャンしていたため、Codex の SQLite 状態が `CODEX_SQLITE_HOME` 環境変数で再配置されたとき、状態 DB は一度もスキャンされず、その threads は古いプロバイダーバケットに残ったままでした。サードパーティ移行と統一セッション移行の両方が共有する `codex_state_db_paths` ヘルパーが、現在は `CODEX_SQLITE_HOME` へフォールバックします(`config` 内の `sqlite_home` は引き続き優先)。
|
||||||
|
|
||||||
|
### プロバイダーターミナルがユーザーの shell を尊重
|
||||||
|
|
||||||
|
macOS / Linux でプロバイダーターミナルを起動するとき、以前は `bash` をハードコードしていたため、zsh / fish ユーザーの rc ファイルが読み込まれませんでした。ランチャーは現在、`$SHELL` からユーザーの既定 shell を検出し(macOS は `/bin/zsh`、Linux は `/bin/bash` へフォールバック)、クリーンスタートのフラグ付きで exec します。一方、起動スクリプト自体は移植性のために POSIX `sh` を使うようにしました(fish や、`/bin/sh` が存在しないことのある NixOS など)。([#4140](https://github.com/farion1231/cc-switch/pull/4140)、[#1546](https://github.com/farion1231/cc-switch/issues/1546) を修正)
|
||||||
|
|
||||||
|
### Claude MCP のパスがカスタム設定ディレクトリを尊重
|
||||||
|
|
||||||
|
カスタムの Claude 設定ディレクトリが設定されているとき、MCP server の読み書きが、既定の場所ではなくそのディレクトリ配下の MCP ファイルへ解決されるようになり、MCP の状態が profile ごとに分離されます。旧ファイルに対する以前の「アクセス時コピー」移行は削除し、オーバーライドパスへ直接解決するようにしました。([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||||
|
|
||||||
|
### 検索後にプリセット結果をクリック可能に
|
||||||
|
|
||||||
|
「プロバイダーを追加」のプリセットセレクタで検索した後、結果がクリックも選択もできなくなることがありました。入力と競合して先頭文字を飲み込んでいた(「gateway」→「ateway」など)`requestAnimationFrame` の `select()` を削除し、すぐクリックできる経路の入力オートフォーカスを復元し、検索ボックスが開いているときに Ctrl/Cmd+F を押せば再フォーカスするようにもしました。プロバイダーリストのタイピングガードも Ctrl/Cmd+F 分岐に絞り込み、Escape で引き続き検索パネルを閉じられるようにしました。([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||||
|
|
||||||
|
### Skills ブラウズとプロバイダーカードの表示修正
|
||||||
|
|
||||||
|
いくつかの表示とインタラクションの問題を修正しました: skills.sh をブラウズ中もリポジトリ管理操作が引き続き使え、リポジトリが空の結果を返したときも更新が引き続き使え、プロバイダーカード上の長すぎるプロバイダー名やウェブサイト URL があふれずに切り詰められ、OMO のモデル変種ドロップダウンが選択ラベルを切り詰めて完全な内容をツールチップで示し、Select のメニュー項目が現在選択中の項目にチェックマークを表示します。([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||||
|
|
||||||
|
### 設定タブ切り替え時のスクロールリセット
|
||||||
|
|
||||||
|
設定ダイアログ内でタブを切り替えると前のタブのスクロール位置が引き継がれ、新しいタブの途中で止まることがありました。現在はアクティブなタブが変わるたびに、スクロールコンテナがトップへリセットされます。([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ドキュメント
|
||||||
|
|
||||||
|
### Kimi ピン留めスポンサーバナー
|
||||||
|
|
||||||
|
4 言語すべての README(en / zh / ja / de)の冒頭のピン留めスポンサーバナーが、これまでの MiniMax M2.7 バナーに代わって Kimi K2.7 Code になりました。文言は K2.7 Code のリリース(コーディング向けの agentic モデルで、思考 token の使用量が K2.6 比で約 30% 低減)を反映し、バナーは Moonshot CDN ではなくリポジトリ内のリソース(`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`)から提供し、`aff=cc-switch` の Moonshot コンソールを指すクリック可能な行動喚起を添えました。
|
||||||
|
|
||||||
|
### Codex 統一セッション履歴ガイド
|
||||||
|
|
||||||
|
3 言語(zh / en / ja)のガイドを新設し、Codex 統一セッション履歴トグルの有効化時の移行(有効化時)と台帳に基づく復元(無効化時)が実際に何をするのか、なぜセッションデータが本当に削除されないのか(マーカーの変更 + 自動バックアップのみ)、そしてファイルが本当にディスク上にあるのか、それとも別のプロバイダードロワーに分類されただけなのかを照合する方法を解説しました。「セッションが消えた」という、よくある誤解に対する症状の対照表と、macOS / Linux / Windows のディスク照合コマンドを含み、v3.16.3 の「利用ガイド」release notes の冒頭項としてリンクしました。
|
||||||
|
|
||||||
|
### Homebrew インストール手順の簡素化
|
||||||
|
|
||||||
|
インストールガイドが、`brew install --cask cc-switch` の前に `brew tap farion1231/ccswitch` を実行するようユーザーに求めなくなりました。この廃止された tap 手順を en / ja / zh のユーザーマニュアルから削除し、cask を直接インストールできるようにしました。([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||||
|
|
||||||
|
### Star-History 世界ランキングバッジ
|
||||||
|
|
||||||
|
4 言語すべての README で、既存の Trendshift バッジの隣に star-history の世界ランキングバッジを新設し、ライト / ダークテーマの変種を付けました。
|
||||||
|
|
||||||
|
### 火山方舟 Coding Plan キャンペーンリンク
|
||||||
|
|
||||||
|
ByteDance / 火山方舟スポンサー項目内の「中国本土の開発者はこちらをクリック」リンクが、これまでの `codingplan` 紹介 URL に代わって火山の `ai618` キャンペーンページを指すようになり、4 言語すべての README をカバーします。
|
||||||
|
|
||||||
|
### CCSub スポンサーバナーのベクター素材
|
||||||
|
|
||||||
|
低解像度の `ccsub.jpg` スポンサーロゴをベクターの `ccsub.svg` へ置き換え、2046x648 のレターボックスから 2046x850(約 2.406:1)へ拡げ、他のスポンサー表バナーと揃えて同じ 62px の高さで描画されるようにしました。4 言語すべての README が新しい素材を指します。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## アップグレード時の注意
|
||||||
|
|
||||||
|
### 国産 Codex プロバイダーのネイティブ Responses 移行
|
||||||
|
|
||||||
|
本リリースは、ネイティブ Responses endpoint を備える複数の国産プロバイダー(千問 / 百炼、小米 MiMo、火山 Doubao、美団 LongCat、MiniMax 国内 / 国際)の Codex プリセットを `openai_responses` へ切り替え、`modelCatalog` を削除しました。すでにこれらのプリセットをもとに設定済みの既存プロバイダーは影響を受けず、設定はそのまま保たれます。ネイティブ Responses(形式変換テイクオーバーを省く)へ切り替えたい場合は、プリセットからもう一度選び直して保存してください。SiliconFlow がホストする MiniMax は引き続き `openai_chat` を使い、今回の移行の対象外です。
|
||||||
|
|
||||||
|
### データベースのバージョンが新しすぎる場合の復旧
|
||||||
|
|
||||||
|
より高いバージョンの CC Switch でデータベースを開いた後、旧版へ戻した場合、旧版は起動時に新しい「データベースのバージョンが新しすぎる」リカバリ画面に入り、そのデータベースを読めるバージョンへのアップグレードへ案内します。これは期待される動作です——最新版へアップグレードすれば正常に戻ります。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## リスク通知
|
||||||
|
|
||||||
|
本リリースは、リバースプロキシ系機能に関する以前のリスク通知を引き続き適用します。
|
||||||
|
|
||||||
|
**Codex OAuth リバースプロキシ**: ChatGPT サブスクリプションの Codex OAuth をリバースプロキシ経由で使用すると、OpenAI の利用規約に違反する可能性があります。詳細は [v3.13.0 release notes](v3.13.0-ja.md#️-リスクに関する注意事項) を参照してください。
|
||||||
|
|
||||||
|
**Codex サードパーティプロバイダー Chat ルーティング**: CC Switch ローカルプロキシで Codex リクエストを変換し、サードパーティプロバイダーへ転送する場合、課金・コンプライアンス・データ保持に関する制約はプロバイダーごとに異なります。利用前に対象プロバイダーの利用規約を確認してください。
|
||||||
|
|
||||||
|
**Claude Desktop サードパーティプロバイダープロキシ切り替え**: CC Switch 内蔵のプロキシゲートウェイで Claude Desktop のリクエストをサードパーティプロバイダーへ転送する場合も、対象プロバイダーの課金・コンプライアンス・データ保持に関する規約に従う必要があります。
|
||||||
|
|
||||||
|
上記機能を有効化したユーザーは、関連するリスクを自ら負うものとします。CC Switch は、これらの機能の利用によって発生したアカウント制限、警告、サービス停止について責任を負いません。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 謝辞
|
||||||
|
|
||||||
|
v3.16.4 で機能と修正を届けてくださった以下のコントリビューターに感謝します:
|
||||||
|
|
||||||
|
- [#3817](https://github.com/farion1231/cc-switch/pull/3817): 転送前にリクエストボディを展開し zstd に対応、@chenx-dust に感謝。
|
||||||
|
- [#4583](https://github.com/farion1231/cc-switch/pull/4583): Copilot / Codex OAuth モジュールがグローバルプロキシをバイパスし Claude モデルが 400 になる問題を修正、@zymouse に感謝。
|
||||||
|
- [#4589](https://github.com/farion1231/cc-switch/pull/4589): ローカルプロキシのリクエストオーバーライド(カスタムリクエストヘッダーとリクエストボディ)を追加、@mfzzf に感謝。
|
||||||
|
- [#4575](https://github.com/farion1231/cc-switch/pull/4575): データベースのバージョンが新しすぎる場合のアプリ内リカバリ画面を追加、@SaladDay に感謝。
|
||||||
|
- [#4556](https://github.com/farion1231/cc-switch/pull/4556): 複数箇所の JsonEditor にダークモードを導入、@TanKimzeg に感謝。
|
||||||
|
- [#4438](https://github.com/farion1231/cc-switch/pull/4438): カスタム日付範囲のリアルタイム終了時刻を追加、@arichyx に感謝。
|
||||||
|
- [#3950](https://github.com/farion1231/cc-switch/pull/3950): Windows ARM64 リリースのサポートを追加、@MOON-DREAM-STARS に感謝。
|
||||||
|
- [#4401](https://github.com/farion1231/cc-switch/pull/4401): Kimi For Coding プリセットに CLAUDE_CODE_AUTO_COMPACT_WINDOW を追加、@cyijun に感謝。
|
||||||
|
- [#4323](https://github.com/farion1231/cc-switch/pull/4323): Skills 管理とモデル設定のインタラクション表示を修正、@thisTom に感謝。
|
||||||
|
- [#3431](https://github.com/farion1231/cc-switch/pull/3431): カスタム設定ディレクトリの Claude MCP パスを揃える、@makoMakoGo に感謝。
|
||||||
|
- [#4159](https://github.com/farion1231/cc-switch/pull/4159): 関数名を欠く Chat ツール呼び出しをスキップ、@hueifeng に感謝。
|
||||||
|
- [#4385](https://github.com/farion1231/cc-switch/pull/4385): glm-5.2 の価格を追加、@arichyx に感謝。
|
||||||
|
- [#4079](https://github.com/farion1231/cc-switch/pull/4079): models.dev からのモデル価格インポートに対応、@kingcanfish に感謝。
|
||||||
|
- [#4315](https://github.com/farion1231/cc-switch/pull/4315): プリセット検索後に結果をクリック選択できない問題を修正、@RuixeWolf に感謝。
|
||||||
|
- [#4316](https://github.com/farion1231/cc-switch/pull/4316): 重複した Codex base_url 項を防止、@jeffwcx に感謝。
|
||||||
|
- [#4140](https://github.com/farion1231/cc-switch/pull/4140): プロバイダーターミナルがユーザーの shell を尊重するように、@zkforge に感謝。
|
||||||
|
- [#4113](https://github.com/farion1231/cc-switch/pull/4113): セッション詳細ヘッダーにソースファイル名を表示、@xu-song に感謝。
|
||||||
|
- [#4160](https://github.com/farion1231/cc-switch/pull/4160): Codex のキャッシュされたツール呼び出しフィールドを復元、@chen-985211 に感謝。
|
||||||
|
- [#4239](https://github.com/farion1231/cc-switch/pull/4239): DeepSeek endpoint で thinking:disabled のとき effort パラメータを剥がす、@maskshell に感謝。
|
||||||
|
- [#4165](https://github.com/farion1231/cc-switch/pull/4165): 設定タブ切り替え時にスクロールをリセット、@Muleizhang に感謝。
|
||||||
|
- [#4319](https://github.com/farion1231/cc-switch/pull/4319): 廃止された Homebrew tap 手順を削除、@tianpeng-dev に感謝。
|
||||||
|
- [#4522](https://github.com/farion1231/cc-switch/pull/4522): SubRouter プロバイダープリセットを追加、@abingyyds に感謝。
|
||||||
|
|
||||||
|
v3.16.3 リリース後に Codex プロキシ経路、使用量の課金、ローカルプロキシの堅牢性、プラットフォーム互換性の問題を報告してくださったすべてのユーザーにも感謝します。今回の多くのパッチは、こうした実際の利用シーンから得られた再現の手がかりに基づいています。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から、お使いのシステムに対応するビルドをダウンロードしてください。
|
||||||
|
|
||||||
|
### システム要件
|
||||||
|
|
||||||
|
| システム | 最低バージョン | アーキテクチャ |
|
||||||
|
| -------- | ------------------------ | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 以降 | x64 / ARM64 |
|
||||||
|
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 下表を参照 | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| ---------------------------------------- | -------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.4-Windows.msi` | **推奨** - 自動更新対応の MSI インストーラー |
|
||||||
|
| `CC-Switch-v3.16.4-Windows-Portable.zip` | ポータブル版、展開してそのまま実行できます |
|
||||||
|
|
||||||
|
Windows ARM64 デバイスをお使いの場合は、ファイル名に `arm64` 識別子が含まれる対応する制品を選択してください。
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| ファイル | 説明 |
|
||||||
|
| -------------------------------- | ------------------------------------------------------ |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.dmg` | **推奨** - DMG インストーラー、Applications へドラッグ |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.zip` | 展開して Applications へドラッグ、Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||||
|
|
||||||
|
Homebrew インストール:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux アセットは **x86_64** と **ARM64**(`aarch64`)の両方を提供します。ファイル名にアーキテクチャ識別子が含まれているため、マシンの `uname -m` 出力に合わせて選択してください:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||||
|
| --------------------------------------- | ----------- | ------------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` または `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` または `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 実行権限を付与して直接起動、または AUR を使用 |
|
||||||
|
| その他 / 不明 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# CC Switch v3.16.4
|
||||||
|
|
||||||
|
> 继 v3.16.3 把「用量计费做准」之后,这一版把重心放在打磨 Codex 代理链路与丰富用量 / 定价工具上——国产供应商原生 Responses 迁移、上游格式选择器与模型映射解耦、zstd 请求 / 错误体解压,以及一批工具调用与 OAuth 走代理的修复;同时新增本地代理请求覆盖、数据库版本过新时的应用内恢复屏、原生 Windows ARM64 构建,并带来一波预设与品牌更新(SubRouter、OpenCode Go、CTok→ETok 改名、Kimi 品牌刷新与 prime-partner 徽标)。
|
||||||
|
|
||||||
|
**[English →](v3.16.4-en.md) | [日本語版 →](v3.16.4-ja.md)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用攻略
|
||||||
|
|
||||||
|
本版以打磨与扩展为主,新增的能力主要落在用量面板与供应商表单的高级选项里,建议结合以下文档了解:
|
||||||
|
|
||||||
|
- **[Codex 桌面看不到自定义模型?](../guides/codex-desktop-custom-model-visibility-zh.md)**:不少用户反馈在 Codex 桌面应用里看不到配置的第三方 / 自定义模型。这是 Codex 桌面应用**上游自身的门控行为**(按官方登录状态放行模型选择器),并非 CC Switch 的本地配置问题,**本版(v3.16.4)未对此做改动**;文档里说明了原因,以及可用的缓解办法(保留官方登录 + 路由接管)。
|
||||||
|
- **[用量统计](../user-manual/zh/4-proxy/4.4-usage.md)**:了解用量看板的数据来源与统计口径。本版新增了从 models.dev 批量导入模型定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询,以及自定义日期范围的「实时结束时间」。
|
||||||
|
- **[设置](../user-manual/zh/1-getting-started/1.5-settings.md)**:本地代理请求覆盖(自定义请求头 / 请求体)、Codex 上游格式选择器与本地路由开关等都在供应商表单的高级选项里。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
>
|
||||||
|
> ## 唯一官方渠道声明(请务必阅读)
|
||||||
|
>
|
||||||
|
> CC Switch 是**完全免费、开源**的桌面应用,**不会向用户收取任何费用**。请仅通过下列官方渠道获取本软件:
|
||||||
|
>
|
||||||
|
> | 类别 | 唯一官方 |
|
||||||
|
> | -------- | ------------------------------------------------------------------------------ |
|
||||||
|
> | 官网 | **[ccswitch.io](https://ccswitch.io)** |
|
||||||
|
> | 源码 | **[github.com/farion1231/cc-switch](https://github.com/farion1231/cc-switch)** |
|
||||||
|
> | 下载 | **[GitHub Releases](https://github.com/farion1231/cc-switch/releases)** |
|
||||||
|
> | 作者 | **[@farion1231](https://github.com/farion1231)** |
|
||||||
|
> | 举报山寨 | **[GitHub Issues](https://github.com/farion1231/cc-switch/issues)** |
|
||||||
|
>
|
||||||
|
> **任何向你收费、要求充值、或索取登录凭据的"CC Switch"网站或客户端均为假冒**。如果你被诱导支付了费用,请立即停止操作并通过 GitHub Issues 反馈。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
|
||||||
|
CC Switch v3.16.4 是 v3.16.3 之后的一版维护更新。这一版围绕 Codex 代理链路做了一轮收紧——为多家具备原生 OpenAI Responses 端点的国产供应商切换到原生格式(省去 Responses→Chat 的路由接管转换)、把「上游格式」从「本地路由」开关里独立出来、补上 zstd 请求与错误响应体的解压,并修了一串工具调用与「OAuth 模块绕过全局代理」的问题。
|
||||||
|
|
||||||
|
与此同时,本版还丰富了用量与定价工具(从 models.dev 导入定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询、自定义日期范围的实时结束时间、GLM-5.2 与豆包 Seed 2.1 定价),新增了一批代理与韧性能力(自定义请求头 / 请求体覆盖、数据库版本过新时的应用内恢复屏、原生 Windows ARM64 构建),并带来一波预设与品牌更新(SubRouter 与 OpenCode Go 订阅、CTok→ETok 改名、Kimi 品牌刷新与 prime-partner 徽标、Kimi K2.7 Code 赞助横幅)。
|
||||||
|
|
||||||
|
**发布日期**:2026-06-27
|
||||||
|
|
||||||
|
**更新规模**:53 commits | 126 files changed | +8,149 / -1,016 lines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 重点内容
|
||||||
|
|
||||||
|
- **国产 Codex 供应商走原生 Responses**:千问 / 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax(国内 / 国际)现在直连各自的原生 Responses 端点,不再经过 Responses→Chat 的格式转换接管,链路更短、更稳。
|
||||||
|
- **本地代理请求覆盖**:供应商可配置自定义请求头与请求体覆盖,由本地代理在转发时应用,并对受保护的安全请求头做了拦截校验。
|
||||||
|
- **数据库版本过新的应用内恢复屏**:当 SQLite 版本比当前应用支持的更新时,不再死在「重试只会再次失败」的原生弹窗里,而是引导到一个可一键升级应用的恢复界面。
|
||||||
|
- **更丰富的用量 / 定价工具**:从 models.dev 批量导入模型定价、火山方舟 Coding / Agent Plan 的 AK/SK 用量查询、自定义日期范围的「实时结束时间」,以及 GLM-5.2 与豆包 Seed 2.1 的定价。
|
||||||
|
- **新预设与品牌更新**:新增 SubRouter 与 OpenCode Go 订阅预设,CTok 改名为 ETok,刷新 Kimi 品牌标识并为官方 Kimi 预设加上 prime-partner 心形徽标。
|
||||||
|
- **原生 Windows ARM64 构建**:发布产物新增原生 ARM64 版本,ARM 架构的 Windows 设备不再依赖 x64 模拟。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 新功能
|
||||||
|
|
||||||
|
### 数据库版本过新时的应用内恢复屏
|
||||||
|
|
||||||
|
当 SQLite 的 `user_version` 比当前应用支持的 `SCHEMA_VERSION` 更新时(例如降级回旧版、或被第三方客户端写过该文件),启动过去会死在一个原生的「重试 / 退出」弹窗里——而「重试」只会再次失败。现在应用会引导到一个专门的恢复界面:有可用更新时提供一键「升级应用」按钮(下载 + 安装 + 重启,带进度条),没有可用更新时则提示即便是最新版也读不了这个数据库。该「版本过新」检查在任何写库动作之前进行,因此应用永远不会对一个读不懂的数据库执行 DDL;恢复模式下的原生关闭会干净退出(此时托盘尚未创建)。([#4575](https://github.com/farion1231/cc-switch/pull/4575))
|
||||||
|
|
||||||
|
### 本地代理请求覆盖(自定义请求头与请求体)
|
||||||
|
|
||||||
|
供应商配置现在可以定义自定义请求头与请求体覆盖,由本地代理在转发时应用,并通过 Claude 与 Codex 供应商表单里的新字段暴露。输入会经过校验,其中包含一份受保护的请求头名单,用于阻止覆盖安全敏感的请求头。([#4589](https://github.com/farion1231/cc-switch/pull/4589))
|
||||||
|
|
||||||
|
### 火山方舟 Coding / Agent Plan 用量查询
|
||||||
|
|
||||||
|
用量面板现在可以查询火山方舟(Volcengine Ark)的 Coding Plan 与 Agent Plan 配额。由于方舟控制面 OpenAPI(`open.volcengineapi.com`)要求的是账号级 AccessKey 签名、而非推理 API key,用量脚本新增了独立的 AK/SK 输入区,并配有一个直达火山 IAM 密钥管理控制台(`https://console.volcengine.com/iam/keymanage`)的可点击链接;代理实现了火山签名 V4(一个 AWS SigV4 变体:固定的 canonical header 顺序、`HMAC-SHA256` 算法、`ark` 服务 scope)。它会先探测 `GetAFPUsage`(Agent Plan 的 5 小时 / 周 / 月配额)自动判定套餐,失败再回退到 `GetCodingPlanUsage`,从 `Level` 字段解析窗口标签(并对 `ResetTimestamp <= 0` 做守卫),同时在用量页脚、托盘菜单与四种语言里补上了 `monthly` 档标签。
|
||||||
|
|
||||||
|
### 从 models.dev 导入模型定价
|
||||||
|
|
||||||
|
「添加定价」面板新增了一个「从 models.dev 导入」按钮:拉取 `https://models.dev/api.json`,支持全文搜索整个目录,并通过与手动录入相同的 `update_model_pricing` 路径导入所选条目。导入的 model id 会按后端的 `clean_model_id_for_pricing` 规则归一化(剥供应商前缀、转小写、截断 `:` 后缀、把 `@` 映射为 `-`、丢掉 `[1m]` 标记),让落库的行真正能匹配成本归因查询。配套修复让「按范围回填零成本」改用 Rust 端按原始 model 别名(路由前缀、`:free` 变体、日期后缀)匹配,而不再用精确 SQL 字符串匹配,从而新定价的别名行能立刻被计价、而不必等下次启动回填(修复 [#4017](https://github.com/farion1231/cc-switch/issues/4017))。([#4079](https://github.com/farion1231/cc-switch/pull/4079))
|
||||||
|
|
||||||
|
### 原生 Windows ARM64 构建
|
||||||
|
|
||||||
|
发布产物现在包含原生的 Windows ARM64 制品,ARM 架构的 Windows 设备可以拿到对应的原生构建,不必再依赖 x64 模拟。发布矩阵也改为各平台独立运行(关闭 fail-fast),因此某个任务缺少密钥而失败(例如 fork 里的 macOS 签名)不会再把尚未完成的同级任务一并取消。([#3950](https://github.com/farion1231/cc-switch/pull/3950))
|
||||||
|
|
||||||
|
### 自定义日期范围的实时结束时间
|
||||||
|
|
||||||
|
自定义日期范围选择器新增了一个「结束时间跟随当前时间」勾选框;开启后结束时间变为只读并自动跟随此刻,因此用量数据始终反映从所选起点到当下的实时消耗。这在 Coding Plan 的 5 小时配额窗口里尤其有用。`liveEndTime` 已纳入 React Query 的缓存键,因此一个实时范围和一个端点相同的固定范围不会再共用同一个陈旧缓存项。([#4438](https://github.com/farion1231/cc-switch/pull/4438))
|
||||||
|
|
||||||
|
### 会话详情头显示源文件名
|
||||||
|
|
||||||
|
会话详情头现在会在项目目录旁显示会话日志的文件名(悬停看完整路径、可点击复制),方便用户直接从界面定位并打开底层的 JSONL 文件。对于像 ~70 字符的 Codex rollout 这类没有空格的长文件名,会截断到 `max-w-[200px]`,避免在窄窗口里溢出到操作按钮区。([#4113](https://github.com/farion1231/cc-switch/pull/4113))
|
||||||
|
|
||||||
|
### 导入按钮的未托管 Skill 提示
|
||||||
|
|
||||||
|
顶栏的 Skills 导入按钮现在会在本地存在未托管的 Skill 可导入时显示一个绿点与提示,让你一眼看出磁盘上的 Skill 还没被纳管。该扫描在挂载时执行一次,并在多次导航间共享(30s `staleTime` + `keepPreviousData`),避免重复磁盘 IO。
|
||||||
|
|
||||||
|
### OpenCode Go 订阅预设
|
||||||
|
|
||||||
|
新增 OpenCode Go(`opencode.ai/zen/go`)预设,覆盖 Claude、Codex 与 OpenCode,使用可直接粘贴的纯 API key(无 OAuth)。Codex 预设走 `openai_chat` 转换并带 GLM / Kimi / DeepSeek / MiMo 模型目录(且不带静态 `codexChatReasoning`,按每个模型推断能力),OpenCode 则通过 `@ai-sdk/openai-compatible` 指向 `/zen/go/v1`。四个 OpenCode Go 预设——Claude、Claude Desktop、Codex、OpenCode——都带上了推荐链接与应用内推广文案;推广横幅现在仅凭 `partnerPromotionKey` 即可展示(不再绑定 `isPartner`),因此一个预设可以展示推荐推广却不获得金色付费合作伙伴星标(这也顺带让既有的 MiniMax 推广重新显示出来)。
|
||||||
|
|
||||||
|
### Prime-Partner 预设徽标与排序
|
||||||
|
|
||||||
|
第一方 Moonshot Kimi 预设(Kimi / Kimi For Coding / Kimi K2.7 Code)现在被标记为 prime partner:不再显示金色星标,而是渲染一颗实心金色心形(无徽标边框),并在默认(Original)排序里浮到官方分类预设之后、其余之前。分组用三路 partition 实现,每组保持内部顺序,且一个同时被标为 prime-partner 的官方预设只会留在官方组里。
|
||||||
|
|
||||||
|
### GLM-5.2 与豆包 Seed 2.1 定价
|
||||||
|
|
||||||
|
种子模型定价现在包含 GLM-5.2([#4385](https://github.com/farion1231/cc-switch/pull/4385))与豆包 Seed 2.1 Pro / Turbo,让这些模型的用量被正确计价、而不是记成零成本。豆包价格采用火山官方 list 价(按约 7.14 的汇率折算);`cache_creation` 保持为 0,因为豆包按时间而非按 token 写入计费缓存存储,既有的 2.0 行也保留以供历史记账。
|
||||||
|
|
||||||
|
### Kimi For Coding 自动压缩窗口
|
||||||
|
|
||||||
|
Kimi For Coding 预设现在把 `CLAUDE_CODE_AUTO_COMPACT_WINDOW` 默认设为 262144,与 Kimi 官方文档一致,并通过 `templateValues` 暴露,方便用户为将来的模型或性能调优自定义该值。([#4401](https://github.com/farion1231/cc-switch/pull/4401))
|
||||||
|
|
||||||
|
### SubRouter 合作伙伴供应商
|
||||||
|
|
||||||
|
新增 SubRouter(`subrouter.ai`,一个让一把 key 访问多模型多供应商的 AI 中转聚合商)作为预设,覆盖全部 7 个受管应用——Anthropic 格式端点用于 Claude Code / Claude Desktop / OpenClaw / Hermes,OpenAI 兼容的 `/v1` 端点(`gpt-5.5`)用于 Codex 与 OpenCode,Gemini 兼容的 `/v1beta` 端点(`gemini-3.5-flash`)用于 Gemini CLI——带上自有品牌图标、金色合作伙伴星标、四语推广文案,以及预填为 API key 注册地址的推荐注册链接(`?aff=l3ri`)。([#4522](https://github.com/farion1231/cc-switch/pull/4522))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 变更
|
||||||
|
|
||||||
|
### 国产 Codex 供应商走原生 Responses API
|
||||||
|
|
||||||
|
多家国产供应商(千问 / DashScope 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax 国内 / 国际)现在暴露了原生的 OpenAI Responses 端点,因此它们的 Codex 预设切换到 `apiFormat: "openai_responses"`,直连上游而不再经过 Responses→Chat 的路由接管转换。丢掉不再需要的 `codexChatReasoning` 与 `modelCatalog` 也让「本地路由映射」开关默认保持未勾选。SiliconFlow 托管的 MiniMax 仍保持 `openai_chat`,因为那是第三方端点、并非 MiniMax 自家 base_url。其余仍走 chat 的供应商也刷新了过期的 model id(GLM 5.1→5.2、StepFun 3.5-flash-2603→3.7-flash、Ling 2.5-1T→2.6-1T)。
|
||||||
|
|
||||||
|
### 上游格式选择器与模型映射开关解耦
|
||||||
|
|
||||||
|
Codex 供应商表单此前把 Chat 格式转换与路由接管(模型映射)绑在同一个开关上,导致一个提供原生 Responses API 的供应商无法在不强制 Chat Completions 转换的情况下使用模型映射。现在「上游格式」(Chat Completions / Responses)成了一个独立、始终可见的选择器,而本地路由开关只负责控制高级子区(模型映射目录,以及格式为 Chat 时的推理能力)。它的初始状态由已保存目录是否存在派生,不新增持久化字段;`codexConfig` 的四语(zh / en / ja / zh-TW)文案也随之重写。
|
||||||
|
|
||||||
|
### 豆包 Seed 2.1 Pro 预设
|
||||||
|
|
||||||
|
DouBaoSeed 预设现在在全部 6 个客户端(claude、claude-desktop、codex、opencode、openclaw、hermes)指向 `doubao-seed-2-1-pro`(替换 `doubao-seed-2-0-code-preview-latest`),展示名更新为「Doubao Seed 2.1 Pro」,并把 OpenClaw 的成本字段从 0.002 / 0.006 订正为 0.84 / 4.2 美元每百万 token 以匹配新模型。
|
||||||
|
|
||||||
|
### CTok 改名为 ETok
|
||||||
|
|
||||||
|
随着厂商对域名、端点与商标的更名,所有面向用户的品牌从 CTok 迁移到 ETok(`ctok.ai`→`etok.ai`、`api.ctok.ai`→`api.etok.ai`,以及内部 id、展示名、图标和 README 合作伙伴横幅),覆盖每一个客户端预设。Codex 历史迁移白名单里仍保留 `ctok` 作为旧 id、与新 `etok` 并存,以保证改名后存量用户的本地会话历史仍被正确分桶。
|
||||||
|
|
||||||
|
### Kimi 预设命名统一
|
||||||
|
|
||||||
|
OpenCode 与 OpenClaw 此前被标为「Kimi K2.7 Code」的 Kimi 预设,更名为与其它应用一致的「Kimi」(OpenCode 的供应商展示名也一并更名);模型标签仍保留「Kimi K2.7 Code」,因为它描述的是实际模型。
|
||||||
|
|
||||||
|
### JSON 编辑器暗色模式
|
||||||
|
|
||||||
|
用量脚本弹窗、供应商表单与通用供应商表单里的 CodeMirror `JsonEditor` 现在会通过 `useDarkMode()` 跟随应用主题,切换到 `oneDark` 编辑器主题,而不再在应用其余部分已是暗色时仍停留在亮色。([#4556](https://github.com/farion1231/cc-switch/pull/4556))
|
||||||
|
|
||||||
|
### 更紧凑的「添加供应商」标题与底部提示
|
||||||
|
|
||||||
|
「添加供应商」对话框把标题到页签、页签到卡片的纵向间距从 24px 收到 12px,并新增一个始终可见的固定底部提示,引导用户在选好预设后填写下方字段。`FullScreenPanel` 新增可选的 `contentClassName` 属性,让内边距覆盖只作用于此面板、不影响其它共用它的面板。
|
||||||
|
|
||||||
|
### 主题自适应的 Kimi 标识
|
||||||
|
|
||||||
|
内联的 Kimi 占位标记替换为厂商刷新后的标识。K 字形使用 `currentColor`,因此会跟随主题文字色(亮色模式深、暗色模式白),而品牌点缀色固定为新的 `#1783FF`,元数据回退色也相应对齐。
|
||||||
|
|
||||||
|
### 移除 Fable 5 Verified 纪念横幅
|
||||||
|
|
||||||
|
设置「关于」页不再显示 3.16.3 为标明特别构建而加在应用名旁的 Fable 5 Verified 纪念横幅;横幅图片及其标记被移除,「关于」面板回到标准的版本徽标布局。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
### Copilot / Codex OAuth 请求现在遵循全局代理
|
||||||
|
|
||||||
|
`CopilotAuthManager` 与 `CodexOAuthManager` 在构造时写死了 `Client::new()`,导致它们的认证流程(换 token、拉 `/models` 列表、判定 model vendor、device-code 与 OAuth 刷新请求)无视配置的全局代理、直连目标服务。在 Copilot 上,直连会让 `/models` 返回 0 个 Claude 模型,使 live 模型解析失效,上游以 `400 model_not_supported` 拒绝请求。现在两个 manager 都改为每次请求从共享客户端现取(`crate::proxy::http_client::get()`),从而遵循全局代理 URL 并支持运行时热更新。修复 [#2016](https://github.com/farion1231/cc-switch/issues/2016)、[#2931](https://github.com/farion1231/cc-switch/issues/2931)。([#4583](https://github.com/farion1231/cc-switch/pull/4583))
|
||||||
|
|
||||||
|
### 压缩请求体与错误体的解压
|
||||||
|
|
||||||
|
Codex Desktop 在对 Codex 后端认证时会发送 zstd 压缩的请求体,这会破坏本地代理路由,因为处理器直接用 `serde_json` 解析原始压缩字节。代理现在会在 JSON 解析前对请求体解压(gzip / br / deflate,外加新增的 zstd 支持,包括 `gzip, zstd` 这类堆叠编码),覆盖三个 Codex 处理器,并剥掉过期的 `content-encoding` / `content-length` / `transfer-encoding` 请求头让转发器重新生成。上游非 2xx 的错误体也以同样方式解压,因此压缩过的限流与鉴权细节不再被丢弃、对客户端隐藏。修复 [#3764](https://github.com/farion1231/cc-switch/issues/3764)、[#3696](https://github.com/farion1231/cc-switch/issues/3696)。([#3817](https://github.com/farion1231/cc-switch/pull/3817))
|
||||||
|
|
||||||
|
### DeepSeek 端点 `thinking: disabled` 的 400 错误
|
||||||
|
|
||||||
|
DeepSeek 的 Anthropic 兼容端点会拒绝 `thinking.type=disabled` 与 effort 参数共存的请求、返回 HTTP 400,这会破坏 Claude Code 2.1.166+ 那些硬编码 `thinking: disabled` 的子 agent(Workflow / Dynamic Workflow)。代理现在不是去覆盖客户端的意图,而是对官方 DeepSeek 端点剥掉冲突的 `output_config.effort` / `reasoning_effort` 参数,因为子 agent 本就不需要展示推理。([#4239](https://github.com/farion1231/cc-switch/pull/4239))
|
||||||
|
|
||||||
|
### 回滚 Anthropic system 消息上提
|
||||||
|
|
||||||
|
回滚了 [#3775](https://github.com/farion1231/cc-switch/pull/3775) 把 Anthropic 兼容供应商的 `role=system` 消息从 `messages[]` 上提到顶层 `system` 字段的改动。DeepSeek 端点本就原生接受内联的 system 消息,而该重写改变了请求前缀;保持消息原位能保留 prompt 前缀,避免一处疑似的缓存命中率回退(参见 [#4297](https://github.com/farion1231/cc-switch/issues/4297))。来自 #3775 的、不相关的 Windows 测试修复以及 tool-thinking-history 归一化都保留。
|
||||||
|
|
||||||
|
### Chat 工具调用缺函数名
|
||||||
|
|
||||||
|
一些上游会在流式工具调用增量里发送空的或缺失的函数名,过去这会产生无效的 Codex Chat 输出项(或一个 `unknown_tool` 回退)。现在累积的工具调用状态不会再被空增量覆盖,而那些始终没拿到 `call_id` 与有效名字的工具调用会在最终化阶段被跳过,覆盖流式、非流式与旧版 `function_call` 三条路径。([#4159](https://github.com/farion1231/cc-switch/pull/4159))
|
||||||
|
|
||||||
|
### 恢复 Codex 缓存的工具调用字段
|
||||||
|
|
||||||
|
当 Codex 发起一个引用 `previous_response_id` 的后续 Chat 请求时,它的 `function_call` 项可能只携带 `call_id`。历史增强此前只回填 `reasoning` / `reasoning_content`,留空了函数的 `name`、`arguments`、`status` 等字段;现在它会从历史里恢复全部缓存的工具调用字段,让该调用能为 Chat 上游正确重建。([#4160](https://github.com/farion1231/cc-switch/pull/4160))
|
||||||
|
|
||||||
|
### config.toml 里重复的 Codex base_url 条目
|
||||||
|
|
||||||
|
把 Codex 的 `base_url` 写入 `config.toml` 时此前每个区段只替换或移除一个匹配的赋值,因此一个已经含多行 `base_url` 的区段会留下多余项、累积重复。`setCodexBaseUrl` 现在会折叠目标区段或顶层的所有匹配(替换第一处、移除其余),TOML 的 `base_url` 正则也处理了转义引号。([#4316](https://github.com/farion1231/cc-switch/pull/4316))
|
||||||
|
|
||||||
|
### 历史迁移探测 CODEX_SQLITE_HOME 的状态库
|
||||||
|
|
||||||
|
Codex 会话历史迁移此前只扫描 `~/.codex/state_5.sqlite` 与 `config.toml` 的 `sqlite_home` 位置,因此当 Codex 的 SQLite 状态通过 `CODEX_SQLITE_HOME` 环境变量被重定位时,状态库从未被扫描、其 threads 仍留在旧的供应商分桶里。第三方与统一会话两套迁移共用的 `codex_state_db_paths` 辅助函数现在会回退到 `CODEX_SQLITE_HOME`(`config` 里的 `sqlite_home` 仍优先)。
|
||||||
|
|
||||||
|
### 供应商终端尊重用户 shell
|
||||||
|
|
||||||
|
在 macOS / Linux 上启动供应商终端时此前硬编码了 `bash`,导致 zsh / fish 用户的 rc 文件不会加载。启动器现在会从 `$SHELL` 检测用户默认 shell(macOS 回退 `/bin/zsh`、Linux 回退 `/bin/bash`)并以干净启动的 flag exec 进去,而启动脚本本身改走 POSIX `sh` 以保证可移植性(例如 fish,以及 `/bin/sh` 可能不存在的 NixOS)。([#4140](https://github.com/farion1231/cc-switch/pull/4140),修复 [#1546](https://github.com/farion1231/cc-switch/issues/1546))
|
||||||
|
|
||||||
|
### Claude MCP 路径尊重自定义配置目录
|
||||||
|
|
||||||
|
当配置了自定义的 Claude 配置目录时,MCP server 的读写现在会解析到该目录下的 MCP 文件、而非默认位置,让 MCP 状态按 profile 隔离。此前对旧文件的「访问即拷贝」迁移被移除,改为直接解析覆盖路径。([#3431](https://github.com/farion1231/cc-switch/pull/3431))
|
||||||
|
|
||||||
|
### 搜索后预设结果可点击
|
||||||
|
|
||||||
|
在「添加供应商」预设选择器里搜索后,结果一度无法点击或选中。那个与输入打架、会吃掉首字符(如「gateway」→「ateway」)的 `requestAnimationFrame` `select()` 被移除,开箱即点路径的输入自动聚焦被恢复,当搜索框已打开时按 Ctrl/Cmd+F 也接上了重新聚焦。供应商列表的打字守卫也被收窄到 Ctrl/Cmd+F 分支,从而 Escape 仍能关闭搜索面板。([#4315](https://github.com/farion1231/cc-switch/pull/4315))
|
||||||
|
|
||||||
|
### Skills 浏览与供应商卡片显示修复
|
||||||
|
|
||||||
|
修复了若干显示与交互问题:浏览 skills.sh 时仓库管理操作保持可用,仓库返回空结果时刷新也保持可用;供应商卡片上过长的供应商名与网站 URL 现在会截断而非溢出;OMO 模型变体下拉会截断所选标签并配全文提示;Select 菜单项会在当前选中项上显示对勾。([#4323](https://github.com/farion1231/cc-switch/pull/4323))
|
||||||
|
|
||||||
|
### 切换设置页签时重置滚动
|
||||||
|
|
||||||
|
在设置对话框里切换页签会保留上一个页签的滚动位置,有时会停在新页签的中途;现在每当激活页签变化时,滚动容器都会重置到顶部。([#4165](https://github.com/farion1231/cc-switch/pull/4165))
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
### Kimi 置顶赞助横幅
|
||||||
|
|
||||||
|
全部四种 README 语言(en / zh / ja / de)顶部的置顶赞助横幅现在换成了 Kimi K2.7 Code,取代此前的 MiniMax M2.7 横幅。文案反映 K2.7 Code 发布(一个面向编程的 agentic 模型,思考 token 用量较 K2.6 降低约 30%),横幅改由仓库内资源(`assets/partners/banners/kimi-banner-en.png` / `kimi-banner-zh.png`)提供、不再走 Moonshot CDN,并附一个指向 `aff=cc-switch` Moonshot 控制台的可点击行动号召。
|
||||||
|
|
||||||
|
### Codex 统一会话历史攻略
|
||||||
|
|
||||||
|
新增三语(zh / en / ja)攻略,讲清统一 Codex 会话历史开关的开启迁移(启用时)与按账本还原(禁用时)到底做了什么、为什么会话数据从不会真正删除(只改标记 + 自动备份),以及如何核对文件是真在磁盘上、还是只是被归到了另一个供应商抽屉里。它包含一张针对常见「我的会话不见了」误解的症状对照表,以及 macOS / Linux / Windows 的磁盘核对命令,并作为首项链入 v3.16.3 的「使用攻略」release notes。
|
||||||
|
|
||||||
|
### 简化 Homebrew 安装说明
|
||||||
|
|
||||||
|
安装指南不再要求用户在 `brew install --cask cc-switch` 之前先运行 `brew tap farion1231/ccswitch`;这个已废弃的 tap 步骤已从 en / ja / zh 用户手册里移除,cask 现在可直接安装。([#4319](https://github.com/farion1231/cc-switch/pull/4319))
|
||||||
|
|
||||||
|
### Star-History 全球排名徽标
|
||||||
|
|
||||||
|
在全部四种 README 语言里、既有的 Trendshift 徽标旁新增了一个 star-history 全球排名徽标,并带亮 / 暗主题变体。
|
||||||
|
|
||||||
|
### 火山方舟 Coding Plan 活动链接
|
||||||
|
|
||||||
|
ByteDance / 火山方舟赞助条目里的「中国大陆地区的开发者请点击这里」链接现在指向火山的 `ai618` 活动页,取代此前的 `codingplan` 推荐 URL,覆盖全部四种 README 语言。
|
||||||
|
|
||||||
|
### CCSub 赞助横幅矢量资源
|
||||||
|
|
||||||
|
把低分辨率的 `ccsub.jpg` 赞助 logo 替换为矢量的 `ccsub.svg`,并从 2046x648 letterbox 到 2046x850(约 2.406:1),使其与其它赞助表横幅匹配、以相同的 62px 高度渲染。全部四种 README 语言都指向新资源。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级提醒
|
||||||
|
|
||||||
|
### 国产 Codex 供应商原生 Responses 迁移
|
||||||
|
|
||||||
|
本版把多家具备原生 Responses 端点的国产供应商(千问 / 百炼、小米 MiMo、火山豆包、美团 LongCat、MiniMax 国内 / 国际)的 Codex 预设切换为 `openai_responses` 并移除了 `modelCatalog`。已经基于这些预设配置过的存量供应商不受影响、配置保持原样;如果你希望改用原生 Responses(省去格式转换接管),可以重新从预设选择一次并保存。SiliconFlow 托管的 MiniMax 仍走 `openai_chat`,不在此次迁移之列。
|
||||||
|
|
||||||
|
### 数据库版本过新的恢复
|
||||||
|
|
||||||
|
如果你曾用更高版本的 CC Switch 打开过数据库、再切回旧版,旧版启动时会进入新的「数据库版本过新」恢复屏,并引导你升级到能读懂该数据库的版本。这是预期行为——升级到最新版即可恢复正常。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险提示
|
||||||
|
|
||||||
|
本版本继续沿用此前版本对反向代理类功能的风险提示。
|
||||||
|
|
||||||
|
**Codex OAuth 反向代理**:使用 ChatGPT 订阅的 Codex OAuth 反代可能违反 OpenAI 服务条款,详情见 [v3.13.0 release notes](v3.13.0-zh.md#️-风险提示)。
|
||||||
|
|
||||||
|
**Codex 第三方供应商 Chat 路由**:通过 CC Switch 本地代理把 Codex 请求转换并转发到第三方供应商时,各供应商对计费、合规与数据留存的约束不同,请在使用前阅读目标供应商的服务条款。
|
||||||
|
|
||||||
|
**Claude Desktop 第三方供应商代理切换**:通过 CC Switch 内置代理网关把 Claude Desktop 的请求转到第三方供应商时,同样需要遵守目标供应商的计费、合规与数据留存约束。
|
||||||
|
|
||||||
|
用户启用上述功能即表示自行承担相关风险。CC Switch 不对因使用这些功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
感谢以下贡献者在 v3.16.4 中提交的功能与修复:
|
||||||
|
|
||||||
|
- [#3817](https://github.com/farion1231/cc-switch/pull/3817):转发前解压请求体并支持 zstd,感谢 @chenx-dust。
|
||||||
|
- [#4583](https://github.com/farion1231/cc-switch/pull/4583):修复 Copilot / Codex OAuth 模块绕过全局代理导致 Claude 模型 400,感谢 @zymouse。
|
||||||
|
- [#4589](https://github.com/farion1231/cc-switch/pull/4589):新增本地代理请求覆盖(自定义请求头与请求体),感谢 @mfzzf。
|
||||||
|
- [#4575](https://github.com/farion1231/cc-switch/pull/4575):新增数据库版本过新时的应用内恢复屏,感谢 @SaladDay。
|
||||||
|
- [#4556](https://github.com/farion1231/cc-switch/pull/4556):为多处 JsonEditor 接入暗色模式,感谢 @TanKimzeg。
|
||||||
|
- [#4438](https://github.com/farion1231/cc-switch/pull/4438):新增自定义日期范围的实时结束时间,感谢 @arichyx。
|
||||||
|
- [#3950](https://github.com/farion1231/cc-switch/pull/3950):新增 Windows ARM64 发布支持,感谢 @MOON-DREAM-STARS。
|
||||||
|
- [#4401](https://github.com/farion1231/cc-switch/pull/4401):为 Kimi For Coding 预设添加 CLAUDE_CODE_AUTO_COMPACT_WINDOW,感谢 @cyijun。
|
||||||
|
- [#4323](https://github.com/farion1231/cc-switch/pull/4323):修复 Skills 管理与模型配置的交互展示,感谢 @thisTom。
|
||||||
|
- [#3431](https://github.com/farion1231/cc-switch/pull/3431):对齐自定义配置目录的 Claude MCP 路径,感谢 @makoMakoGo。
|
||||||
|
- [#4159](https://github.com/farion1231/cc-switch/pull/4159):跳过缺函数名的 Chat 工具调用,感谢 @hueifeng。
|
||||||
|
- [#4385](https://github.com/farion1231/cc-switch/pull/4385):新增 glm-5.2 定价,感谢 @arichyx。
|
||||||
|
- [#4079](https://github.com/farion1231/cc-switch/pull/4079):支持从 models.dev 导入模型定价,感谢 @kingcanfish。
|
||||||
|
- [#4315](https://github.com/farion1231/cc-switch/pull/4315):修复搜索预设后结果无法点击选中,感谢 @RuixeWolf。
|
||||||
|
- [#4316](https://github.com/farion1231/cc-switch/pull/4316):防止重复的 Codex base_url 条目,感谢 @jeffwcx。
|
||||||
|
- [#4140](https://github.com/farion1231/cc-switch/pull/4140):让供应商终端尊重用户 shell,感谢 @zkforge。
|
||||||
|
- [#4113](https://github.com/farion1231/cc-switch/pull/4113):在会话详情头显示源文件名,感谢 @xu-song。
|
||||||
|
- [#4160](https://github.com/farion1231/cc-switch/pull/4160):恢复 Codex 缓存的工具调用字段,感谢 @chen-985211。
|
||||||
|
- [#4239](https://github.com/farion1231/cc-switch/pull/4239):DeepSeek 端点 thinking:disabled 时剥掉 effort 参数,感谢 @maskshell。
|
||||||
|
- [#4165](https://github.com/farion1231/cc-switch/pull/4165):切换设置页签时重置滚动,感谢 @Muleizhang。
|
||||||
|
- [#4319](https://github.com/farion1231/cc-switch/pull/4319):移除已废弃的 Homebrew tap 步骤,感谢 @tianpeng-dev。
|
||||||
|
- [#4522](https://github.com/farion1231/cc-switch/pull/4522):新增 SubRouter 供应商预设,感谢 @abingyyds。
|
||||||
|
|
||||||
|
也感谢所有在 v3.16.3 发布后反馈 Codex 代理链路、用量计费、本地代理稳健性与平台兼容性问题的用户,很多补丁都来自这些真实使用场景里的复现线索。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下载与安装
|
||||||
|
|
||||||
|
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||||
|
|
||||||
|
### 系统要求
|
||||||
|
|
||||||
|
| 系统 | 最低版本 | 架构 |
|
||||||
|
| ------- | -------------------------- | ----------------------------------- |
|
||||||
|
| Windows | Windows 10 及以上 | x64 / ARM64 |
|
||||||
|
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||||
|
| Linux | 见下表 | x64 / ARM64 |
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| ---------------------------------------- | ----------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.4-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||||
|
| `CC-Switch-v3.16.4-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||||
|
|
||||||
|
Windows ARM64 设备请选择文件名中带 `arm64` 标识的对应制品。
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
| -------------------------------- | --------------------------------------------- |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||||
|
| `CC-Switch-v3.16.4-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||||
|
|
||||||
|
Homebrew 安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
更新:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew upgrade --cask cc-switch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
Linux 资产同时提供 **x86_64** 和 **ARM64**(`aarch64`)两种架构。资产文件名中包含架构标识,请按你机器的 `uname -m` 输出选择对应版本:
|
||||||
|
|
||||||
|
- `CC-Switch-v3.16.4-Linux-x86_64.AppImage` / `.deb` / `.rpm`
|
||||||
|
- `CC-Switch-v3.16.4-Linux-arm64.AppImage` / `.deb` / `.rpm`
|
||||||
|
|
||||||
|
| 发行版 | 推荐格式 | 安装方式 |
|
||||||
|
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||||
|
| Ubuntu / Debian / Linux Mint / Pop!\_OS | `.deb` | `sudo dpkg -i CC-Switch-*.deb` 或 `sudo apt install ./CC-Switch-*.deb` |
|
||||||
|
| Fedora / RHEL / CentOS / Rocky Linux | `.rpm` | `sudo rpm -i CC-Switch-*.rpm` 或 `sudo dnf install ./CC-Switch-*.rpm` |
|
||||||
|
| openSUSE | `.rpm` | `sudo zypper install ./CC-Switch-*.rpm` |
|
||||||
|
| Arch Linux / Manjaro | `.AppImage` | 添加执行权限后直接运行,或使用 AUR |
|
||||||
|
| 其他发行版 / 不确定 | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||||
@@ -122,9 +122,6 @@ npm install -g @google/gemini-cli
|
|||||||
### Option 1: Homebrew (Recommended)
|
### Option 1: Homebrew (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add tap
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
|
|
||||||
# Install
|
# Install
|
||||||
brew install --cask cc-switch
|
brew install --cask cc-switch
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Codex presets fall into two groups by upstream protocol.
|
|||||||
|-------------|-------------|
|
|-------------|-------------|
|
||||||
| DeepSeek | DeepSeek models |
|
| DeepSeek | DeepSeek models |
|
||||||
| Zhipu GLM / GLM en | Zhipu AI GLM models |
|
| Zhipu GLM / GLM en | Zhipu AI GLM models |
|
||||||
| Kimi | Moonshot Kimi models |
|
| Kimi / Kimi For Coding | Moonshot Kimi models |
|
||||||
| MiniMax / MiniMax en | MiniMax models |
|
| MiniMax / MiniMax en | MiniMax models |
|
||||||
| StepFun / StepFun en | StepFun Step models |
|
| StepFun / StepFun en | StepFun Step models |
|
||||||
| Baidu Qianfan Coding Plan | Baidu Qianfan coding plan |
|
| Baidu Qianfan Coding Plan | Baidu Qianfan coding plan |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ v3.13.0 provides **ready-to-use built-in templates** for the following categorie
|
|||||||
|
|
||||||
| Category | Covered Providers | Template Type |
|
| Category | Covered Providers | Template Type |
|
||||||
| ------------------ | --------------------------------------------------------- | ------------------------------- |
|
| ------------------ | --------------------------------------------------------- | ------------------------------- |
|
||||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | Plan quota (with usage progress) |
|
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine | Plan quota (with usage progress) |
|
||||||
| Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query |
|
| Third-party balance| DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | Official balance query |
|
||||||
|
|
||||||
> **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic.
|
> **Tip**: Beyond these built-in templates, for uncovered providers you can use the **custom script** approach (see below) to write your own query logic.
|
||||||
|
|||||||
@@ -122,9 +122,6 @@ npm install -g @google/gemini-cli
|
|||||||
### 方法 1:Homebrew(推奨)
|
### 方法 1:Homebrew(推奨)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# tap を追加
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
|
|
||||||
# インストール
|
# インストール
|
||||||
brew install --cask cc-switch
|
brew install --cask cc-switch
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Codex プリセットは上流プロトコルにより 2 種類に分かれま
|
|||||||
|----------|------|
|
|----------|------|
|
||||||
| DeepSeek | DeepSeek モデル |
|
| DeepSeek | DeepSeek モデル |
|
||||||
| Zhipu GLM / GLM en | Zhipu AI の GLM モデル |
|
| Zhipu GLM / GLM en | Zhipu AI の GLM モデル |
|
||||||
| Kimi | Moonshot Kimi モデル |
|
| Kimi / Kimi For Coding | Moonshot Kimi モデル |
|
||||||
| MiniMax / MiniMax en | MiniMax モデル |
|
| MiniMax / MiniMax en | MiniMax モデル |
|
||||||
| StepFun / StepFun en | StepFun Step モデル |
|
| StepFun / StepFun en | StepFun Step モデル |
|
||||||
| Baidu Qianfan Coding Plan | 百度千帆コーディングプラン |
|
| Baidu Qianfan Coding Plan | 百度千帆コーディングプラン |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ v3.13.0 では以下のカテゴリに **すぐに使える内蔵テンプレー
|
|||||||
|
|
||||||
| カテゴリ | 対象プロバイダー | テンプレートタイプ |
|
| カテゴリ | 対象プロバイダー | テンプレートタイプ |
|
||||||
| --------------- | --------------------------------------------------------- | ------------------------- |
|
| --------------- | --------------------------------------------------------- | ------------------------- |
|
||||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | プランクォータ(使用進捗付き) |
|
| Token Plan | Kimi / Zhipu GLM / MiniMax / Volcengine(火山方舟) | プランクォータ(使用進捗付き) |
|
||||||
| 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ |
|
| 第三者残高 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 公式残高クエリ |
|
||||||
|
|
||||||
> **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。
|
> **ヒント**:上記の内蔵テンプレート以外で対象外のプロバイダーには、**カスタムスクリプト** 方式(下記参照)で独自のクエリロジックを記述できます。
|
||||||
|
|||||||
@@ -138,9 +138,6 @@ npm install -g @google/gemini-cli --registry=https://registry.npmmirror.com
|
|||||||
### 方式一:Homebrew(推荐)
|
### 方式一:Homebrew(推荐)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 添加 tap
|
|
||||||
brew tap farion1231/ccswitch
|
|
||||||
|
|
||||||
# 安装
|
# 安装
|
||||||
brew install --cask cc-switch
|
brew install --cask cc-switch
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Codex 预设按上游协议分两类。
|
|||||||
|----------|------|
|
|----------|------|
|
||||||
| DeepSeek | DeepSeek 模型 |
|
| DeepSeek | DeepSeek 模型 |
|
||||||
| 智谱 GLM / GLM en | 智谱 AI 的 GLM 模型 |
|
| 智谱 GLM / GLM en | 智谱 AI 的 GLM 模型 |
|
||||||
| Kimi | Moonshot Kimi 模型 |
|
| Kimi / Kimi For Coding | Moonshot Kimi 模型 |
|
||||||
| MiniMax / MiniMax en | MiniMax 模型 |
|
| MiniMax / MiniMax en | MiniMax 模型 |
|
||||||
| StepFun / StepFun en | 阶跃星辰 Step 模型 |
|
| StepFun / StepFun en | 阶跃星辰 Step 模型 |
|
||||||
| 百度千帆 Coding Plan | 百度千帆编程套餐 |
|
| 百度千帆 Coding Plan | 百度千帆编程套餐 |
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ v3.13.0 为以下类别提供了**开箱即用的内置模板**,启用后无
|
|||||||
|
|
||||||
| 类别 | 覆盖供应商 | 模板类型 |
|
| 类别 | 覆盖供应商 | 模板类型 |
|
||||||
| ---------- | --------------------------------------------------------- | ----------------------- |
|
| ---------- | --------------------------------------------------------- | ----------------------- |
|
||||||
| Token Plan | Kimi / Zhipu GLM / MiniMax | 套餐配额(带使用进度) |
|
| Token Plan | Kimi / Zhipu GLM / MiniMax / 火山方舟 | 套餐配额(带使用进度) |
|
||||||
| 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 |
|
| 第三方余额 | DeepSeek / StepFun / SiliconFlow / OpenRouter / Novita AI | 官方余额查询 |
|
||||||
|
|
||||||
> 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。
|
> 💡 除了以上内置模板外,对未被覆盖的供应商,你可以使用**自定义脚本**方式(见下文)编写自己的查询逻辑。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.16.2",
|
"version": "3.16.4",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ dependencies = [
|
|||||||
"futures-lite",
|
"futures-lite",
|
||||||
"parking",
|
"parking",
|
||||||
"polling",
|
"polling",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"slab",
|
"slab",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -215,7 +215,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"event-listener",
|
"event-listener",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -241,7 +241,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"slab",
|
"slab",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -421,6 +421,29 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bindgen"
|
||||||
|
version = "0.69.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cexpr",
|
||||||
|
"clang-sys",
|
||||||
|
"itertools",
|
||||||
|
"lazy_static",
|
||||||
|
"lazycell",
|
||||||
|
"log",
|
||||||
|
"prettyplease",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"regex",
|
||||||
|
"rustc-hash 1.1.0",
|
||||||
|
"shlex",
|
||||||
|
"syn 2.0.117",
|
||||||
|
"which",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -735,7 +758,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.16.2"
|
version = "3.16.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arboard",
|
"arboard",
|
||||||
@@ -802,6 +825,7 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winreg 0.52.0",
|
"winreg 0.52.0",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -810,6 +834,15 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cexpr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||||
|
dependencies = [
|
||||||
|
"nom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfb"
|
name = "cfb"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
@@ -867,6 +900,17 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clang-sys"
|
||||||
|
version = "1.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
|
||||||
|
dependencies = [
|
||||||
|
"glob",
|
||||||
|
"libc",
|
||||||
|
"libloading 0.8.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -936,6 +980,15 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "convert_case"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-segmentation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.1"
|
version = "0.18.1"
|
||||||
@@ -1178,7 +1231,7 @@ version = "0.99.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"convert_case",
|
"convert_case 0.4.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
@@ -1385,6 +1438,12 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "either"
|
||||||
|
version = "1.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.6"
|
version = "3.0.6"
|
||||||
@@ -1914,7 +1973,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2222,6 +2281,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "home"
|
||||||
|
version = "0.5.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.29.1"
|
version = "0.29.1"
|
||||||
@@ -2616,6 +2684,15 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -2753,6 +2830,18 @@ dependencies = [
|
|||||||
"selectors 0.24.0",
|
"selectors 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazy_static"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lazycell"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "leb128fmt"
|
name = "leb128fmt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2779,7 +2868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gtk-sys",
|
"gtk-sys",
|
||||||
"libloading",
|
"libloading 0.7.4",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2799,6 +2888,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libloading"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -2822,6 +2921,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.4.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -2954,6 +3059,12 @@ version = "0.3.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "minisign-verify"
|
name = "minisign-verify"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -3071,6 +3182,16 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "7.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"minimal-lexical",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3839,7 +3960,7 @@ dependencies = [
|
|||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4002,7 +4123,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn-proto",
|
"quinn-proto",
|
||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
@@ -4022,7 +4143,7 @@ dependencies = [
|
|||||||
"lru-slab",
|
"lru-slab",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"ring",
|
"ring",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -4278,6 +4399,12 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "relative-path"
|
||||||
|
version = "1.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rend"
|
name = "rend"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -4447,6 +4574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
|
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rquickjs-core",
|
"rquickjs-core",
|
||||||
|
"rquickjs-macro",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4455,15 +4583,34 @@ version = "0.8.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
|
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"relative-path",
|
||||||
"rquickjs-sys",
|
"rquickjs-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rquickjs-macro"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
|
||||||
|
dependencies = [
|
||||||
|
"convert_case 0.6.0",
|
||||||
|
"fnv",
|
||||||
|
"ident_case",
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"proc-macro-crate 1.3.1",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rquickjs-core",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rquickjs-sys"
|
name = "rquickjs-sys"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
|
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bindgen",
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4507,6 +4654,12 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustc-hash"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -4522,6 +4675,19 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "0.38.44"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.4.15",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -4531,7 +4697,7 @@ dependencies = [
|
|||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.12.1",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4775,7 +4941,7 @@ dependencies = [
|
|||||||
"phf 0.13.1",
|
"phf 0.13.1",
|
||||||
"phf_codegen 0.13.1",
|
"phf_codegen 0.13.1",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"rustc-hash",
|
"rustc-hash 2.1.1",
|
||||||
"servo_arc 0.4.3",
|
"servo_arc 0.4.3",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
@@ -5814,7 +5980,7 @@ dependencies = [
|
|||||||
"fastrand",
|
"fastrand",
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6783,6 +6949,18 @@ version = "0.1.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "which"
|
||||||
|
version = "4.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
"home",
|
||||||
|
"once_cell",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi"
|
name = "winapi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -7527,7 +7705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"gethostname",
|
"gethostname",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"x11rb-protocol",
|
"x11rb-protocol",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7544,7 +7722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7601,7 +7779,7 @@ dependencies = [
|
|||||||
"hex",
|
"hex",
|
||||||
"libc",
|
"libc",
|
||||||
"ordered-stream",
|
"ordered-stream",
|
||||||
"rustix",
|
"rustix 1.1.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.16.2"
|
version = "3.16.4"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -43,6 +43,7 @@ reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks
|
|||||||
arboard = "3.6"
|
arboard = "3.6"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
brotli = "7"
|
brotli = "7"
|
||||||
|
zstd = "0.13"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
@@ -91,6 +92,9 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] }
|
|||||||
winreg = "0.52"
|
winreg = "0.52"
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_UI_Shell"] }
|
||||||
|
|
||||||
|
[target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies]
|
||||||
|
rquickjs = { version = "0.8", features = ["bindgen"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.5"
|
objc2 = "0.5"
|
||||||
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
objc2-app-kit = { version = "0.2", features = ["NSColor"] }
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ pub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[
|
|||||||
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
env_key: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||||
supports_1m: true,
|
supports_1m: true,
|
||||||
},
|
},
|
||||||
|
// fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法
|
||||||
|
// 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把
|
||||||
|
// 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为
|
||||||
|
// Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排),
|
||||||
|
// 与此处物理顺序无关。
|
||||||
|
ClaudeDesktopDefaultRoute {
|
||||||
|
route_id: "claude-fable-5",
|
||||||
|
env_key: "ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||||
|
supports_1m: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -238,11 +248,16 @@ pub fn is_claude_safe_model_id(model: &str) -> bool {
|
|||||||
|
|
||||||
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
|
// 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值
|
||||||
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
|
// (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。
|
||||||
["sonnet-", "opus-", "haiku-"].iter().any(|prefix| {
|
// Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable
|
||||||
route_tail
|
// (app.asar 内 ["sonnet","opus","haiku","fable","mythos"]),故 claude-fable-*
|
||||||
.strip_prefix(prefix)
|
// 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。
|
||||||
.is_some_and(|rest| !rest.is_empty())
|
["sonnet-", "opus-", "haiku-", "fable-"]
|
||||||
})
|
.iter()
|
||||||
|
.any(|prefix| {
|
||||||
|
route_tail
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.is_some_and(|rest| !rest.is_empty())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
|
fn inference_model_json(spec: &InferenceModelSpec) -> Value {
|
||||||
@@ -693,8 +708,8 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
|||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
|
// 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布
|
||||||
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
|
// 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的
|
||||||
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/sonnet 归类
|
// 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet
|
||||||
// 到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
// 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。
|
||||||
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
|
// 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的
|
||||||
// 安全模型名回落,避免非 Claude route 被误映射。
|
// 安全模型名回落,避免非 Claude route 被误映射。
|
||||||
if !is_claude_safe_model_id(requested) {
|
if !is_claude_safe_model_id(requested) {
|
||||||
@@ -704,6 +719,18 @@ pub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<V
|
|||||||
routes
|
routes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
|
.find(|route| claude_role_keyword(&route.route_id) == Some(role))
|
||||||
|
// 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,
|
||||||
|
// 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。
|
||||||
|
// 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。
|
||||||
|
.or_else(|| {
|
||||||
|
(role == "fable")
|
||||||
|
.then(|| {
|
||||||
|
routes
|
||||||
|
.iter()
|
||||||
|
.find(|route| claude_role_keyword(&route.route_id) == Some("opus"))
|
||||||
|
})
|
||||||
|
.flatten()
|
||||||
|
})
|
||||||
.map(|route| route.upstream_model.clone())
|
.map(|route| route.upstream_model.clone())
|
||||||
})
|
})
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -754,15 +781,17 @@ fn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按角色关键词(opus / haiku / sonnet)归类一个 Claude 模型名/route_id。
|
/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。
|
||||||
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
|
/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。
|
||||||
/// 与前端 `routeRoleFromId` 同序(opus → haiku → sonnet)。
|
/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。
|
||||||
fn claude_role_keyword(model: &str) -> Option<&'static str> {
|
fn claude_role_keyword(model: &str) -> Option<&'static str> {
|
||||||
let normalized = model.to_ascii_lowercase();
|
let normalized = model.to_ascii_lowercase();
|
||||||
if normalized.contains("opus") {
|
if normalized.contains("opus") {
|
||||||
Some("opus")
|
Some("opus")
|
||||||
} else if normalized.contains("haiku") {
|
} else if normalized.contains("haiku") {
|
||||||
Some("haiku")
|
Some("haiku")
|
||||||
|
} else if normalized.contains("fable") {
|
||||||
|
Some("fable")
|
||||||
} else if normalized.contains("sonnet") {
|
} else if normalized.contains("sonnet") {
|
||||||
Some("sonnet")
|
Some("sonnet")
|
||||||
} else {
|
} else {
|
||||||
@@ -1310,8 +1339,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_proxy_port(db: &Database, port: u16) {
|
fn set_proxy_port(db: &Database, port: u16) {
|
||||||
let mut config = crate::proxy::types::ProxyConfig::default();
|
let config = crate::proxy::types::ProxyConfig {
|
||||||
config.listen_port = port;
|
listen_port: port,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
futures::executor::block_on(db.update_proxy_config(config)).expect("update proxy config");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1650,6 +1681,127 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("gpt-5"));
|
assert!(err.to_string().contains("gpt-5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_desktop_proxy_maps_fable_to_opus_tier() {
|
||||||
|
// issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置
|
||||||
|
// fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。
|
||||||
|
let mut provider = proxy_provider("proxy");
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_mut()
|
||||||
|
.expect("meta")
|
||||||
|
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||||
|
(
|
||||||
|
"claude-opus-4-8".to_string(),
|
||||||
|
ClaudeDesktopModelRoute {
|
||||||
|
model: "upstream-opus".to_string(),
|
||||||
|
label_override: None,
|
||||||
|
supports_1m: Some(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-sonnet-4-6".to_string(),
|
||||||
|
ClaudeDesktopModelRoute {
|
||||||
|
model: "upstream-sonnet".to_string(),
|
||||||
|
label_override: None,
|
||||||
|
supports_1m: Some(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let mapped = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect("fable should fall back to the opus tier");
|
||||||
|
assert_eq!(mapped["model"], json!("upstream-opus"));
|
||||||
|
|
||||||
|
// 带 [1m] 标记与日期后缀的形态也应命中同一回落。
|
||||||
|
let mapped_one_m = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5[1m]", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect("fable with [1m] marker should fall back to the opus tier");
|
||||||
|
assert_eq!(mapped_one_m["model"], json!("upstream-opus"));
|
||||||
|
|
||||||
|
let mapped_dated = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5-20260609", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect("dated fable alias should fall back to the opus tier");
|
||||||
|
assert_eq!(mapped_dated["model"], json!("upstream-opus"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_desktop_proxy_fable_without_opus_route_still_errors() {
|
||||||
|
// 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。
|
||||||
|
let mut provider = proxy_provider("proxy");
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_mut()
|
||||||
|
.expect("meta")
|
||||||
|
.claude_desktop_model_routes = std::collections::HashMap::from([(
|
||||||
|
"claude-sonnet-4-6".to_string(),
|
||||||
|
ClaudeDesktopModelRoute {
|
||||||
|
model: "upstream-sonnet".to_string(),
|
||||||
|
label_override: None,
|
||||||
|
supports_1m: Some(true),
|
||||||
|
},
|
||||||
|
)]);
|
||||||
|
|
||||||
|
let err = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect_err("fable without an opus route should fail");
|
||||||
|
assert!(err.to_string().contains("claude-fable-5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn claude_desktop_proxy_maps_fable_to_dedicated_route() {
|
||||||
|
// Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置
|
||||||
|
// 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。
|
||||||
|
let mut provider = proxy_provider("proxy");
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_mut()
|
||||||
|
.expect("meta")
|
||||||
|
.claude_desktop_model_routes = std::collections::HashMap::from([
|
||||||
|
(
|
||||||
|
"claude-opus-4-8".to_string(),
|
||||||
|
ClaudeDesktopModelRoute {
|
||||||
|
model: "upstream-opus".to_string(),
|
||||||
|
label_override: None,
|
||||||
|
supports_1m: Some(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-fable-5".to_string(),
|
||||||
|
ClaudeDesktopModelRoute {
|
||||||
|
model: "upstream-fable".to_string(),
|
||||||
|
label_override: None,
|
||||||
|
supports_1m: Some(true),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 精确匹配优先命中 fable 档
|
||||||
|
let mapped = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect("explicit fable route should match");
|
||||||
|
assert_eq!(mapped["model"], json!("upstream-fable"));
|
||||||
|
|
||||||
|
// 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus
|
||||||
|
let mapped_dated = map_proxy_request_model(
|
||||||
|
json!({"model": "claude-fable-5-20260609", "messages": []}),
|
||||||
|
&provider,
|
||||||
|
)
|
||||||
|
.expect("dated fable alias should map via fable role keyword");
|
||||||
|
assert_eq!(mapped_dated["model"], json!("upstream-fable"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
|
fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {
|
||||||
let mut provider = proxy_provider("proxy");
|
let mut provider = proxy_provider("proxy");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::env;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path};
|
use crate::config::{atomic_write, get_claude_mcp_path};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
/// 需要在 Windows 上用 cmd /c 包装的命令
|
/// 需要在 Windows 上用 cmd /c 包装的命令
|
||||||
@@ -98,51 +98,9 @@ pub struct McpStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_config_path() -> PathBuf {
|
fn user_config_path() -> PathBuf {
|
||||||
ensure_mcp_override_migrated();
|
|
||||||
get_claude_mcp_path()
|
get_claude_mcp_path()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_mcp_override_migrated() {
|
|
||||||
if crate::settings::get_claude_override_dir().is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_path = get_claude_mcp_path();
|
|
||||||
if new_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let legacy_path = get_default_claude_mcp_path();
|
|
||||||
if !legacy_path.exists() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(parent) = new_path.parent() {
|
|
||||||
if let Err(err) = fs::create_dir_all(parent) {
|
|
||||||
log::warn!("创建 MCP 目录失败: {err}");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
match fs::copy(&legacy_path, &new_path) {
|
|
||||||
Ok(_) => {
|
|
||||||
log::info!(
|
|
||||||
"已根据覆盖目录复制 MCP 配置: {} -> {}",
|
|
||||||
legacy_path.display(),
|
|
||||||
new_path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"复制 MCP 配置失败: {} -> {}: {}",
|
|
||||||
legacy_path.display(),
|
|
||||||
new_path.display(),
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
fn read_json_value(path: &Path) -> Result<Value, AppError> {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(serde_json::json!({}));
|
return Ok(serde_json::json!({}));
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) ->
|
|||||||
/// Extract the upstream base URL from a Codex `config.toml` string.
|
/// Extract the upstream base URL from a Codex `config.toml` string.
|
||||||
///
|
///
|
||||||
/// Prefers the active `[model_providers.<model_provider>].base_url`, falling
|
/// Prefers the active `[model_providers.<model_provider>].base_url`, falling
|
||||||
/// back to a top-level `base_url` when no model provider is selected.
|
/// back to a top-level `base_url`. Deliberately never reads a non-active
|
||||||
|
/// `[model_providers.*]` section — the frontend `extractCodexBaseUrl`
|
||||||
|
/// (`getRecoverableBaseUrlAssignments`) excludes those too, and a leftover
|
||||||
|
/// section unrelated to the active provider must not leak into `{{baseUrl}}`.
|
||||||
pub fn extract_codex_base_url(config_text: &str) -> Option<String> {
|
pub fn extract_codex_base_url(config_text: &str) -> Option<String> {
|
||||||
let doc = config_text.parse::<toml::Value>().ok()?;
|
let doc = config_text.parse::<toml::Value>().ok()?;
|
||||||
|
|
||||||
@@ -1040,16 +1043,197 @@ pub fn read_codex_live_settings() -> Result<Value, AppError> {
|
|||||||
Ok(json!({ "auth": auth, "config": cfg_text }))
|
Ok(json!({ "auth": auth, "config": cfg_text }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `[model_providers.custom]` entry that makes an official (ChatGPT OAuth)
|
||||||
|
/// provider behave like Codex's built-in `openai` entry while running under
|
||||||
|
/// the shared custom id: `requires_openai_auth` routes auth to the ChatGPT
|
||||||
|
/// login in `auth.json` (base_url then defaults to the official Codex
|
||||||
|
/// backend), `name = "OpenAI"` keeps Codex's `is_openai()` feature gates
|
||||||
|
/// (web search, remote compaction), and `supports_websockets` restores the
|
||||||
|
/// built-in default that custom entries otherwise lose.
|
||||||
|
fn codex_unified_official_provider_table() -> toml_edit::Table {
|
||||||
|
let mut table = toml_edit::Table::new();
|
||||||
|
table["name"] = toml_edit::value("OpenAI");
|
||||||
|
table["requires_openai_auth"] = toml_edit::value(true);
|
||||||
|
table["supports_websockets"] = toml_edit::value(true);
|
||||||
|
table["wire_api"] = toml_edit::value("responses");
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_matches_codex_unified_official_provider(table: &toml_edit::Table) -> bool {
|
||||||
|
table.len() == 4
|
||||||
|
&& table.get("name").and_then(|item| item.as_str()) == Some("OpenAI")
|
||||||
|
&& table
|
||||||
|
.get("requires_openai_auth")
|
||||||
|
.and_then(|item| item.as_bool())
|
||||||
|
== Some(true)
|
||||||
|
&& table
|
||||||
|
.get("supports_websockets")
|
||||||
|
.and_then(|item| item.as_bool())
|
||||||
|
== Some(true)
|
||||||
|
&& table.get("wire_api").and_then(|item| item.as_str()) == Some("responses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一 Codex 会话历史:把官方供应商的 live 配置改写为以共享的
|
||||||
|
/// `custom` model_provider 标识运行(认证仍走 `auth.json` 的 ChatGPT 登录),
|
||||||
|
/// 使开关开启后创建的官方会话与第三方会话共用同一个 resume 历史桶。
|
||||||
|
///
|
||||||
|
/// 两种情况拒绝注入、原样返回:
|
||||||
|
/// - 配置已有显式 `model_provider`:用户手工指定的路由不被覆盖;
|
||||||
|
/// - 配置已有形态不同的 `[model_providers.custom]` 表:设置 `model_provider`
|
||||||
|
/// 会激活这张我们不认识的表(可能带第三方 base_url/token,会把 ChatGPT
|
||||||
|
/// OAuth 流量路由到错误后端),宁可让开关对该配置不生效。
|
||||||
|
pub fn inject_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
|
||||||
|
let mut doc = config_text
|
||||||
|
.parse::<DocumentMut>()
|
||||||
|
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||||
|
|
||||||
|
if doc.get("model_provider").is_some() {
|
||||||
|
return Ok(config_text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existing_custom_conflicts = doc
|
||||||
|
.get("model_providers")
|
||||||
|
.and_then(|item| item.as_table())
|
||||||
|
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
|
||||||
|
.and_then(|item| item.as_table())
|
||||||
|
.is_some_and(|table| !table_matches_codex_unified_official_provider(table));
|
||||||
|
if existing_custom_conflicts {
|
||||||
|
log::warn!(
|
||||||
|
"官方 Codex 配置已存在自定义 [model_providers.custom],跳过统一会话路由注入以避免激活未知路由"
|
||||||
|
);
|
||||||
|
return Ok(config_text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
doc["model_provider"] = toml_edit::value(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
|
||||||
|
|
||||||
|
if doc.get("model_providers").is_none() {
|
||||||
|
let mut parent = toml_edit::Table::new();
|
||||||
|
parent.set_implicit(true);
|
||||||
|
doc["model_providers"] = toml_edit::Item::Table(parent);
|
||||||
|
}
|
||||||
|
if let Some(providers) = doc["model_providers"].as_table_mut() {
|
||||||
|
if !providers.contains_key(CC_SWITCH_CODEX_MODEL_PROVIDER_ID) {
|
||||||
|
providers.insert(
|
||||||
|
CC_SWITCH_CODEX_MODEL_PROVIDER_ID,
|
||||||
|
toml_edit::Item::Table(codex_unified_official_provider_table()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inject_codex_unified_session_bucket` 的反向操作:从配置文本里剥掉注入的
|
||||||
|
/// 统一会话路由,保证切换回填不会把它带进数据库的存储配置(关闭开关后
|
||||||
|
/// 切换即可完全还原)。仅当形态与注入产物完全一致时才剥离;第三方模板和
|
||||||
|
/// 用户自定义的 `custom` 条目(带 base_url 等差异字段)原样保留。
|
||||||
|
pub fn strip_codex_unified_session_bucket(config_text: &str) -> Result<String, AppError> {
|
||||||
|
if !config_text.contains("model_provider") {
|
||||||
|
return Ok(config_text.to_string());
|
||||||
|
}
|
||||||
|
let mut doc = config_text
|
||||||
|
.parse::<DocumentMut>()
|
||||||
|
.map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
|
||||||
|
|
||||||
|
if doc.get("model_provider").and_then(|item| item.as_str())
|
||||||
|
!= Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
|
||||||
|
{
|
||||||
|
return Ok(config_text.to_string());
|
||||||
|
}
|
||||||
|
let matches_injected = doc
|
||||||
|
.get("model_providers")
|
||||||
|
.and_then(|item| item.as_table())
|
||||||
|
.and_then(|providers| providers.get(CC_SWITCH_CODEX_MODEL_PROVIDER_ID))
|
||||||
|
.and_then(|item| item.as_table())
|
||||||
|
.is_some_and(table_matches_codex_unified_official_provider);
|
||||||
|
if !matches_injected {
|
||||||
|
return Ok(config_text.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.as_table_mut().remove("model_provider");
|
||||||
|
let providers_empty = doc["model_providers"]
|
||||||
|
.as_table_mut()
|
||||||
|
.map(|providers| {
|
||||||
|
providers.remove(CC_SWITCH_CODEX_MODEL_PROVIDER_ID);
|
||||||
|
providers.is_empty()
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
if providers_empty {
|
||||||
|
doc.as_table_mut().remove("model_providers");
|
||||||
|
}
|
||||||
|
Ok(doc.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统一会话开关开启时,把官方供应商 `{ auth, config }` 设置对象中的
|
||||||
|
/// config 文本注入共享 custom 路由;开关关闭或非官方供应商时不做改动。
|
||||||
|
///
|
||||||
|
/// 普通 live 写入(`write_codex_live_for_provider`)与代理接管备份
|
||||||
|
/// (`update_live_backup_from_provider`)两条落盘路径共用:接管期间
|
||||||
|
/// live 归代理所有,注入必须进备份,接管释放恢复的 live 才带统一路由。
|
||||||
|
pub fn apply_codex_unified_session_bucket_to_settings(
|
||||||
|
category: Option<&str>,
|
||||||
|
settings: &mut Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if category != Some("official") || !crate::settings::unify_codex_session_history() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let config_text = settings
|
||||||
|
.get("config")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let injected = inject_codex_unified_session_bucket(&config_text)?;
|
||||||
|
if injected != config_text {
|
||||||
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
|
obj.insert("config".to_string(), Value::String(injected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backfill helper: strip the unified-session injection from a live
|
||||||
|
/// `{ auth, config }` settings object before it is stored back to the DB.
|
||||||
|
pub fn strip_codex_unified_session_bucket_from_settings(
|
||||||
|
settings: &mut Value,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Some(config_text) = settings
|
||||||
|
.get("config")
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(str::to_string)
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let stripped = strip_codex_unified_session_bucket(&config_text)?;
|
||||||
|
if stripped != config_text {
|
||||||
|
if let Some(obj) = settings.as_object_mut() {
|
||||||
|
obj.insert("config".to_string(), Value::String(stripped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Route a Codex live write between full auth+config or config-only.
|
/// Route a Codex live write between full auth+config or config-only.
|
||||||
///
|
///
|
||||||
/// Official providers with usable login material own `auth.json`. Third-party
|
/// Official providers with usable login material own `auth.json`. Third-party
|
||||||
/// providers only touch `config.toml` when the compatibility setting is enabled
|
/// providers only touch `config.toml` when the compatibility setting is enabled
|
||||||
/// so the user's ChatGPT login cache survives provider switches.
|
/// so the user's ChatGPT login cache survives provider switches.
|
||||||
|
///
|
||||||
|
/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由
|
||||||
|
/// (见 `inject_codex_unified_session_bucket`)。
|
||||||
pub fn write_codex_live_for_provider(
|
pub fn write_codex_live_for_provider(
|
||||||
category: Option<&str>,
|
category: Option<&str>,
|
||||||
auth: &Value,
|
auth: &Value,
|
||||||
config_text: Option<&str>,
|
config_text: Option<&str>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
|
let unified_official_config =
|
||||||
|
if category == Some("official") && crate::settings::unify_codex_session_history() {
|
||||||
|
Some(inject_codex_unified_session_bucket(
|
||||||
|
config_text.unwrap_or(""),
|
||||||
|
)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let config_text = unified_official_config.as_deref().or(config_text);
|
||||||
|
|
||||||
let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth))
|
let should_write_auth = (category == Some("official") && codex_auth_has_login_material(auth))
|
||||||
|| (category != Some("official")
|
|| (category != Some("official")
|
||||||
&& !crate::settings::preserve_codex_official_auth_on_switch());
|
&& !crate::settings::preserve_codex_official_auth_on_switch());
|
||||||
@@ -1254,6 +1438,153 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_injects_for_empty_official_config() {
|
||||||
|
let injected = inject_codex_unified_session_bucket("").expect("inject");
|
||||||
|
let doc: toml::Table = toml::from_str(&injected).expect("parse injected config");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
doc.get("model_provider").and_then(|v| v.as_str()),
|
||||||
|
Some(CC_SWITCH_CODEX_MODEL_PROVIDER_ID)
|
||||||
|
);
|
||||||
|
let custom = doc["model_providers"][CC_SWITCH_CODEX_MODEL_PROVIDER_ID]
|
||||||
|
.as_table()
|
||||||
|
.expect("custom provider table");
|
||||||
|
assert_eq!(custom.get("name").and_then(|v| v.as_str()), Some("OpenAI"));
|
||||||
|
assert_eq!(
|
||||||
|
custom.get("requires_openai_auth").and_then(|v| v.as_bool()),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom.get("supports_websockets").and_then(|v| v.as_bool()),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
custom.get("wire_api").and_then(|v| v.as_str()),
|
||||||
|
Some("responses")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_preserves_other_keys_and_explicit_routing() {
|
||||||
|
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
|
||||||
|
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
|
||||||
|
assert!(injected.contains("model_catalog_json"));
|
||||||
|
assert!(injected.contains("model_provider = \"custom\""));
|
||||||
|
|
||||||
|
// 用户显式指定过 model_provider 的官方配置不被覆盖
|
||||||
|
let explicit = "model_provider = \"openai_https\"\n";
|
||||||
|
let unchanged = inject_codex_unified_session_bucket(explicit).expect("inject");
|
||||||
|
assert_eq!(unchanged, explicit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_skips_conflicting_custom_table() {
|
||||||
|
// 残留的非注入形态 custom 表:设置 model_provider 会把官方流量
|
||||||
|
// 路由到表里的第三方端点,必须整体拒绝注入。
|
||||||
|
let stale = r#"[model_providers.custom]
|
||||||
|
name = "Relay"
|
||||||
|
base_url = "https://relay.example/v1"
|
||||||
|
"#;
|
||||||
|
let unchanged = inject_codex_unified_session_bucket(stale).expect("inject");
|
||||||
|
assert_eq!(unchanged, stale);
|
||||||
|
|
||||||
|
// 已是注入形态的 custom 表(如重复注入)则照常补上 model_provider
|
||||||
|
let injected_once = inject_codex_unified_session_bucket("").expect("inject");
|
||||||
|
let reinjected = inject_codex_unified_session_bucket(&injected_once).expect("re-inject");
|
||||||
|
assert_eq!(reinjected, injected_once);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_strip_round_trips_injection() {
|
||||||
|
let injected = inject_codex_unified_session_bucket("").expect("inject");
|
||||||
|
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
|
||||||
|
assert_eq!(stripped.trim(), "");
|
||||||
|
|
||||||
|
let with_catalog = "model_catalog_json = \"cc-switch-model-catalog.json\"\n";
|
||||||
|
let injected = inject_codex_unified_session_bucket(with_catalog).expect("inject");
|
||||||
|
let stripped = strip_codex_unified_session_bucket(&injected).expect("strip");
|
||||||
|
assert_eq!(stripped, with_catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_strip_keeps_third_party_custom_entry() {
|
||||||
|
// 第三方模板同样用 custom 路由,但条目带 base_url 等差异字段,
|
||||||
|
// 形态不等于注入产物,必须原样保留。
|
||||||
|
let third_party = r#"model_provider = "custom"
|
||||||
|
|
||||||
|
[model_providers.custom]
|
||||||
|
name = "Relay"
|
||||||
|
base_url = "https://relay.example/v1"
|
||||||
|
wire_api = "responses"
|
||||||
|
requires_openai_auth = true
|
||||||
|
"#;
|
||||||
|
let untouched = strip_codex_unified_session_bucket(third_party).expect("strip");
|
||||||
|
assert_eq!(untouched, third_party);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unified_session_bucket_strip_from_settings_only_touches_config() {
|
||||||
|
let injected = inject_codex_unified_session_bucket("").expect("inject");
|
||||||
|
let mut settings = json!({
|
||||||
|
"auth": { "tokens": { "access_token": "secret" } },
|
||||||
|
"config": injected,
|
||||||
|
});
|
||||||
|
strip_codex_unified_session_bucket_from_settings(&mut settings).expect("strip settings");
|
||||||
|
assert_eq!(
|
||||||
|
settings
|
||||||
|
.get("config")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim),
|
||||||
|
Some("")
|
||||||
|
);
|
||||||
|
assert!(settings.pointer("/auth/tokens/access_token").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_base_url_prefers_active_provider_section() {
|
||||||
|
let input = r#"model_provider = "azure"
|
||||||
|
|
||||||
|
[model_providers.azure]
|
||||||
|
base_url = "https://azure.example.com/v1"
|
||||||
|
|
||||||
|
[model_providers.other]
|
||||||
|
base_url = "https://other.example.com/v1"
|
||||||
|
"#;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
extract_codex_base_url(input).as_deref(),
|
||||||
|
Some("https://azure.example.com/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_base_url_falls_back_to_top_level_only() {
|
||||||
|
let top_level = r#"base_url = "https://top-level.example.com/v1""#;
|
||||||
|
assert_eq!(
|
||||||
|
extract_codex_base_url(top_level).as_deref(),
|
||||||
|
Some("https://top-level.example.com/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the frontend extractCodexBaseUrl: a non-active provider section
|
||||||
|
// is never a credential source, whether the active provider points
|
||||||
|
// elsewhere (e.g. the built-in "openai") or none is selected at all.
|
||||||
|
#[test]
|
||||||
|
fn extract_base_url_ignores_non_active_provider_sections() {
|
||||||
|
let mismatched = r#"model_provider = "openai"
|
||||||
|
|
||||||
|
[model_providers.custom]
|
||||||
|
base_url = "https://leftover.example.com/v1"
|
||||||
|
"#;
|
||||||
|
assert_eq!(extract_codex_base_url(mismatched), None);
|
||||||
|
|
||||||
|
let no_active = r#"[model_providers.any]
|
||||||
|
base_url = "https://single.example.com/v1"
|
||||||
|
"#;
|
||||||
|
assert_eq!(extract_codex_base_url(no_active), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prepare_provider_live_config_rejects_key_without_config() {
|
fn prepare_provider_live_config_rejects_key_without_config() {
|
||||||
let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "")
|
let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "")
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ use crate::services::subscription::SubscriptionQuota;
|
|||||||
pub async fn get_coding_plan_quota(
|
pub async fn get_coding_plan_quota(
|
||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
|
// 火山方舟用控制面 AK/SK 签名查询用量;其他供应商不传,沿用 api_key。
|
||||||
|
access_key_id: Option<String>,
|
||||||
|
secret_access_key: Option<String>,
|
||||||
) -> Result<SubscriptionQuota, String> {
|
) -> Result<SubscriptionQuota, String> {
|
||||||
crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key).await
|
crate::services::coding_plan::get_coding_plan_quota(
|
||||||
|
&base_url,
|
||||||
|
&api_key,
|
||||||
|
access_key_id.as_deref(),
|
||||||
|
secret_access_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ pub async fn fetch_models_for_config(
|
|||||||
api_key: String,
|
api_key: String,
|
||||||
is_full_url: Option<bool>,
|
is_full_url: Option<bool>,
|
||||||
models_url: Option<String>,
|
models_url: Option<String>,
|
||||||
|
custom_user_agent: Option<String>,
|
||||||
) -> Result<Vec<FetchedModel>, String> {
|
) -> Result<Vec<FetchedModel>, String> {
|
||||||
|
// 与转发 / 检测路径共用 parse_custom_user_agent:非法 UA 静默忽略(不阻断取模型)。
|
||||||
|
let user_agent = crate::provider::parse_custom_user_agent(custom_user_agent.as_deref())
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
model_fetch::fetch_models(
|
model_fetch::fetch_models(
|
||||||
&base_url,
|
&base_url,
|
||||||
&api_key,
|
&api_key,
|
||||||
is_full_url.unwrap_or(false),
|
is_full_url.unwrap_or(false),
|
||||||
models_url.as_deref(),
|
models_url.as_deref(),
|
||||||
|
user_agent,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,9 +514,19 @@ async fn query_provider_usage_inner(
|
|||||||
let (base_url, api_key) =
|
let (base_url, api_key) =
|
||||||
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
resolve_coding_plan_credentials(&app_type, provider, usage_script);
|
||||||
|
|
||||||
let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key)
|
// 火山方舟用账号 AK/SK 签名查询用量(存于 usage_script,与推理 api_key 分离);
|
||||||
.await
|
// 其他供应商为 None,service 层沿用 api_key。
|
||||||
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
let access_key_id = usage_script.and_then(|s| s.access_key_id.clone());
|
||||||
|
let secret_access_key = usage_script.and_then(|s| s.secret_access_key.clone());
|
||||||
|
|
||||||
|
let quota = crate::services::coding_plan::get_coding_plan_quota(
|
||||||
|
&base_url,
|
||||||
|
&api_key,
|
||||||
|
access_key_id.as_deref(),
|
||||||
|
secret_access_key.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to query coding plan: {e}"))?;
|
||||||
|
|
||||||
// 将 SubscriptionQuota 转换为 UsageResult
|
// 将 SubscriptionQuota 转换为 UsageResult
|
||||||
if !quota.success {
|
if !quota.success {
|
||||||
@@ -1086,6 +1096,8 @@ mod native_query_credentials_tests {
|
|||||||
template_type: Some("token_plan".to_string()),
|
template_type: Some("token_plan".to_string()),
|
||||||
auto_query_interval: None,
|
auto_query_interval: None,
|
||||||
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
coding_plan_provider: coding_plan_provider.map(str::to_string),
|
||||||
|
access_key_id: None,
|
||||||
|
secret_access_key: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
|
|
||||||
|
/// 应用更新下载进度(通过 `update-download-progress` 事件发给前端)。
|
||||||
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
struct UpdateDownloadProgress {
|
||||||
|
downloaded: u64,
|
||||||
|
total: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_settings_for_save(
|
fn merge_settings_for_save(
|
||||||
mut incoming: crate::settings::AppSettings,
|
mut incoming: crate::settings::AppSettings,
|
||||||
@@ -35,24 +43,11 @@ fn merge_settings_for_save(
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if incoming.local_migrations.is_none() {
|
// local_migrations 是纯后端状态(迁移完成标记),前端没有合法的修改场景,
|
||||||
incoming.local_migrations = existing.local_migrations.clone();
|
// 无条件取现有值。若按 incoming 透传:后端清掉 marker(如关闭统一会话
|
||||||
} else if let (Some(incoming_migrations), Some(existing_migrations)) =
|
// 开关)后、前端 query 缓存刷新前的一次全量保存会把旧 marker 重放回来,
|
||||||
(&mut incoming.local_migrations, &existing.local_migrations)
|
// 重新开启时被"复活"的标记挡住而漏迁。
|
||||||
{
|
incoming.local_migrations = existing.local_migrations.clone();
|
||||||
if incoming_migrations
|
|
||||||
.codex_third_party_history_provider_bucket_v1
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
incoming_migrations.codex_third_party_history_provider_bucket_v1 = existing_migrations
|
|
||||||
.codex_third_party_history_provider_bucket_v1
|
|
||||||
.clone();
|
|
||||||
}
|
|
||||||
if incoming_migrations.codex_provider_template_v1.is_none() {
|
|
||||||
incoming_migrations.codex_provider_template_v1 =
|
|
||||||
existing_migrations.codex_provider_template_v1.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
incoming
|
incoming
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,13 +59,117 @@ pub async fn get_settings() -> Result<crate::settings::AppSettings, String> {
|
|||||||
|
|
||||||
/// 保存设置
|
/// 保存设置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
|
pub async fn save_settings(
|
||||||
|
state: tauri::State<'_, crate::store::AppState>,
|
||||||
|
settings: crate::settings::AppSettings,
|
||||||
|
) -> Result<bool, String> {
|
||||||
let existing = crate::settings::get_settings();
|
let existing = crate::settings::get_settings();
|
||||||
let merged = merge_settings_for_save(settings, &existing);
|
let merged = merge_settings_for_save(settings, &existing);
|
||||||
|
let unify_codex_changed =
|
||||||
|
merged.unify_codex_session_history != existing.unify_codex_session_history;
|
||||||
|
let unify_codex_enabled = merged.unify_codex_session_history;
|
||||||
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
crate::settings::update_settings(merged).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 统一会话开关变更时立即重写当前官方 Codex 供应商的 live 配置,
|
||||||
|
// 不必等下一次切换才生效。
|
||||||
|
if unify_codex_changed {
|
||||||
|
// live 重写失败时回滚设置并把保存整体报失败:若设置保持已切换状态,
|
||||||
|
// live 仍跑旧桶,后续的历史迁移/还原会让会话再次分裂(开启=历史
|
||||||
|
// 迁走而新会话仍写 openai 桶;关闭=会话还原而 live 仍写 custom)。
|
||||||
|
// 报错让前端 saved=false 短路还原;回滚是整次保存的事务语义
|
||||||
|
// (本开关的保存只携带开关相关字段)。
|
||||||
|
if let Err(err) =
|
||||||
|
crate::services::provider::reapply_current_codex_official_live(state.inner())
|
||||||
|
{
|
||||||
|
log::warn!("统一 Codex 会话历史开关变更后重写 live 配置失败,回滚设置: {err}");
|
||||||
|
if let Err(rollback_err) = crate::settings::update_settings(existing) {
|
||||||
|
log::error!("回滚统一会话开关设置失败: {rollback_err}");
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"统一 Codex 会话历史开关未生效(live 配置重写失败): {err}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if unify_codex_enabled {
|
||||||
|
// 后台执行存量迁移(openai 桶 → custom 桶;仅当用户勾选了迁入既有
|
||||||
|
// 会话,函数内部自门控)。大会话目录可能要读数秒,不能阻塞设置保存;
|
||||||
|
// 失败时不写完成标记,下次启动自动重试。
|
||||||
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
|
||||||
|
Ok(outcome) => {
|
||||||
|
if let Some(reason) = outcome.skipped_reason {
|
||||||
|
log::debug!("○ Codex official history unify migration skipped: {reason}");
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
|
||||||
|
outcome.migrated_jsonl_files,
|
||||||
|
outcome.migrated_state_rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("✗ Codex official history unify migration failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 清除标记与迁移意愿,让重新开启并再次勾选时能补迁
|
||||||
|
// 关闭期间落入 openai 桶的官方会话。
|
||||||
|
if let Err(err) = crate::settings::clear_codex_official_history_unify_migration() {
|
||||||
|
log::warn!("清除统一会话迁移标记失败: {err}");
|
||||||
|
}
|
||||||
|
if let Err(err) = crate::settings::clear_codex_unify_migrate_existing() {
|
||||||
|
log::warn!("清除统一会话迁移意愿失败: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CodexUnifyHistoryRestoreResult {
|
||||||
|
pub restored_jsonl_files: usize,
|
||||||
|
pub restored_state_rows: usize,
|
||||||
|
/// 还原被跳过的原因(如当前目录没有账本),前端据此提示而非报"成功 0 项"。
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub skipped_reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否存在统一会话开关的迁移备份(决定关闭弹窗里是否显示"恢复备份"勾选)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn has_codex_unify_history_backup() -> Result<bool, String> {
|
||||||
|
Ok(crate::codex_history_migration::has_codex_official_history_unify_backup())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按迁移备份账本把当时迁入共享桶的官方会话还原回 "openai" 桶。
|
||||||
|
/// 由关闭统一会话开关的确认弹窗触发;幂等,可安全重试。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn restore_codex_unified_history() -> Result<CodexUnifyHistoryRestoreResult, String> {
|
||||||
|
let outcome = tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
crate::codex_history_migration::restore_codex_official_history_from_backups()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(reason) = &outcome.skipped_reason {
|
||||||
|
log::debug!("○ Codex official history restore skipped: {reason}");
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"✓ Codex official history restored from backups: jsonl_files={}, state_rows={}",
|
||||||
|
outcome.restored_jsonl_files,
|
||||||
|
outcome.restored_state_rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CodexUnifyHistoryRestoreResult {
|
||||||
|
restored_jsonl_files: outcome.restored_jsonl_files,
|
||||||
|
restored_state_rows: outcome.restored_state_rows,
|
||||||
|
skipped_reason: outcome.skipped_reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 重启应用程序(当 app_config_dir 变更后使用)
|
/// 重启应用程序(当 app_config_dir 变更后使用)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||||
@@ -79,11 +178,112 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
|||||||
// 在后台延迟重启,让函数有时间返回响应
|
// 在后台延迟重启,让函数有时间返回响应
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
|
||||||
|
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
|
||||||
|
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
|
||||||
|
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
|
||||||
|
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
|
||||||
|
crate::cleanup_before_exit(&app).await;
|
||||||
app.restart();
|
app.restart();
|
||||||
});
|
});
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 下载并安装应用更新,然后由后端直接重启应用。
|
||||||
|
///
|
||||||
|
/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调
|
||||||
|
/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。
|
||||||
|
/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String> {
|
||||||
|
let updater = app
|
||||||
|
.updater_builder()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("初始化更新器失败: {e}"))?;
|
||||||
|
|
||||||
|
let Some(update) = updater
|
||||||
|
.check()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("检查更新失败: {e}"))?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
log::info!("开始下载应用更新: {}", update.version);
|
||||||
|
let progress_handle = app.clone();
|
||||||
|
let mut downloaded: u64 = 0;
|
||||||
|
let bytes = update
|
||||||
|
.download(
|
||||||
|
move |chunk_len, content_len| {
|
||||||
|
downloaded = downloaded.saturating_add(chunk_len as u64);
|
||||||
|
let _ = progress_handle.emit(
|
||||||
|
"update-download-progress",
|
||||||
|
UpdateDownloadProgress {
|
||||||
|
downloaded,
|
||||||
|
total: content_len,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|| {},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("下载更新失败: {e}"))?;
|
||||||
|
|
||||||
|
log::info!("开始安装应用更新: {}", update.version);
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
// Windows updater 会在 install() 内启动安装器并直接退出当前进程
|
||||||
|
// (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发
|
||||||
|
// NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。
|
||||||
|
// 因此清理只能放在 install 前执行,且必须显式移除托盘图标。
|
||||||
|
crate::save_window_state_before_exit(&app);
|
||||||
|
crate::cleanup_before_exit(&app).await;
|
||||||
|
crate::remove_tray_icon_before_exit(&app);
|
||||||
|
crate::destroy_single_instance_lock(&app);
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
update.install(bytes).map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
// macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。
|
||||||
|
update
|
||||||
|
.install(bytes)
|
||||||
|
.map_err(|e| format!("安装更新失败: {e}"))?;
|
||||||
|
|
||||||
|
crate::save_window_state_before_exit(&app);
|
||||||
|
crate::cleanup_before_exit(&app).await;
|
||||||
|
|
||||||
|
log::info!("应用更新安装完成,正在重启应用");
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
crate::restart_process(&app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有可用的应用更新,返回可用的新版本号(无更新时返回 None)。
|
||||||
|
///
|
||||||
|
/// 数据库版本过新的恢复界面用它判断:升级应用能否解决问题。若返回 None,说明
|
||||||
|
/// 已是最新版本,但数据库仍不兼容(通常由第三方客户端或更高版本创建),应提示用户
|
||||||
|
/// 升级无法解决,而不是让其反复尝试。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_app_update_available(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let updater = app
|
||||||
|
.updater_builder()
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("初始化更新器失败: {e}"))?;
|
||||||
|
let update = updater
|
||||||
|
.check()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("检查更新失败: {e}"))?;
|
||||||
|
Ok(update.map(|u| u.version))
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
/// 获取 app_config_dir 覆盖配置 (从 Store)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
pub async fn get_app_config_dir_override(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
@@ -116,8 +316,9 @@ pub async fn set_auto_launch(enabled: bool) -> Result<bool, String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::merge_settings_for_save;
|
use super::merge_settings_for_save;
|
||||||
use crate::settings::{
|
use crate::settings::{
|
||||||
AppSettings, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration,
|
AppSettings, CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration,
|
||||||
LocalMigrations, S3SyncSettings, WebDavSyncSettings,
|
CodexThirdPartyHistoryProviderBucketMigration, LocalMigrations, S3SyncSettings,
|
||||||
|
WebDavSyncSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -316,6 +517,13 @@ mod tests {
|
|||||||
completed_at: "2026-05-20T00:01:00Z".to_string(),
|
completed_at: "2026-05-20T00:01:00Z".to_string(),
|
||||||
migrated_provider_ids: vec!["legacy".to_string()],
|
migrated_provider_ids: vec!["legacy".to_string()],
|
||||||
}),
|
}),
|
||||||
|
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||||
|
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||||
|
target_provider_id: "custom".to_string(),
|
||||||
|
migrated_jsonl_files: 5,
|
||||||
|
migrated_state_rows: 7,
|
||||||
|
codex_config_dir: None,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
..AppSettings::default()
|
..AppSettings::default()
|
||||||
};
|
};
|
||||||
@@ -345,6 +553,70 @@ mod tests {
|
|||||||
template_migration.migrated_provider_ids,
|
template_migration.migrated_provider_ids,
|
||||||
vec!["legacy".to_string()]
|
vec!["legacy".to_string()]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let unify_migration = merged
|
||||||
|
.local_migrations
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
|
||||||
|
.expect("official unify migration marker should be preserved");
|
||||||
|
assert_eq!(unify_migration.migrated_jsonl_files, 5);
|
||||||
|
assert_eq!(unify_migration.migrated_state_rows, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// incoming 带有 local_migrations(哪怕是空的)也不能覆盖后端维护的标记。
|
||||||
|
#[test]
|
||||||
|
fn save_settings_should_keep_backend_migration_markers_over_incoming() {
|
||||||
|
let existing = AppSettings {
|
||||||
|
local_migrations: Some(LocalMigrations {
|
||||||
|
codex_third_party_history_provider_bucket_v1: None,
|
||||||
|
codex_provider_template_v1: None,
|
||||||
|
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||||
|
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||||
|
target_provider_id: "custom".to_string(),
|
||||||
|
migrated_jsonl_files: 1,
|
||||||
|
migrated_state_rows: 2,
|
||||||
|
codex_config_dir: None,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let incoming = AppSettings {
|
||||||
|
local_migrations: Some(LocalMigrations::default()),
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
let merged = merge_settings_for_save(incoming, &existing);
|
||||||
|
|
||||||
|
assert!(merged
|
||||||
|
.local_migrations
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|migrations| migrations.codex_official_history_unify_v1.as_ref())
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后端清掉 marker 后(如关闭统一会话开关)、前端缓存刷新前的全量保存
|
||||||
|
/// 会携带旧 marker;merge 必须忽略它,否则被"复活"的标记会让重新开启
|
||||||
|
/// 时误判已迁移而漏迁。
|
||||||
|
#[test]
|
||||||
|
fn save_settings_should_ignore_stale_incoming_migration_markers() {
|
||||||
|
let existing = AppSettings::default();
|
||||||
|
|
||||||
|
let incoming = AppSettings {
|
||||||
|
local_migrations: Some(LocalMigrations {
|
||||||
|
codex_official_history_unify_v1: Some(CodexOfficialHistoryUnifyMigration {
|
||||||
|
completed_at: "2026-06-12T00:00:00Z".to_string(),
|
||||||
|
target_provider_id: "custom".to_string(),
|
||||||
|
migrated_jsonl_files: 1,
|
||||||
|
migrated_state_rows: 2,
|
||||||
|
codex_config_dir: None,
|
||||||
|
}),
|
||||||
|
..LocalMigrations::default()
|
||||||
|
}),
|
||||||
|
..AppSettings::default()
|
||||||
|
};
|
||||||
|
let merged = merge_settings_for_save(incoming, &existing);
|
||||||
|
|
||||||
|
assert!(merged.local_migrations.is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
//! 流式健康检查命令
|
//! 供应商连通性检查命令
|
||||||
|
//!
|
||||||
|
//! 注意:本检查只探测 base_url 是否可达,不发真实大模型请求,也不触碰故障转移
|
||||||
|
//! 熔断器(熔断器由真实转发流量驱动)。详见 `services::stream_check`。
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::commands::copilot::CopilotAuthState;
|
use crate::commands::copilot::CopilotAuthState;
|
||||||
@@ -10,7 +13,7 @@ use crate::store::AppState;
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
/// 流式健康检查(单个供应商)
|
/// 连通性检查(单个供应商)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stream_check_provider(
|
pub async fn stream_check_provider(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -25,25 +28,12 @@ pub async fn stream_check_provider(
|
|||||||
.get(&provider_id)
|
.get(&provider_id)
|
||||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||||
|
|
||||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
// Copilot 端点是动态的(随 OAuth token 解析),需预先取出 host 再探测;
|
||||||
|
// 其余供应商传 None,由服务层从 settings_config 提取 base_url。无需鉴权。
|
||||||
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
|
let base_url_override = resolve_copilot_base_url_override(provider, &copilot_state).await?;
|
||||||
let claude_api_format_override = resolve_claude_api_format_override(
|
let result =
|
||||||
&app_type,
|
StreamCheckService::check_with_retry(&app_type, provider, &config, base_url_override)
|
||||||
provider,
|
.await?;
|
||||||
&config,
|
|
||||||
&copilot_state,
|
|
||||||
auth_override.as_ref(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let result = StreamCheckService::check_with_retry(
|
|
||||||
&app_type,
|
|
||||||
provider,
|
|
||||||
&config,
|
|
||||||
auth_override,
|
|
||||||
base_url_override,
|
|
||||||
claude_api_format_override,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 记录日志
|
// 记录日志
|
||||||
let _ =
|
let _ =
|
||||||
@@ -54,7 +44,7 @@ pub async fn stream_check_provider(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量流式健康检查
|
/// 批量连通性检查
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stream_check_all_providers(
|
pub async fn stream_check_all_providers(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -65,7 +55,6 @@ pub async fn stream_check_all_providers(
|
|||||||
let config = state.db.get_stream_check_config()?;
|
let config = state.db.get_stream_check_config()?;
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
|
||||||
let mut results = Vec::new();
|
|
||||||
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
let allowed_ids: Option<HashSet<String>> = if proxy_targets_only {
|
||||||
let mut ids = HashSet::new();
|
let mut ids = HashSet::new();
|
||||||
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
if let Ok(Some(current_id)) = state.db.get_current_provider(app_type.as_str()) {
|
||||||
@@ -81,6 +70,7 @@ pub async fn stream_check_all_providers(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
for (id, provider) in providers {
|
for (id, provider) in providers {
|
||||||
if let Some(ids) = &allowed_ids {
|
if let Some(ids) = &allowed_ids {
|
||||||
if !ids.contains(&id) {
|
if !ids.contains(&id) {
|
||||||
@@ -88,54 +78,22 @@ pub async fn stream_check_all_providers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
|
||||||
let base_url_override =
|
let base_url_override =
|
||||||
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
|
resolve_copilot_base_url_override(&provider, &copilot_state).await?;
|
||||||
let claude_api_format_override = resolve_claude_api_format_override(
|
let result =
|
||||||
&app_type,
|
StreamCheckService::check_with_retry(&app_type, &provider, &config, base_url_override)
|
||||||
&provider,
|
.await
|
||||||
&config,
|
.unwrap_or_else(|e| StreamCheckResult {
|
||||||
&copilot_state,
|
status: HealthStatus::Failed,
|
||||||
auth_override.as_ref(),
|
success: false,
|
||||||
)
|
message: e.to_string(),
|
||||||
.await
|
response_time_ms: None,
|
||||||
.unwrap_or_else(|e| {
|
http_status: None,
|
||||||
log::warn!(
|
model_used: String::new(),
|
||||||
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
tested_at: chrono::Utc::now().timestamp(),
|
||||||
provider.id,
|
retry_count: 0,
|
||||||
e
|
error_category: None,
|
||||||
);
|
});
|
||||||
None
|
|
||||||
});
|
|
||||||
let result = StreamCheckService::check_with_retry(
|
|
||||||
&app_type,
|
|
||||||
&provider,
|
|
||||||
&config,
|
|
||||||
auth_override,
|
|
||||||
base_url_override,
|
|
||||||
claude_api_format_override,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
let (http_status, message) = match &e {
|
|
||||||
crate::error::AppError::HttpStatus { status, .. } => (
|
|
||||||
Some(*status),
|
|
||||||
StreamCheckService::classify_http_status(*status).to_string(),
|
|
||||||
),
|
|
||||||
_ => (None, e.to_string()),
|
|
||||||
};
|
|
||||||
StreamCheckResult {
|
|
||||||
status: HealthStatus::Failed,
|
|
||||||
success: false,
|
|
||||||
message,
|
|
||||||
response_time_ms: None,
|
|
||||||
http_status,
|
|
||||||
model_used: String::new(),
|
|
||||||
tested_at: chrono::Utc::now().timestamp(),
|
|
||||||
retry_count: 0,
|
|
||||||
error_category: None,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.db
|
.db
|
||||||
@@ -147,13 +105,13 @@ pub async fn stream_check_all_providers(
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取流式检查配置
|
/// 获取连通性检查配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
pub fn get_stream_check_config(state: State<'_, AppState>) -> Result<StreamCheckConfig, AppError> {
|
||||||
state.db.get_stream_check_config()
|
state.db.get_stream_check_config()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存流式检查配置
|
/// 保存连通性检查配置
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn save_stream_check_config(
|
pub fn save_stream_check_config(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -162,39 +120,8 @@ pub fn save_stream_check_config(
|
|||||||
state.db.save_stream_check_config(&config)
|
state.db.save_stream_check_config(&config)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_copilot_auth_override(
|
/// Copilot 供应商的 base_url 需要从 OAuth 管理器动态解析(按账号或默认端点)。
|
||||||
provider: &crate::provider::Provider,
|
/// `is_full_url` 的供应商已是完整地址,无需解析。
|
||||||
copilot_state: &State<'_, CopilotAuthState>,
|
|
||||||
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
|
||||||
let is_copilot = is_copilot_provider(provider);
|
|
||||||
|
|
||||||
if !is_copilot {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let auth_manager = copilot_state.0.read().await;
|
|
||||||
let account_id = provider
|
|
||||||
.meta
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
|
||||||
|
|
||||||
let token = match account_id.as_deref() {
|
|
||||||
Some(id) => auth_manager
|
|
||||||
.get_valid_token_for_account(id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
|
||||||
None => auth_manager
|
|
||||||
.get_valid_token()
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Message(format!("GitHub Copilot 认证失败: {e}")))?,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(crate::proxy::providers::AuthInfo::new(
|
|
||||||
token,
|
|
||||||
crate::proxy::providers::AuthStrategy::GitHubCopilot,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_copilot_base_url_override(
|
async fn resolve_copilot_base_url_override(
|
||||||
provider: &crate::provider::Provider,
|
provider: &crate::provider::Provider,
|
||||||
copilot_state: &State<'_, CopilotAuthState>,
|
copilot_state: &State<'_, CopilotAuthState>,
|
||||||
@@ -238,54 +165,6 @@ fn is_copilot_provider(provider: &crate::provider::Provider) -> bool {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_claude_api_format_override(
|
|
||||||
app_type: &AppType,
|
|
||||||
provider: &crate::provider::Provider,
|
|
||||||
config: &StreamCheckConfig,
|
|
||||||
copilot_state: &State<'_, CopilotAuthState>,
|
|
||||||
auth_override: Option<&crate::proxy::providers::AuthInfo>,
|
|
||||||
) -> Result<Option<String>, AppError> {
|
|
||||||
if *app_type != AppType::Claude {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let is_copilot = auth_override
|
|
||||||
.map(|auth| auth.strategy == crate::proxy::providers::AuthStrategy::GitHubCopilot)
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !is_copilot {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
let model_id = StreamCheckService::resolve_effective_test_model(app_type, provider, config);
|
|
||||||
let auth_manager = copilot_state.0.read().await;
|
|
||||||
let account_id = provider
|
|
||||||
.meta
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|meta| meta.managed_account_id_for("github_copilot"));
|
|
||||||
|
|
||||||
let vendor_result = match account_id.as_deref() {
|
|
||||||
Some(id) => {
|
|
||||||
auth_manager
|
|
||||||
.get_model_vendor_for_account(id, &model_id)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
None => auth_manager.get_model_vendor(&model_id).await,
|
|
||||||
};
|
|
||||||
|
|
||||||
let api_format = match vendor_result {
|
|
||||||
Ok(Some(vendor)) if vendor.eq_ignore_ascii_case("openai") => "openai_responses",
|
|
||||||
Ok(Some(_)) | Ok(None) => "openai_chat",
|
|
||||||
Err(err) => {
|
|
||||||
log::warn!(
|
|
||||||
"[StreamCheck] Failed to resolve Copilot model vendor for {model_id}: {err}. Falling back to chat/completions"
|
|
||||||
);
|
|
||||||
"openai_chat"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(api_format.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::is_copilot_provider;
|
use super::is_copilot_provider;
|
||||||
|
|||||||
@@ -14,10 +14,16 @@ pub fn get_usage_summary(
|
|||||||
start_date: Option<i64>,
|
start_date: Option<i64>,
|
||||||
end_date: Option<i64>,
|
end_date: Option<i64>,
|
||||||
app_type: Option<String>,
|
app_type: Option<String>,
|
||||||
|
provider_name: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
) -> Result<UsageSummary, AppError> {
|
) -> Result<UsageSummary, AppError> {
|
||||||
state
|
state.db.get_usage_summary(
|
||||||
.db
|
start_date,
|
||||||
.get_usage_summary(start_date, end_date, app_type.as_deref())
|
end_date,
|
||||||
|
app_type.as_deref(),
|
||||||
|
provider_name.as_deref(),
|
||||||
|
model.as_deref(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取按 app_type 拆分的使用量汇总
|
/// 获取按 app_type 拆分的使用量汇总
|
||||||
@@ -26,8 +32,15 @@ pub fn get_usage_summary_by_app(
|
|||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
start_date: Option<i64>,
|
start_date: Option<i64>,
|
||||||
end_date: Option<i64>,
|
end_date: Option<i64>,
|
||||||
|
provider_name: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
) -> Result<Vec<UsageSummaryByApp>, AppError> {
|
) -> Result<Vec<UsageSummaryByApp>, AppError> {
|
||||||
state.db.get_usage_summary_by_app(start_date, end_date)
|
state.db.get_usage_summary_by_app(
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
provider_name.as_deref(),
|
||||||
|
model.as_deref(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取每日趋势
|
/// 获取每日趋势
|
||||||
@@ -37,10 +50,16 @@ pub fn get_usage_trends(
|
|||||||
start_date: Option<i64>,
|
start_date: Option<i64>,
|
||||||
end_date: Option<i64>,
|
end_date: Option<i64>,
|
||||||
app_type: Option<String>,
|
app_type: Option<String>,
|
||||||
|
provider_name: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
) -> Result<Vec<DailyStats>, AppError> {
|
) -> Result<Vec<DailyStats>, AppError> {
|
||||||
state
|
state.db.get_daily_trends(
|
||||||
.db
|
start_date,
|
||||||
.get_daily_trends(start_date, end_date, app_type.as_deref())
|
end_date,
|
||||||
|
app_type.as_deref(),
|
||||||
|
provider_name.as_deref(),
|
||||||
|
model.as_deref(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Provider 统计
|
/// 获取 Provider 统计
|
||||||
@@ -50,10 +69,16 @@ pub fn get_provider_stats(
|
|||||||
start_date: Option<i64>,
|
start_date: Option<i64>,
|
||||||
end_date: Option<i64>,
|
end_date: Option<i64>,
|
||||||
app_type: Option<String>,
|
app_type: Option<String>,
|
||||||
|
provider_name: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
) -> Result<Vec<ProviderStats>, AppError> {
|
) -> Result<Vec<ProviderStats>, AppError> {
|
||||||
state
|
state.db.get_provider_stats(
|
||||||
.db
|
start_date,
|
||||||
.get_provider_stats(start_date, end_date, app_type.as_deref())
|
end_date,
|
||||||
|
app_type.as_deref(),
|
||||||
|
provider_name.as_deref(),
|
||||||
|
model.as_deref(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取模型统计
|
/// 获取模型统计
|
||||||
@@ -63,10 +88,16 @@ pub fn get_model_stats(
|
|||||||
start_date: Option<i64>,
|
start_date: Option<i64>,
|
||||||
end_date: Option<i64>,
|
end_date: Option<i64>,
|
||||||
app_type: Option<String>,
|
app_type: Option<String>,
|
||||||
|
provider_name: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
) -> Result<Vec<ModelStats>, AppError> {
|
) -> Result<Vec<ModelStats>, AppError> {
|
||||||
state
|
state.db.get_model_stats(
|
||||||
.db
|
start_date,
|
||||||
.get_model_stats(start_date, end_date, app_type.as_deref())
|
end_date,
|
||||||
|
app_type.as_deref(),
|
||||||
|
provider_name.as_deref(),
|
||||||
|
model.as_deref(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取请求日志列表
|
/// 获取请求日志列表
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::{Map, Value};
|
use serde_json::{Map, Value};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
@@ -47,25 +47,118 @@ pub fn get_default_claude_mcp_path() -> PathBuf {
|
|||||||
get_home_dir().join(".claude.json")
|
get_home_dir().join(".claude.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn derive_mcp_path_from_override(dir: &Path) -> Option<PathBuf> {
|
fn normalize_path_lexically(path: &Path) -> PathBuf {
|
||||||
let file_name = dir
|
let mut normalized = PathBuf::new();
|
||||||
.file_name()
|
|
||||||
.map(|name| name.to_string_lossy().to_string())?
|
for component in path.components() {
|
||||||
.trim()
|
match component {
|
||||||
.to_string();
|
Component::CurDir => {}
|
||||||
if file_name.is_empty() {
|
Component::ParentDir => {
|
||||||
return None;
|
if !normalized.pop() {
|
||||||
|
normalized.push(component.as_os_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Component::Normal(part) => normalized.push(part),
|
||||||
|
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let parent = dir.parent().unwrap_or_else(|| Path::new(""));
|
|
||||||
Some(parent.join(format!("{file_name}.json")))
|
normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Claude MCP 配置文件路径,若设置了目录覆盖则与覆盖目录同级
|
fn comparable_path_key(path: &Path) -> String {
|
||||||
|
let mut key = normalize_path_lexically(path).to_string_lossy().to_string();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
key = key.replace('\\', "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
while key.len() > 1 && key.ends_with('/') {
|
||||||
|
key.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
key.make_ascii_lowercase();
|
||||||
|
}
|
||||||
|
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_eq_lexical(left: &Path, right: &Path) -> bool {
|
||||||
|
comparable_path_key(left) == comparable_path_key(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn derive_wsl_default_mcp_path(dir: &Path) -> Option<PathBuf> {
|
||||||
|
use std::path::Prefix;
|
||||||
|
|
||||||
|
let normalized = normalize_path_lexically(dir);
|
||||||
|
let mut components = normalized.components();
|
||||||
|
let prefix = match components.next()? {
|
||||||
|
Component::Prefix(prefix) => prefix,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let server = match prefix.kind() {
|
||||||
|
Prefix::UNC(server, _) | Prefix::VerbatimUNC(server, _) => server.to_string_lossy(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !server.eq_ignore_ascii_case("wsl$") && !server.eq_ignore_ascii_case("wsl.localhost") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for component in components {
|
||||||
|
match component {
|
||||||
|
Component::RootDir | Component::CurDir => {}
|
||||||
|
Component::Normal(part) => parts.push(part.to_string_lossy().to_string()),
|
||||||
|
Component::ParentDir | Component::Prefix(_) => return None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_wsl_home_default =
|
||||||
|
parts.len() == 3 && parts[0] == "home" && !parts[1].is_empty() && parts[2] == ".claude";
|
||||||
|
let is_wsl_root_default = parts.len() == 2 && parts[0] == "root" && parts[1] == ".claude";
|
||||||
|
|
||||||
|
if is_wsl_home_default || is_wsl_root_default {
|
||||||
|
return normalized
|
||||||
|
.parent()
|
||||||
|
.map(|parent| parent.join(".claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mcp_path_for_config_dir(dir: &Path) -> Option<PathBuf> {
|
||||||
|
let default_config_dir = get_home_dir().join(".claude");
|
||||||
|
if path_eq_lexical(dir, &default_config_dir) {
|
||||||
|
return Some(get_default_claude_mcp_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(path) = derive_wsl_default_mcp_path(dir) {
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_mcp_path_from_override(dir: &Path) -> PathBuf {
|
||||||
|
dir.join(".claude.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 Claude MCP 配置文件路径
|
||||||
pub fn get_claude_mcp_path() -> PathBuf {
|
pub fn get_claude_mcp_path() -> PathBuf {
|
||||||
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
if let Some(custom_dir) = crate::settings::get_claude_override_dir() {
|
||||||
if let Some(path) = derive_mcp_path_from_override(&custom_dir) {
|
if let Some(path) = default_mcp_path_for_config_dir(&custom_dir) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
return derive_mcp_path_from_override(&custom_dir);
|
||||||
}
|
}
|
||||||
get_default_claude_mcp_path()
|
get_default_claude_mcp_path()
|
||||||
}
|
}
|
||||||
@@ -263,33 +356,73 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_mcp_path_from_override_preserves_folder_name() {
|
fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() {
|
||||||
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
let override_dir = PathBuf::from("/tmp/profile/.claude");
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
let derived = derive_mcp_path_from_override(&override_dir);
|
||||||
.expect("should derive path for nested dir");
|
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude/.claude.json"));
|
||||||
assert_eq!(derived, PathBuf::from("/tmp/profile/.claude.json"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_mcp_path_from_override_handles_non_hidden_folder() {
|
fn derive_mcp_path_from_override_uses_config_dir_for_non_hidden_folder() {
|
||||||
let override_dir = PathBuf::from("/data/claude-config");
|
let override_dir = PathBuf::from("/data/claude-config");
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
let derived = derive_mcp_path_from_override(&override_dir);
|
||||||
.expect("should derive path for standard dir");
|
assert_eq!(derived, PathBuf::from("/data/claude-config/.claude.json"));
|
||||||
assert_eq!(derived, PathBuf::from("/data/claude-config.json"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
fn derive_mcp_path_from_override_supports_relative_rootless_dir() {
|
||||||
let override_dir = PathBuf::from("claude");
|
let override_dir = PathBuf::from("claude");
|
||||||
let derived = derive_mcp_path_from_override(&override_dir)
|
let derived = derive_mcp_path_from_override(&override_dir);
|
||||||
.expect("should derive path for single segment");
|
assert_eq!(derived, PathBuf::from("claude/.claude.json"));
|
||||||
assert_eq!(derived, PathBuf::from("claude.json"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn derive_mcp_path_from_root_like_dir_returns_none() {
|
fn derive_mcp_path_from_root_like_dir_uses_root_file() {
|
||||||
let override_dir = PathBuf::from("/");
|
let override_dir = PathBuf::from("/");
|
||||||
assert!(derive_mcp_path_from_override(&override_dir).is_none());
|
let derived = derive_mcp_path_from_override(&override_dir);
|
||||||
|
assert_eq!(derived, PathBuf::from("/.claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn derive_mcp_path_from_override_preserves_leading_parent_dirs() {
|
||||||
|
let override_dir = PathBuf::from("../../profiles/work/.claude");
|
||||||
|
let derived = derive_mcp_path_from_override(&override_dir);
|
||||||
|
assert_eq!(derived, override_dir.join(".claude.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn wsl_unc_home_default_uses_split_mcp_path() {
|
||||||
|
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude");
|
||||||
|
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||||
|
.expect("WSL home default should use split MCP path");
|
||||||
|
assert_eq!(
|
||||||
|
derived,
|
||||||
|
PathBuf::from(r"\\wsl$\Ubuntu\home\travis\.claude.json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn wsl_unc_root_default_uses_split_mcp_path() {
|
||||||
|
let override_dir = PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude");
|
||||||
|
let derived = default_mcp_path_for_config_dir(&override_dir)
|
||||||
|
.expect("WSL root default should use split MCP path");
|
||||||
|
assert_eq!(
|
||||||
|
derived,
|
||||||
|
PathBuf::from(r"\\wsl.localhost\Ubuntu\root\.claude.json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn wsl_unc_custom_dir_uses_nested_mcp_path() {
|
||||||
|
let override_dir = PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude");
|
||||||
|
assert!(default_mcp_path_for_config_dir(&override_dir).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
derive_mcp_path_from_override(&override_dir),
|
||||||
|
PathBuf::from(r"\\wsl$\Ubuntu\opt\claude\.claude\.claude.json")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ impl Database {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 剪枝是不可逆的:明细一旦汇总删除,0 成本行就永远失去按 pricing_model
|
||||||
|
// 补价重算的机会(启动序列里 seed 定价先于 rollup、但启动回填在 rollup
|
||||||
|
// 之后;周期任务同理)。所以剪枝前先尽力回填一次。失败仅告警不阻断——
|
||||||
|
// 否则一行损坏的定价数据会永久卡死日志清理。
|
||||||
|
// 注意必须在 SAVEPOINT 之外调用:回填内部自己开顶层事务。
|
||||||
|
if let Err(e) = Self::backfill_missing_usage_costs_on_conn(&conn, None) {
|
||||||
|
log::warn!("Pre-prune cost backfill failed, pruning anyway: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
// Use a savepoint for atomicity
|
// Use a savepoint for atomicity
|
||||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -106,15 +115,18 @@ impl Database {
|
|||||||
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
fn do_rollup_and_prune(conn: &rusqlite::Connection, cutoff: i64) -> Result<u64, AppError> {
|
||||||
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
// Aggregate old logs, merging with any pre-existing rollup rows via LEFT JOIN.
|
||||||
let effective_filter = effective_usage_log_filter("l");
|
let effective_filter = effective_usage_log_filter("l");
|
||||||
|
// request_model 维度保留路由接管的「客户端别名 → 真实模型」映射,
|
||||||
|
// pricing_model 维度保留写入时的计价基准(request 计价模式下与 model 分叉);
|
||||||
|
// 明细行的这两列可能为 NULL(历史/手工数据),归一为 ''。
|
||||||
let aggregation_sql = format!(
|
let aggregation_sql = format!(
|
||||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||||
(date, app_type, provider_id, model,
|
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||||
request_count, success_count,
|
request_count, success_count,
|
||||||
input_tokens, output_tokens,
|
input_tokens, output_tokens,
|
||||||
cache_read_tokens, cache_creation_tokens,
|
cache_read_tokens, cache_creation_tokens,
|
||||||
total_cost_usd, avg_latency_ms)
|
total_cost_usd, avg_latency_ms)
|
||||||
SELECT
|
SELECT
|
||||||
d, a, p, m,
|
d, a, p, m, rm, pm,
|
||||||
COALESCE(old.request_count, 0) + new_req,
|
COALESCE(old.request_count, 0) + new_req,
|
||||||
COALESCE(old.success_count, 0) + new_succ,
|
COALESCE(old.success_count, 0) + new_succ,
|
||||||
COALESCE(old.input_tokens, 0) + new_in,
|
COALESCE(old.input_tokens, 0) + new_in,
|
||||||
@@ -131,6 +143,8 @@ impl Database {
|
|||||||
SELECT
|
SELECT
|
||||||
date(l.created_at, 'unixepoch', 'localtime') as d,
|
date(l.created_at, 'unixepoch', 'localtime') as d,
|
||||||
l.app_type as a, l.provider_id as p, l.model as m,
|
l.app_type as a, l.provider_id as p, l.model as m,
|
||||||
|
COALESCE(l.request_model, '') as rm,
|
||||||
|
COALESCE(l.pricing_model, '') as pm,
|
||||||
COUNT(*) as new_req,
|
COUNT(*) as new_req,
|
||||||
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||||
COALESCE(SUM(l.input_tokens), 0) as new_in,
|
COALESCE(SUM(l.input_tokens), 0) as new_in,
|
||||||
@@ -141,11 +155,12 @@ impl Database {
|
|||||||
COALESCE(AVG(l.latency_ms), 0) as new_lat
|
COALESCE(AVG(l.latency_ms), 0) as new_lat
|
||||||
FROM proxy_request_logs l
|
FROM proxy_request_logs l
|
||||||
WHERE l.created_at < ?1 AND {effective_filter}
|
WHERE l.created_at < ?1 AND {effective_filter}
|
||||||
GROUP BY d, a, p, m
|
GROUP BY d, a, p, m, rm, pm
|
||||||
) agg
|
) agg
|
||||||
LEFT JOIN usage_daily_rollups old
|
LEFT JOIN usage_daily_rollups old
|
||||||
ON old.date = agg.d AND old.app_type = agg.a
|
ON old.date = agg.d AND old.app_type = agg.a
|
||||||
AND old.provider_id = agg.p AND old.model = agg.m"
|
AND old.provider_id = agg.p AND old.model = agg.m
|
||||||
|
AND old.request_model = agg.rm AND old.pricing_model = agg.pm"
|
||||||
);
|
);
|
||||||
|
|
||||||
conn.execute(&aggregation_sql, [cutoff])
|
conn.execute(&aggregation_sql, [cutoff])
|
||||||
@@ -325,6 +340,144 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rollup_preserves_request_model_dimension() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let old_ts = now - 40 * 86400;
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
// 路由接管行:model 是真实上游模型,request_model 是客户端别名。
|
||||||
|
// 同 model 下两个不同别名必须各自成行,prune 后映射关系仍可审计。
|
||||||
|
for (i, request_model) in [
|
||||||
|
("a", "claude-sonnet-4-6"),
|
||||||
|
("b", "claude-sonnet-4-6"),
|
||||||
|
("c", "claude-haiku-4-5"),
|
||||||
|
] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_request_logs (
|
||||||
|
request_id, provider_id, app_type, model, request_model,
|
||||||
|
input_tokens, output_tokens, total_cost_usd,
|
||||||
|
latency_ms, status_code, created_at
|
||||||
|
) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, 100, 50, '0.01', 100, 200, ?3)",
|
||||||
|
rusqlite::params![format!("takeover-{i}"), request_model, old_ts],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = db.rollup_and_prune(30)?;
|
||||||
|
assert_eq!(deleted, 3);
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT request_model, request_count FROM usage_daily_rollups
|
||||||
|
WHERE model = 'kimi-k2' ORDER BY request_model",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows,
|
||||||
|
vec![
|
||||||
|
("claude-haiku-4-5".to_string(), 1),
|
||||||
|
("claude-sonnet-4-6".to_string(), 2),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rollup_preserves_pricing_model_dimension() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let old_ts = now - 40 * 86400;
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
// request 计价模式下 pricing_model 与 model 分叉,必须各自成行
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_request_logs (
|
||||||
|
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||||
|
input_tokens, output_tokens, total_cost_usd,
|
||||||
|
latency_ms, status_code, created_at
|
||||||
|
) VALUES ('pm-a', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'kimi-k2',
|
||||||
|
100, 50, '0.01', 100, 200, ?1)",
|
||||||
|
rusqlite::params![old_ts],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_request_logs (
|
||||||
|
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||||
|
input_tokens, output_tokens, total_cost_usd,
|
||||||
|
latency_ms, status_code, created_at
|
||||||
|
) VALUES ('pm-b', 'p1', 'claude', 'kimi-k2', 'claude-sonnet-4-6', 'claude-sonnet-4-6',
|
||||||
|
100, 50, '0.30', 100, 200, ?1)",
|
||||||
|
rusqlite::params![old_ts],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = db.rollup_and_prune(30)?;
|
||||||
|
assert_eq!(deleted, 2);
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT pricing_model, total_cost_usd FROM usage_daily_rollups
|
||||||
|
WHERE model = 'kimi-k2' ORDER BY pricing_model",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[0].0, "claude-sonnet-4-6");
|
||||||
|
assert_eq!(rows[1].0, "kimi-k2");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_rollup_backfills_costs_before_pruning() -> Result<(), AppError> {
|
||||||
|
let db = Database::memory()?;
|
||||||
|
let now = chrono::Utc::now().timestamp();
|
||||||
|
let old_ts = now - 40 * 86400;
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
// >30 天的 0 成本行:pricing_model(gpt-5.5)在 seed 定价表中有价。
|
||||||
|
// 剪枝是不可逆的,rollup 必须先回填再汇总,否则按 0 永久入账。
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_request_logs (
|
||||||
|
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||||
|
input_tokens, output_tokens, total_cost_usd,
|
||||||
|
latency_ms, status_code, created_at
|
||||||
|
) VALUES ('prune-backfill', 'p1', 'codex', 'gpt-5.5', 'gpt-5.5', 'gpt-5.5',
|
||||||
|
1000000, 0, '0', 100, 200, ?1)",
|
||||||
|
rusqlite::params![old_ts],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleted = db.rollup_and_prune(30)?;
|
||||||
|
assert_eq!(deleted, 1);
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let total_cost: f64 = conn.query_row(
|
||||||
|
"SELECT CAST(total_cost_usd AS REAL) FROM usage_daily_rollups
|
||||||
|
WHERE model = 'gpt-5.5'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
// gpt-5.5 input $5/M × 1M tokens,回填后再汇总
|
||||||
|
assert!(
|
||||||
|
(total_cost - 5.0).abs() < 1e-6,
|
||||||
|
"expected backfilled cost 5.0, got {total_cost}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||||
let db = Database::memory()?;
|
let db = Database::memory()?;
|
||||||
@@ -340,8 +493,10 @@ mod tests {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let conn = crate::database::lock_conn!(db.conn);
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
let date_str = Local
|
||||||
.unwrap()
|
.timestamp_opt(old_ts, 0)
|
||||||
|
.single()
|
||||||
|
.expect("old timestamp should be a valid local datetime")
|
||||||
.format("%Y-%m-%d")
|
.format("%Y-%m-%d")
|
||||||
.to_string();
|
.to_string();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
/// 当前 Schema 版本号
|
/// 当前 Schema 版本号
|
||||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||||
pub(crate) const SCHEMA_VERSION: i32 = 10;
|
pub(crate) const SCHEMA_VERSION: i32 = 11;
|
||||||
|
|
||||||
/// 安全地序列化 JSON,避免 unwrap panic
|
/// 安全地序列化 JSON,避免 unwrap panic
|
||||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||||
@@ -159,6 +159,22 @@ impl Database {
|
|||||||
Ok(db)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读取磁盘上数据库的 `user_version`;仅当它比应用支持的 [`SCHEMA_VERSION`]
|
||||||
|
/// 更新时返回 `Some(version)`。
|
||||||
|
///
|
||||||
|
/// 用于初始化失败后判断是否为「数据库版本过新(应用过旧,需升级应用)」的可恢复
|
||||||
|
/// 场景——此时不应反复弹出无效的重试对话框,而应引导用户在应用内升级。
|
||||||
|
pub fn stored_user_version_exceeds_supported(
|
||||||
|
db_path: &std::path::Path,
|
||||||
|
) -> Result<Option<i32>, AppError> {
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let conn = Connection::open(db_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
let version = Self::get_user_version(&conn)?;
|
||||||
|
Ok((version > SCHEMA_VERSION).then_some(version))
|
||||||
|
}
|
||||||
|
|
||||||
/// 创建内存数据库(用于测试)
|
/// 创建内存数据库(用于测试)
|
||||||
pub fn memory() -> Result<Self, AppError> {
|
pub fn memory() -> Result<Self, AppError> {
|
||||||
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
let conn = Connection::open_in_memory().map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -181,9 +181,12 @@ impl Database {
|
|||||||
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 10. Proxy Request Logs 表
|
// 10. Proxy Request Logs 表
|
||||||
|
// pricing_model = 写入时实际用于计价的模型名(pricing_model_source 解析结果),
|
||||||
|
// 回填按它重算;NULL 表示 v11 之前的历史行,'' 表示未计价的错误行。
|
||||||
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
request_model TEXT,
|
request_model TEXT,
|
||||||
|
pricing_model TEXT,
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
@@ -255,12 +258,17 @@ impl Database {
|
|||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||||
|
// request_model 保留路由接管的「客户端别名 → 真实模型」映射维度,
|
||||||
|
// pricing_model 保留写入时的计价基准(request 计价模式下与 model 分叉),
|
||||||
|
// 否则明细被 prune 后接管计费不可审计;历史行迁移时填 ''(未知)。
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
"CREATE TABLE IF NOT EXISTS usage_daily_rollups (
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
app_type TEXT NOT NULL,
|
app_type TEXT NOT NULL,
|
||||||
provider_id TEXT NOT NULL,
|
provider_id TEXT NOT NULL,
|
||||||
model TEXT NOT NULL,
|
model TEXT NOT NULL,
|
||||||
|
request_model TEXT NOT NULL DEFAULT '',
|
||||||
|
pricing_model TEXT NOT NULL DEFAULT '',
|
||||||
request_count INTEGER NOT NULL DEFAULT 0,
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
success_count INTEGER NOT NULL DEFAULT 0,
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -269,7 +277,7 @@ impl Database {
|
|||||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (date, app_type, provider_id, model)
|
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
@@ -431,6 +439,11 @@ impl Database {
|
|||||||
Self::migrate_v9_to_v10(conn)?;
|
Self::migrate_v9_to_v10(conn)?;
|
||||||
Self::set_user_version(conn, 10)?;
|
Self::set_user_version(conn, 10)?;
|
||||||
}
|
}
|
||||||
|
10 => {
|
||||||
|
log::info!("迁移数据库从 v10 到 v11(usage_daily_rollups 保留 request_model 维度)");
|
||||||
|
Self::migrate_v10_to_v11(conn)?;
|
||||||
|
Self::set_user_version(conn, 11)?;
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(AppError::Database(format!(
|
return Err(AppError::Database(format!(
|
||||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||||
@@ -1200,11 +1213,85 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v10 -> v11:usage_daily_rollups 增加 request_model 维度(进入主键),
|
||||||
|
/// proxy_request_logs 增加 pricing_model 列(写入时的计价基准,回填依据)。
|
||||||
|
///
|
||||||
|
/// 路由接管下 model(真实上游模型)≠ request_model(客户端别名),
|
||||||
|
/// 旧 rollup 只按 model 聚合,明细 prune 后映射关系永久丢失、计费不可审计。
|
||||||
|
/// SQLite 改主键必须重建表;历史行的 request_model 已不可知,填 ''。
|
||||||
|
fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// proxy_request_logs.pricing_model:NULL = v11 前的历史行(回填走
|
||||||
|
// model → 占位符回退 request_model 的旧逻辑),'' = 未计价的错误行
|
||||||
|
if Self::table_exists(conn, "proxy_request_logs")? {
|
||||||
|
Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !Self::table_exists(conn, "usage_daily_rollups")? {
|
||||||
|
log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10;
|
||||||
|
CREATE TABLE usage_daily_rollups (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_model TEXT NOT NULL DEFAULT '',
|
||||||
|
pricing_model TEXT NOT NULL DEFAULT '',
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model)
|
||||||
|
);
|
||||||
|
INSERT INTO usage_daily_rollups
|
||||||
|
(date, app_type, provider_id, model, request_model, pricing_model,
|
||||||
|
request_count, success_count, input_tokens, output_tokens,
|
||||||
|
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms)
|
||||||
|
SELECT date, app_type, provider_id, model, '', '',
|
||||||
|
request_count, success_count, input_tokens, output_tokens,
|
||||||
|
cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms
|
||||||
|
FROM usage_daily_rollups_v10;
|
||||||
|
DROP TABLE usage_daily_rollups_v10;",
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 插入默认模型定价数据
|
/// 插入默认模型定价数据
|
||||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||||
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
let pricing_data = [
|
let pricing_data = [
|
||||||
|
// Claude Fable 5(Opus 之上的新档)
|
||||||
|
(
|
||||||
|
"claude-fable-5",
|
||||||
|
"Claude Fable 5",
|
||||||
|
"10",
|
||||||
|
"50",
|
||||||
|
"1.00",
|
||||||
|
"12.50",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"claude-mythos-5",
|
||||||
|
"Claude Mythos 5",
|
||||||
|
"10",
|
||||||
|
"50",
|
||||||
|
"1.00",
|
||||||
|
"12.50",
|
||||||
|
),
|
||||||
// Claude 4.8 系列
|
// Claude 4.8 系列
|
||||||
(
|
(
|
||||||
"claude-opus-4-8",
|
"claude-opus-4-8",
|
||||||
@@ -1571,6 +1658,14 @@ impl Database {
|
|||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
// StepFun 系列
|
// StepFun 系列
|
||||||
|
(
|
||||||
|
"step-3.7-flash",
|
||||||
|
"Step 3.7 Flash",
|
||||||
|
"0.19",
|
||||||
|
"1.13",
|
||||||
|
"0.04",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"step-3.5-flash",
|
"step-3.5-flash",
|
||||||
"Step 3.5 Flash",
|
"Step 3.5 Flash",
|
||||||
@@ -1589,6 +1684,26 @@ impl Database {
|
|||||||
),
|
),
|
||||||
// ====== 国产模型 (USD/1M tokens) ======
|
// ====== 国产模型 (USD/1M tokens) ======
|
||||||
// Doubao (字节跳动)
|
// Doubao (字节跳动)
|
||||||
|
// Seed 2.1 系列(2026-06 火山引擎官方 list 价,CNY 按 ~7.14 折算):
|
||||||
|
// pro 输入 6 元 / 输出 30 元 / 命中 1.2 元
|
||||||
|
// turbo 输入 3 元 / 输出 15 元 / 命中 0.6 元
|
||||||
|
// 「缓存存储 0.017 元/M/小时」是按时长计费的存储费,与本表 cache_creation(按 token 写入价)口径不同,置 0。
|
||||||
|
(
|
||||||
|
"doubao-seed-2-1-pro",
|
||||||
|
"Doubao Seed 2.1 Pro",
|
||||||
|
"0.84",
|
||||||
|
"4.2",
|
||||||
|
"0.17",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubao-seed-2-1-turbo",
|
||||||
|
"Doubao Seed 2.1 Turbo",
|
||||||
|
"0.42",
|
||||||
|
"2.1",
|
||||||
|
"0.08",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"doubao-seed-code",
|
"doubao-seed-code",
|
||||||
"Doubao Seed Code",
|
"Doubao Seed Code",
|
||||||
@@ -1602,7 +1717,7 @@ impl Database {
|
|||||||
"Doubao Seed 2.0 Pro",
|
"Doubao Seed 2.0 Pro",
|
||||||
"0.47",
|
"0.47",
|
||||||
"2.37",
|
"2.37",
|
||||||
"0",
|
"0.09",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1610,7 +1725,7 @@ impl Database {
|
|||||||
"Doubao Seed 2.0 Code",
|
"Doubao Seed 2.0 Code",
|
||||||
"0.47",
|
"0.47",
|
||||||
"2.37",
|
"2.37",
|
||||||
"0",
|
"0.09",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1618,15 +1733,15 @@ impl Database {
|
|||||||
"Doubao Seed 2.0 Code Preview",
|
"Doubao Seed 2.0 Code Preview",
|
||||||
"0.47",
|
"0.47",
|
||||||
"2.37",
|
"2.37",
|
||||||
"0",
|
"0.09",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"doubao-seed-2-0-lite",
|
"doubao-seed-2-0-lite",
|
||||||
"Doubao Seed 2.0 Lite",
|
"Doubao Seed 2.0 Lite",
|
||||||
"0.25",
|
"0.08",
|
||||||
"2",
|
"0.50",
|
||||||
"0",
|
"0.017",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1634,7 +1749,7 @@ impl Database {
|
|||||||
"Doubao Seed 2.0 Mini",
|
"Doubao Seed 2.0 Mini",
|
||||||
"0.03",
|
"0.03",
|
||||||
"0.31",
|
"0.31",
|
||||||
"0",
|
"0.0056",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
// DeepSeek 系列
|
// DeepSeek 系列
|
||||||
@@ -1706,8 +1821,16 @@ impl Database {
|
|||||||
"0.14",
|
"0.14",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
("kimi-k2.5", "Kimi K2.5", "0.60", "2.50", "0.10", "0"),
|
("kimi-k2.5", "Kimi K2.5", "0.60", "3.00", "0.10", "0"),
|
||||||
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
("kimi-k2.6", "Kimi K2.6", "0.95", "4.00", "0.16", "0"),
|
||||||
|
(
|
||||||
|
"kimi-k2.7-code",
|
||||||
|
"Kimi K2.7 Code",
|
||||||
|
"0.95",
|
||||||
|
"4.00",
|
||||||
|
"0.19",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
// MiniMax 系列
|
// MiniMax 系列
|
||||||
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
("minimax-m2.1", "MiniMax M2.1", "0.27", "0.95", "0.03", "0"),
|
||||||
(
|
(
|
||||||
@@ -1719,7 +1842,7 @@ impl Database {
|
|||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
("minimax-m2", "MiniMax M2", "0.27", "0.95", "0.03", "0"),
|
||||||
("minimax-m2.5", "MiniMax M2.5", "0.12", "0.95", "0.03", "0"),
|
("minimax-m2.5", "MiniMax M2.5", "0.15", "0.95", "0.03", "0"),
|
||||||
(
|
(
|
||||||
"minimax-m2.5-lightning",
|
"minimax-m2.5-lightning",
|
||||||
"MiniMax M2.5 Lightning",
|
"MiniMax M2.5 Lightning",
|
||||||
@@ -1746,10 +1869,11 @@ impl Database {
|
|||||||
),
|
),
|
||||||
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
("minimax-m3", "MiniMax M3", "0.60", "2.40", "0.12", "0"),
|
||||||
// GLM (智谱)
|
// GLM (智谱)
|
||||||
("glm-4.7", "GLM-4.7", "0.39", "1.75", "0.04", "0"),
|
("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"),
|
||||||
("glm-4.6", "GLM-4.6", "0.28", "1.11", "0.03", "0"),
|
("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"),
|
||||||
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
("glm-5", "GLM-5", "1", "3.2", "0.2", "0"),
|
||||||
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
("glm-5.1", "GLM-5.1", "1.4", "4.4", "0.26", "0"),
|
||||||
|
("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"),
|
||||||
// MiMo (小米)
|
// MiMo (小米)
|
||||||
(
|
(
|
||||||
"mimo-v2-flash",
|
"mimo-v2-flash",
|
||||||
@@ -1759,12 +1883,28 @@ impl Database {
|
|||||||
"0.009",
|
"0.009",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
("mimo-v2-pro", "MiMo V2 Pro", "1", "3", "0", "0"),
|
("mimo-v2-pro", "MiMo V2 Pro", "0.435", "0.87", "0.0036", "0"),
|
||||||
("mimo-v2.5", "MiMo V2.5", "0.09", "0.29", "0.009", "0"),
|
("mimo-v2.5", "MiMo V2.5", "0.14", "0.29", "0.0028", "0"),
|
||||||
("mimo-v2.5-pro", "MiMo V2.5 Pro", "1", "3", "0", "0"),
|
(
|
||||||
|
"mimo-v2.5-pro",
|
||||||
|
"MiMo V2.5 Pro",
|
||||||
|
"0.435",
|
||||||
|
"0.87",
|
||||||
|
"0.0036",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
// Qwen 系列 (阿里巴巴)
|
// Qwen 系列 (阿里巴巴)
|
||||||
("qwen3.6-plus", "Qwen3.6 Plus", "0.325", "1.95", "0", "0"),
|
("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"),
|
||||||
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0", "0"),
|
("qwen3.7-plus", "Qwen3.7 Plus", "0.40", "1.60", "0.08", "0"),
|
||||||
|
(
|
||||||
|
"qwen3.6-plus",
|
||||||
|
"Qwen3.6 Plus",
|
||||||
|
"0.325",
|
||||||
|
"1.95",
|
||||||
|
"0.065",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
("qwen3.5-plus", "Qwen3.5 Plus", "0.26", "1.56", "0.052", "0"),
|
||||||
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
("qwen3-max", "Qwen3 Max", "0.78", "3.90", "0", "0"),
|
||||||
(
|
(
|
||||||
"qwen3-235b-a22b",
|
"qwen3-235b-a22b",
|
||||||
@@ -1779,7 +1919,7 @@ impl Database {
|
|||||||
"Qwen3 Coder Plus",
|
"Qwen3 Coder Plus",
|
||||||
"0.65",
|
"0.65",
|
||||||
"3.25",
|
"3.25",
|
||||||
"0",
|
"0.13",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1803,7 +1943,7 @@ impl Database {
|
|||||||
"Qwen3 Coder Flash",
|
"Qwen3 Coder Flash",
|
||||||
"0.195",
|
"0.195",
|
||||||
"0.975",
|
"0.975",
|
||||||
"0",
|
"0.039",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -1818,19 +1958,20 @@ impl Database {
|
|||||||
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
("qwq-32b", "QwQ 32B", "0.20", "0.60", "0", "0"),
|
||||||
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
("qwen3-32b", "Qwen3 32B", "0.16", "0.64", "0", "0"),
|
||||||
// Grok 系列 (xAI)
|
// Grok 系列 (xAI)
|
||||||
|
("grok-4.3", "Grok 4.3", "1.25", "2.50", "0.20", "0"),
|
||||||
(
|
(
|
||||||
"grok-4.20-0309-reasoning",
|
"grok-4.20-0309-reasoning",
|
||||||
"Grok 4.20 Reasoning",
|
"Grok 4.20 Reasoning",
|
||||||
"2",
|
"1.25",
|
||||||
"6",
|
"2.50",
|
||||||
"0.20",
|
"0.20",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"grok-4.20-0309-non-reasoning",
|
"grok-4.20-0309-non-reasoning",
|
||||||
"Grok 4.20",
|
"Grok 4.20",
|
||||||
"2",
|
"1.25",
|
||||||
"6",
|
"2.50",
|
||||||
"0.20",
|
"0.20",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
@@ -1863,6 +2004,38 @@ impl Database {
|
|||||||
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
("grok-3", "Grok 3", "3", "15", "0.75", "0"),
|
||||||
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
("grok-3-mini", "Grok 3 Mini", "0.25", "0.50", "0.075", "0"),
|
||||||
// Mistral 系列
|
// Mistral 系列
|
||||||
|
(
|
||||||
|
"mistral-medium-3.5",
|
||||||
|
"Mistral Medium 3.5",
|
||||||
|
"1.50",
|
||||||
|
"7.50",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mistral-small-4",
|
||||||
|
"Mistral Small 4",
|
||||||
|
"0.10",
|
||||||
|
"0.30",
|
||||||
|
"0.01",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"devstral-small-2-2512",
|
||||||
|
"Devstral Small 2",
|
||||||
|
"0.10",
|
||||||
|
"0.30",
|
||||||
|
"0.01",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"magistral-small",
|
||||||
|
"Magistral Small",
|
||||||
|
"0.50",
|
||||||
|
"1.50",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
("codestral-2508", "Codestral", "0.30", "0.90", "0.03", "0"),
|
||||||
(
|
(
|
||||||
"devstral-small-1.1",
|
"devstral-small-1.1",
|
||||||
@@ -1872,7 +2045,7 @@ impl Database {
|
|||||||
"0.01",
|
"0.01",
|
||||||
"0",
|
"0",
|
||||||
),
|
),
|
||||||
("devstral-2-2512", "Devstral 2", "0.40", "0.90", "0.04", "0"),
|
("devstral-2-2512", "Devstral 2", "0.40", "2", "0.04", "0"),
|
||||||
(
|
(
|
||||||
"devstral-medium",
|
"devstral-medium",
|
||||||
"Devstral Medium",
|
"Devstral Medium",
|
||||||
@@ -1953,6 +2126,225 @@ impl Database {
|
|||||||
|
|
||||||
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
fn repair_current_model_pricing(conn: &Connection) -> Result<(), AppError> {
|
||||||
let pricing_fixes = [
|
let pricing_fixes = [
|
||||||
|
// 2026-06-10 全量核价(厂商官方 list 价;CNY 按 ~7.14 折算)
|
||||||
|
// GLM 4.6/4.7:旧值是中转/OpenRouter 折扣价,统一到 Z.ai 官方(与 glm-5/5.1 一致)
|
||||||
|
(
|
||||||
|
"glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0", "0.39", "1.75", "0.04", "0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0", "0.28", "1.11", "0.03", "0",
|
||||||
|
),
|
||||||
|
// Grok 4.20:xAI 已降价 2/6 → 1.25/2.50
|
||||||
|
(
|
||||||
|
"grok-4.20-0309-reasoning",
|
||||||
|
"Grok 4.20 Reasoning",
|
||||||
|
"1.25",
|
||||||
|
"2.50",
|
||||||
|
"0.20",
|
||||||
|
"0",
|
||||||
|
"2",
|
||||||
|
"6",
|
||||||
|
"0.20",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"grok-4.20-0309-non-reasoning",
|
||||||
|
"Grok 4.20",
|
||||||
|
"1.25",
|
||||||
|
"2.50",
|
||||||
|
"0.20",
|
||||||
|
"0",
|
||||||
|
"2",
|
||||||
|
"6",
|
||||||
|
"0.20",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// Kimi K2.5 官方 output 3.00
|
||||||
|
(
|
||||||
|
"kimi-k2.5",
|
||||||
|
"Kimi K2.5",
|
||||||
|
"0.60",
|
||||||
|
"3.00",
|
||||||
|
"0.10",
|
||||||
|
"0",
|
||||||
|
"0.60",
|
||||||
|
"2.50",
|
||||||
|
"0.10",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// MiniMax M2.5 input 0.15
|
||||||
|
(
|
||||||
|
"minimax-m2.5",
|
||||||
|
"MiniMax M2.5",
|
||||||
|
"0.15",
|
||||||
|
"0.95",
|
||||||
|
"0.03",
|
||||||
|
"0",
|
||||||
|
"0.12",
|
||||||
|
"0.95",
|
||||||
|
"0.03",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// Mistral Devstral 2 output 0.90 → 2(与同表 devstral-medium 一致)
|
||||||
|
(
|
||||||
|
"devstral-2-2512",
|
||||||
|
"Devstral 2",
|
||||||
|
"0.40",
|
||||||
|
"2",
|
||||||
|
"0.04",
|
||||||
|
"0",
|
||||||
|
"0.40",
|
||||||
|
"0.90",
|
||||||
|
"0.04",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// Doubao Seed 2.0:lite 旧价贵 3-4 倍 + 全系补 cache 命中价
|
||||||
|
(
|
||||||
|
"doubao-seed-2-0-lite",
|
||||||
|
"Doubao Seed 2.0 Lite",
|
||||||
|
"0.08",
|
||||||
|
"0.50",
|
||||||
|
"0.017",
|
||||||
|
"0",
|
||||||
|
"0.25",
|
||||||
|
"2",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubao-seed-2-0-pro",
|
||||||
|
"Doubao Seed 2.0 Pro",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0.09",
|
||||||
|
"0",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubao-seed-2-0-code",
|
||||||
|
"Doubao Seed 2.0 Code",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0.09",
|
||||||
|
"0",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubao-seed-2-0-code-preview-latest",
|
||||||
|
"Doubao Seed 2.0 Code Preview",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0.09",
|
||||||
|
"0",
|
||||||
|
"0.47",
|
||||||
|
"2.37",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"doubao-seed-2-0-mini",
|
||||||
|
"Doubao Seed 2.0 Mini",
|
||||||
|
"0.03",
|
||||||
|
"0.31",
|
||||||
|
"0.0056",
|
||||||
|
"0",
|
||||||
|
"0.03",
|
||||||
|
"0.31",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// MiMo:5/27 永久降价,旧值是旧价
|
||||||
|
(
|
||||||
|
"mimo-v2-pro",
|
||||||
|
"MiMo V2 Pro",
|
||||||
|
"0.435",
|
||||||
|
"0.87",
|
||||||
|
"0.0036",
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"3",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mimo-v2.5",
|
||||||
|
"MiMo V2.5",
|
||||||
|
"0.14",
|
||||||
|
"0.29",
|
||||||
|
"0.0028",
|
||||||
|
"0",
|
||||||
|
"0.09",
|
||||||
|
"0.29",
|
||||||
|
"0.009",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mimo-v2.5-pro",
|
||||||
|
"MiMo V2.5 Pro",
|
||||||
|
"0.435",
|
||||||
|
"0.87",
|
||||||
|
"0.0036",
|
||||||
|
"0",
|
||||||
|
"1",
|
||||||
|
"3",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
// Qwen:官方"隐式缓存 = 输入 20%"补 cache 命中价
|
||||||
|
(
|
||||||
|
"qwen3.6-plus",
|
||||||
|
"Qwen3.6 Plus",
|
||||||
|
"0.325",
|
||||||
|
"1.95",
|
||||||
|
"0.065",
|
||||||
|
"0",
|
||||||
|
"0.325",
|
||||||
|
"1.95",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"qwen3.5-plus",
|
||||||
|
"Qwen3.5 Plus",
|
||||||
|
"0.26",
|
||||||
|
"1.56",
|
||||||
|
"0.052",
|
||||||
|
"0",
|
||||||
|
"0.26",
|
||||||
|
"1.56",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"qwen3-coder-plus",
|
||||||
|
"Qwen3 Coder Plus",
|
||||||
|
"0.65",
|
||||||
|
"3.25",
|
||||||
|
"0.13",
|
||||||
|
"0",
|
||||||
|
"0.65",
|
||||||
|
"3.25",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"qwen3-coder-flash",
|
||||||
|
"Qwen3 Coder Flash",
|
||||||
|
"0.195",
|
||||||
|
"0.975",
|
||||||
|
"0.039",
|
||||||
|
"0",
|
||||||
|
"0.195",
|
||||||
|
"0.975",
|
||||||
|
"0",
|
||||||
|
"0",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"deepseek-v4-flash",
|
"deepseek-v4-flash",
|
||||||
"DeepSeek V4 Flash",
|
"DeepSeek V4 Flash",
|
||||||
|
|||||||
@@ -345,6 +345,87 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migration_v10_to_v11_rebuilds_rollups_with_request_model_dimension() {
|
||||||
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|
||||||
|
// 模拟 v10 形状的 rollup 表(主键不含 request_model)+ 一行历史聚合数据,
|
||||||
|
// 以及 v10 形状的明细表(无 pricing_model 列)
|
||||||
|
conn.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE proxy_request_logs (
|
||||||
|
request_id TEXT PRIMARY KEY,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_model TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE usage_daily_rollups (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
app_type TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
success_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
avg_latency_ms INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (date, app_type, provider_id, model)
|
||||||
|
);
|
||||||
|
INSERT INTO usage_daily_rollups
|
||||||
|
(date, app_type, provider_id, model, request_count, success_count,
|
||||||
|
input_tokens, output_tokens, total_cost_usd, avg_latency_ms)
|
||||||
|
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 7, 7, 1000, 500, '0.07', 120);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.expect("seed v10 rollup table");
|
||||||
|
|
||||||
|
Database::set_user_version(&conn, 10).expect("set user_version=10");
|
||||||
|
Database::apply_schema_migrations_on_conn(&conn).expect("apply migrations");
|
||||||
|
|
||||||
|
// 新列存在且 NOT NULL DEFAULT ''
|
||||||
|
let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model");
|
||||||
|
assert_eq!(request_model.r#type, "TEXT");
|
||||||
|
assert_eq!(request_model.notnull, 1);
|
||||||
|
let rollup_pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model");
|
||||||
|
assert_eq!(rollup_pricing_model.r#type, "TEXT");
|
||||||
|
assert_eq!(rollup_pricing_model.notnull, 1);
|
||||||
|
|
||||||
|
// 明细表补上 pricing_model 列(可空,历史行 NULL)
|
||||||
|
let pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model");
|
||||||
|
assert_eq!(pricing_model.r#type, "TEXT");
|
||||||
|
assert_eq!(pricing_model.notnull, 0);
|
||||||
|
|
||||||
|
// 历史行保留,request_model 填 ''(未知)
|
||||||
|
let (rm, count, input, cost): (String, i64, i64, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT request_model, request_count, input_tokens, total_cost_usd
|
||||||
|
FROM usage_daily_rollups WHERE model = 'kimi-k2'",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||||
|
)
|
||||||
|
.expect("migrated row");
|
||||||
|
assert_eq!(rm, "");
|
||||||
|
assert_eq!(count, 7);
|
||||||
|
assert_eq!(input, 1000);
|
||||||
|
assert_eq!(cost, "0.07");
|
||||||
|
|
||||||
|
// 主键包含 request_model:同 model 不同别名可共存
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO usage_daily_rollups
|
||||||
|
(date, app_type, provider_id, model, request_model, request_count)
|
||||||
|
VALUES ('2026-05-01', 'claude', 'p1', 'kimi-k2', 'claude-sonnet-4-6', 1)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.expect("insert row with same model but different request_model");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
Database::get_user_version(&conn).expect("version after migration"),
|
||||||
|
SCHEMA_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
|
||||||
let conn = Connection::open_in_memory().expect("open memory db");
|
let conn = Connection::open_in_memory().expect("open memory db");
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ fn build_provider_meta(request: &DeepLinkImportRequest) -> Result<Option<Provide
|
|||||||
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
template_type: None, // Deeplink providers don't specify template type (will use backward compatibility logic)
|
||||||
auto_query_interval: request.usage_auto_interval,
|
auto_query_interval: request.usage_auto_interval,
|
||||||
coding_plan_provider: None,
|
coding_plan_provider: None,
|
||||||
|
access_key_id: None,
|
||||||
|
secret_access_key: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some(ProviderMeta {
|
Ok(Some(ProviderMeta {
|
||||||
|
|||||||
@@ -116,10 +116,76 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
|||||||
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
|
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
serde_yaml::from_str(&content)
|
// Heal duplicate top-level keys left behind by the pre-CRLF-fix append
|
||||||
|
// bug (#3633); serde_yaml rejects them outright, which bricked the panel.
|
||||||
|
let deduped = deduplicate_top_level_keys(&content);
|
||||||
|
|
||||||
|
serde_yaml::from_str(&deduped)
|
||||||
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
|
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of
|
||||||
|
/// each key.
|
||||||
|
///
|
||||||
|
/// Keep-last is deliberate, not arbitrary: the duplicates come from section
|
||||||
|
/// replacement degrading into appends (#3633), so the last block is the
|
||||||
|
/// newest data — and Hermes itself reads the file with PyYAML, whose
|
||||||
|
/// duplicate-key semantics are last-wins. Keeping the first occurrence would
|
||||||
|
/// silently roll the user back to stale config and diverge from what Hermes
|
||||||
|
/// actually runs with.
|
||||||
|
fn deduplicate_top_level_keys(raw: &str) -> String {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
// Pass 1: locate every top-level key line as (key, byte offset).
|
||||||
|
let mut sections: Vec<(&str, usize)> = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
for line in raw.split('\n') {
|
||||||
|
if is_top_level_key_line(line) {
|
||||||
|
if let Some(colon_pos) = line.find(':') {
|
||||||
|
sections.push((&line[..colon_pos], offset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += line.len() + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remaining: HashMap<&str, usize> = HashMap::new();
|
||||||
|
for (key, _) in §ions {
|
||||||
|
*remaining.entry(key).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
if remaining.values().all(|&count| count <= 1) {
|
||||||
|
return raw.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: re-emit, dropping every section that has a later occurrence of
|
||||||
|
// the same key. A section spans from its key line to the next top-level
|
||||||
|
// key line (or EOF), matching find_yaml_section_range. Content before the
|
||||||
|
// first section (comments, document markers) is always kept.
|
||||||
|
let mut result = String::with_capacity(raw.len());
|
||||||
|
let head_end = sections
|
||||||
|
.first()
|
||||||
|
.map(|&(_, start)| start)
|
||||||
|
.unwrap_or(raw.len());
|
||||||
|
result.push_str(&raw[..head_end]);
|
||||||
|
|
||||||
|
for (i, &(key, start)) in sections.iter().enumerate() {
|
||||||
|
let end = sections
|
||||||
|
.get(i + 1)
|
||||||
|
.map(|&(_, next_start)| next_start)
|
||||||
|
.unwrap_or(raw.len());
|
||||||
|
let count = remaining.get_mut(key).expect("key collected in pass 1");
|
||||||
|
*count -= 1;
|
||||||
|
if *count > 0 {
|
||||||
|
log::warn!(
|
||||||
|
"Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push_str(&raw[start..end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// YAML Section-Level Replacement
|
// YAML Section-Level Replacement
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -132,6 +198,11 @@ pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
|
|||||||
/// - Not be a comment (starting with `#`)
|
/// - Not be a comment (starting with `#`)
|
||||||
/// - Not be a sequence item (starting with `-`)
|
/// - Not be a sequence item (starting with `-`)
|
||||||
/// - Contain `:` followed by space, tab, newline, or end-of-line
|
/// - Contain `:` followed by space, tab, newline, or end-of-line
|
||||||
|
///
|
||||||
|
/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n`
|
||||||
|
/// (callers using `split_inclusive`); both count as end-of-line after the
|
||||||
|
/// colon. Rejecting `\r` here used to make every section lookup miss on
|
||||||
|
/// CRLF configs, turning section replacement into endless appends (#3633).
|
||||||
fn is_top_level_key_line(line: &str) -> bool {
|
fn is_top_level_key_line(line: &str) -> bool {
|
||||||
if line.is_empty() {
|
if line.is_empty() {
|
||||||
return false;
|
return false;
|
||||||
@@ -142,7 +213,7 @@ fn is_top_level_key_line(line: &str) -> bool {
|
|||||||
}
|
}
|
||||||
if let Some(colon_pos) = line.find(':') {
|
if let Some(colon_pos) = line.find(':') {
|
||||||
let after_colon = &line[colon_pos + 1..];
|
let after_colon = &line[colon_pos + 1..];
|
||||||
after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t')
|
after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n'])
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
@@ -196,6 +267,21 @@ fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String
|
|||||||
Ok(yaml_str)
|
Ok(yaml_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remove every top-level section with the given key from raw YAML text.
|
||||||
|
/// Used to clean residual duplicates of a key after replacing its first
|
||||||
|
/// occurrence; safe values come from the keep-last healed read, so dropping
|
||||||
|
/// all on-disk copies here loses nothing.
|
||||||
|
fn remove_all_sections(raw: &str, section_key: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(raw.len());
|
||||||
|
let mut rest = raw;
|
||||||
|
while let Some((start, end)) = find_yaml_section_range(rest, section_key) {
|
||||||
|
result.push_str(&rest[..start]);
|
||||||
|
rest = &rest[end..];
|
||||||
|
}
|
||||||
|
result.push_str(rest);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Replace a YAML section in raw text, or append it if not found.
|
/// Replace a YAML section in raw text, or append it if not found.
|
||||||
fn replace_yaml_section(
|
fn replace_yaml_section(
|
||||||
raw: &str,
|
raw: &str,
|
||||||
@@ -208,12 +294,14 @@ fn replace_yaml_section(
|
|||||||
let mut result = String::with_capacity(raw.len());
|
let mut result = String::with_capacity(raw.len());
|
||||||
result.push_str(&raw[..start]);
|
result.push_str(&raw[..start]);
|
||||||
result.push_str(&serialized);
|
result.push_str(&serialized);
|
||||||
|
// Drop duplicate sections of this key from the remainder — configs
|
||||||
|
// written before the CRLF fix may carry several appended copies.
|
||||||
|
let remainder = remove_all_sections(&raw[end..], section_key);
|
||||||
// Ensure proper separation between sections
|
// Ensure proper separation between sections
|
||||||
let remainder = &raw[end..];
|
|
||||||
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
|
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
|
||||||
result.push('\n');
|
result.push('\n');
|
||||||
}
|
}
|
||||||
result.push_str(remainder);
|
result.push_str(&remainder);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
} else {
|
} else {
|
||||||
// Section not found — append at end
|
// Section not found — append at end
|
||||||
@@ -1204,6 +1292,111 @@ model:
|
|||||||
assert!(!section.starts_with("model_extra:"));
|
assert!(!section.starts_with("model_extra:"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_section_handles_crlf() {
|
||||||
|
// Regression for #3633: CRLF line endings must not hide sections.
|
||||||
|
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||||
|
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
|
||||||
|
let section = &yaml[start..end];
|
||||||
|
assert!(section.starts_with("model:"));
|
||||||
|
assert!(section.contains("default: gpt-4"));
|
||||||
|
assert!(!section.contains("agent:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- deduplicate_top_level_keys tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_keeps_last_occurrence() {
|
||||||
|
// Duplicates come from replace-degraded-to-append, so the last block
|
||||||
|
// is the newest data and must win (PyYAML last-wins, like Hermes).
|
||||||
|
let yaml = "\
|
||||||
|
model:
|
||||||
|
default: gpt-4
|
||||||
|
agent:
|
||||||
|
max_turns: 10
|
||||||
|
model:
|
||||||
|
default: claude-opus-4-8
|
||||||
|
";
|
||||||
|
let result = deduplicate_top_level_keys(yaml);
|
||||||
|
assert_eq!(
|
||||||
|
result.lines().filter(|l| *l == "model:").count(),
|
||||||
|
1,
|
||||||
|
"duplicate model: section was not removed"
|
||||||
|
);
|
||||||
|
assert!(result.contains("claude-opus-4-8"));
|
||||||
|
assert!(!result.contains("gpt-4"));
|
||||||
|
assert!(result.contains("max_turns"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_handles_crlf() {
|
||||||
|
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n";
|
||||||
|
let result = deduplicate_top_level_keys(yaml);
|
||||||
|
assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1);
|
||||||
|
assert!(result.contains("default: claude"));
|
||||||
|
assert!(!result.contains("gpt-4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_is_identity_without_duplicates() {
|
||||||
|
let yaml = "\
|
||||||
|
# Hermes config
|
||||||
|
model:
|
||||||
|
default: gpt-4
|
||||||
|
|
||||||
|
agent:
|
||||||
|
max_turns: 10
|
||||||
|
";
|
||||||
|
assert_eq!(deduplicate_top_level_keys(yaml), yaml);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dedup_result_parses_with_last_value() {
|
||||||
|
// End-to-end: a config that serde_yaml rejects today must parse after
|
||||||
|
// healing, and expose the newest (last) value.
|
||||||
|
let yaml = "\
|
||||||
|
custom_providers:
|
||||||
|
- name: old-provider
|
||||||
|
model:
|
||||||
|
default: gpt-4
|
||||||
|
custom_providers:
|
||||||
|
- name: old-provider
|
||||||
|
- name: new-provider
|
||||||
|
";
|
||||||
|
let healed = deduplicate_top_level_keys(yaml);
|
||||||
|
let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap();
|
||||||
|
let providers = value
|
||||||
|
.get("custom_providers")
|
||||||
|
.unwrap()
|
||||||
|
.as_sequence()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
providers[1].get("name").unwrap().as_str().unwrap(),
|
||||||
|
"new-provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- remove_all_sections tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_all_sections_strips_every_occurrence() {
|
||||||
|
let yaml = "\
|
||||||
|
model:
|
||||||
|
default: gpt-4
|
||||||
|
agent:
|
||||||
|
max_turns: 10
|
||||||
|
model:
|
||||||
|
default: claude
|
||||||
|
model:
|
||||||
|
default: gemini
|
||||||
|
";
|
||||||
|
let result = remove_all_sections(yaml, "model");
|
||||||
|
assert!(!result.contains("model:"));
|
||||||
|
assert!(result.contains("agent:"));
|
||||||
|
assert!(result.contains("max_turns"));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- replace_yaml_section tests ----
|
// ---- replace_yaml_section tests ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1239,6 +1432,62 @@ agent:
|
|||||||
assert!(!result.contains("openai"));
|
assert!(!result.contains("openai"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_section_in_crlf_config_replaces_in_place() {
|
||||||
|
// Regression for #3633: on CRLF configs every "replace" used to
|
||||||
|
// degrade into an append, piling up duplicate sections.
|
||||||
|
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
|
||||||
|
let new_model = serde_yaml::Value::Mapping({
|
||||||
|
let mut m = serde_yaml::Mapping::new();
|
||||||
|
m.insert(
|
||||||
|
serde_yaml::Value::String("default".to_string()),
|
||||||
|
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.lines().filter(|l| l.trim() == "model:").count(),
|
||||||
|
1,
|
||||||
|
"model: must be replaced in place, not appended"
|
||||||
|
);
|
||||||
|
assert!(result.contains("claude-opus-4-8"));
|
||||||
|
assert!(!result.contains("gpt-4"));
|
||||||
|
assert!(result.contains("max_turns"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replace_section_removes_residual_duplicates() {
|
||||||
|
// A config already broken by the append bug: replacing the section
|
||||||
|
// must also clean the stale duplicate copies after it.
|
||||||
|
let yaml = "\
|
||||||
|
model:
|
||||||
|
default: gpt-4
|
||||||
|
agent:
|
||||||
|
max_turns: 10
|
||||||
|
model:
|
||||||
|
default: stale-copy
|
||||||
|
";
|
||||||
|
let new_model = serde_yaml::Value::Mapping({
|
||||||
|
let mut m = serde_yaml::Mapping::new();
|
||||||
|
m.insert(
|
||||||
|
serde_yaml::Value::String("default".to_string()),
|
||||||
|
serde_yaml::Value::String("claude-opus-4-8".to_string()),
|
||||||
|
);
|
||||||
|
m
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
|
||||||
|
assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1);
|
||||||
|
assert!(result.contains("claude-opus-4-8"));
|
||||||
|
assert!(!result.contains("stale-copy"));
|
||||||
|
assert!(result.contains("agent:"));
|
||||||
|
// The healed output must be valid YAML again
|
||||||
|
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&result);
|
||||||
|
assert!(parsed.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_new_section() {
|
fn append_new_section() {
|
||||||
let yaml = "\
|
let yaml = "\
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ use std::sync::{OnceLock, RwLock};
|
|||||||
pub struct InitErrorPayload {
|
pub struct InitErrorPayload {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
/// 错误类别。`Some("db_version_too_new")` 表示数据库版本过新(应用过旧),
|
||||||
|
/// 前端据此展示「升级应用」恢复界面而非直接退出。
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
/// 磁盘上数据库的 user_version(数据库版本过新时填充)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub db_version: Option<i32>,
|
||||||
|
/// 当前应用支持的 SCHEMA_VERSION(数据库版本过新时填充)。
|
||||||
|
/// 当升级到最新版后 db_version 仍 > supported_version,说明可能由第三方客户端创建。
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub supported_version: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
static INIT_ERROR: OnceLock<RwLock<Option<InitErrorPayload>>> = OnceLock::new();
|
||||||
@@ -13,7 +24,6 @@ fn cell() -> &'static RwLock<Option<InitErrorPayload>> {
|
|||||||
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
INIT_ERROR.get_or_init(|| RwLock::new(None))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn set_init_error(payload: InitErrorPayload) {
|
pub fn set_init_error(payload: InitErrorPayload) {
|
||||||
#[allow(clippy::unwrap_used)]
|
#[allow(clippy::unwrap_used)]
|
||||||
if let Ok(mut guard) = cell().write() {
|
if let Ok(mut guard) = cell().write() {
|
||||||
@@ -102,6 +112,9 @@ mod tests {
|
|||||||
let payload = InitErrorPayload {
|
let payload = InitErrorPayload {
|
||||||
path: "/tmp/config.json".into(),
|
path: "/tmp/config.json".into(),
|
||||||
error: "broken json".into(),
|
error: "broken json".into(),
|
||||||
|
kind: None,
|
||||||
|
db_version: None,
|
||||||
|
supported_version: None,
|
||||||
};
|
};
|
||||||
set_init_error(payload.clone());
|
set_init_error(payload.clone());
|
||||||
let got = get_init_error().expect("should get payload back");
|
let got = get_init_error().expect("should get payload back");
|
||||||
|
|||||||
@@ -270,6 +270,16 @@ pub fn run() {
|
|||||||
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
// 拦截窗口关闭:根据设置决定是否最小化到托盘
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
// 数据库版本过新的恢复模式下没有托盘可唤回,关闭即退出,避免应用隐身后台
|
||||||
|
let in_db_recovery = crate::init_status::get_init_error()
|
||||||
|
.map(|p| p.kind.as_deref() == Some("db_version_too_new"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if in_db_recovery {
|
||||||
|
api.prevent_close();
|
||||||
|
window.app_handle().exit(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let settings = crate::settings::get_settings();
|
let settings = crate::settings::get_settings();
|
||||||
|
|
||||||
if settings.minimize_to_tray_on_close {
|
if settings.minimize_to_tray_on_close {
|
||||||
@@ -403,6 +413,35 @@ pub fn run() {
|
|||||||
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
// 说明:从 v3.8.* 升级的用户通常会走到这里的 SQLite schema 迁移,
|
||||||
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
// 若迁移失败(数据库损坏/权限不足/user_version 过新等),需要给用户明确提示,
|
||||||
// 否则表现可能只是“应用打不开/闪退”。
|
// 否则表现可能只是“应用打不开/闪退”。
|
||||||
|
//
|
||||||
|
// 预检:数据库版本过新时,必须先于任何 schema 写操作(create_tables 内含
|
||||||
|
// DROP/ALTER 等 DDL)进入恢复界面,避免旧应用对读不懂的更新版 DB 落写。
|
||||||
|
match crate::database::Database::stored_user_version_exceeds_supported(&db_path) {
|
||||||
|
Ok(Some(version)) => {
|
||||||
|
log::warn!("数据库版本过新(v{version}),引导用户在应用内升级应用");
|
||||||
|
crate::init_status::set_init_error(crate::init_status::InitErrorPayload {
|
||||||
|
path: db_path.display().to_string(),
|
||||||
|
error: format!(
|
||||||
|
"数据库版本过新({version}),当前应用仅支持 {},请升级应用后再尝试。",
|
||||||
|
crate::database::SCHEMA_VERSION
|
||||||
|
),
|
||||||
|
kind: Some("db_version_too_new".to_string()),
|
||||||
|
db_version: Some(version),
|
||||||
|
supported_version: Some(crate::database::SCHEMA_VERSION),
|
||||||
|
});
|
||||||
|
// 主窗口默认 visible:false,恢复界面必须强制显示
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("预检数据库版本失败,继续正常初始化流程: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let db = loop {
|
let db = loop {
|
||||||
match crate::database::Database::init() {
|
match crate::database::Database::init() {
|
||||||
Ok(db) => break Arc::new(db),
|
Ok(db) => break Arc::new(db),
|
||||||
@@ -600,6 +639,25 @@ pub fn run() {
|
|||||||
log::warn!("✗ Codex provider template bucket migration failed: {e}");
|
log::warn!("✗ Codex provider template bucket migration failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一会话开关的官方历史迁移:开关开启但上次未完成(如文件被占用
|
||||||
|
// 中途失败)时在启动期重试;函数内部自门控,开关关闭时直接跳过。
|
||||||
|
match crate::codex_history_migration::maybe_migrate_codex_official_history_to_unified_bucket() {
|
||||||
|
Ok(outcome) => {
|
||||||
|
if let Some(reason) = outcome.skipped_reason {
|
||||||
|
log::debug!("○ Codex official history unify migration skipped: {reason}");
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"✓ Codex official history unify migration completed: jsonl_files={}, state_rows={}",
|
||||||
|
outcome.migrated_jsonl_files,
|
||||||
|
outcome.migrated_state_rows
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("✗ Codex official history unify migration failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,6 +1214,8 @@ pub fn run() {
|
|||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
|
commands::has_codex_unify_history_backup,
|
||||||
|
commands::restore_codex_unified_history,
|
||||||
commands::get_rectifier_config,
|
commands::get_rectifier_config,
|
||||||
commands::set_rectifier_config,
|
commands::set_rectifier_config,
|
||||||
commands::get_optimizer_config,
|
commands::get_optimizer_config,
|
||||||
@@ -1165,6 +1225,8 @@ pub fn run() {
|
|||||||
commands::get_log_config,
|
commands::get_log_config,
|
||||||
commands::set_log_config,
|
commands::set_log_config,
|
||||||
commands::restart_app,
|
commands::restart_app,
|
||||||
|
commands::install_update_and_restart,
|
||||||
|
commands::check_app_update_available,
|
||||||
commands::check_for_updates,
|
commands::check_for_updates,
|
||||||
commands::is_portable_mode,
|
commands::is_portable_mode,
|
||||||
commands::copy_text_to_clipboard,
|
commands::copy_text_to_clipboard,
|
||||||
@@ -1443,14 +1505,37 @@ pub fn run() {
|
|||||||
app.run(|app_handle, event| {
|
app.run(|app_handle, event| {
|
||||||
// 处理退出请求(所有平台)
|
// 处理退出请求(所有平台)
|
||||||
if let RunEvent::ExitRequested { api, code, .. } = &event {
|
if let RunEvent::ExitRequested { api, code, .. } = &event {
|
||||||
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
match classify_exit_request(*code) {
|
||||||
// 此时应仅阻止退出、保持托盘后台运行;
|
// code 为 None 表示运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活窗口),
|
||||||
// code 为 Some(_) 表示用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
// 此时应仅阻止退出、保持托盘后台运行。
|
||||||
// 此时执行清理后退出。
|
ExitRequestAction::StayInTray => {
|
||||||
if code.is_none() {
|
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
||||||
log::info!("运行时触发退出请求(无存活窗口),阻止退出以保持托盘后台运行");
|
api.prevent_exit();
|
||||||
api.prevent_exit();
|
return;
|
||||||
return;
|
}
|
||||||
|
// code 为 RESTART_EXIT_CODE:app.restart() / 自更新 relaunch 发起的重启。
|
||||||
|
// 这条路径上 prevent_exit() 会被 Tauri 忽略,事件循环必定退出,随后由
|
||||||
|
// Tauri 在 RunEvent::Exit 后用新二进制 re-exec(macOS 会按更新后的
|
||||||
|
// Info.plist 解析可执行名)。
|
||||||
|
//
|
||||||
|
// 绝不能复用下面的异步清理任务:该任务在 tokio 线程调 save_window_state,
|
||||||
|
// 持有 window-state 插件锁的同时向主线程查询窗口几何;而主线程此刻正在
|
||||||
|
// 退出事件循环,并在插件自带的 RunEvent::Exit 钩子里等待同一把锁——双方
|
||||||
|
// 互等造成进程永久卡死(更新已安装但应用冻结、不再重启,见 #3998)。
|
||||||
|
//
|
||||||
|
// 重启路径交还 Tauri 默认流程即可:
|
||||||
|
// - 窗口状态:插件 Exit 钩子在主线程保存(同线程读取窗口几何,无死锁)
|
||||||
|
// - 托盘图标:Tauri 内部 cleanup_before_exit 清理,正常走 Drop
|
||||||
|
// - 代理/Live 配置:无需恢复,重启后新实例立即接管并恢复代理状态
|
||||||
|
// - 100ms 落盘等待:重启前的 DB 写入均为命令驱动、此刻已完成,
|
||||||
|
// 与所有 Tauri 应用默认重启路径的行为一致,无需额外等待
|
||||||
|
ExitRequestAction::DeferToTauriRestart => {
|
||||||
|
log::info!("收到重启请求 (code={code:?}),交由 Tauri 默认重启流程 re-exec");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 其它 Some(_):用户主动调用 app.exit() 退出(如托盘菜单"退出"),
|
||||||
|
// 此时执行清理后退出。
|
||||||
|
ExitRequestAction::CleanupAndExit => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
|
log::info!("收到用户主动退出请求 (code={code:?}),开始清理...");
|
||||||
@@ -1622,7 +1707,7 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
|||||||
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`,
|
/// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`,
|
||||||
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
|
/// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是
|
||||||
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
|
/// 正常的隐藏/移除语义,作为跨平台兜底也安全。
|
||||||
fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) {
|
||||||
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
|
if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) {
|
||||||
if let Err(e) = tray.set_visible(false) {
|
if let Err(e) = tray.set_visible(false) {
|
||||||
log::warn!("退出时移除托盘图标失败: {e}");
|
log::warn!("退出时移除托盘图标失败: {e}");
|
||||||
@@ -1891,6 +1976,36 @@ fn show_database_init_error_dialog(
|
|||||||
.blocking_show()
|
.blocking_show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 退出请求分类
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// `RunEvent::ExitRequested` 的三类来源,处理方式必须区分。
|
||||||
|
///
|
||||||
|
/// 关键约束:重启请求(`code == RESTART_EXIT_CODE`)上 `prevent_exit()` 会被
|
||||||
|
/// Tauri 静默忽略(见 `ExitRequestApi::prevent_exit` 文档),事件循环必定继续
|
||||||
|
/// 退出并触发各插件的 `RunEvent::Exit` 钩子;任何与之并发的自定义清理任务都
|
||||||
|
/// 可能与插件退出钩子争用同一状态而死锁。
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ExitRequestAction {
|
||||||
|
/// `code` 为 `None`:运行时自动触发(如隐藏窗口的 WebView 被回收导致无存活
|
||||||
|
/// 窗口),阻止退出、保持托盘后台运行。
|
||||||
|
StayInTray,
|
||||||
|
/// `code` 为 `RESTART_EXIT_CODE`:`app.restart()` / 自更新 relaunch 发起的
|
||||||
|
/// 重启,不拦截、不做自定义清理,交还 Tauri 默认 re-exec 流程。
|
||||||
|
DeferToTauriRestart,
|
||||||
|
/// 其它 `Some(_)`:用户主动退出(托盘「退出」等),执行完整异步清理后结束进程。
|
||||||
|
CleanupAndExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_exit_request(code: Option<i32>) -> ExitRequestAction {
|
||||||
|
match code {
|
||||||
|
None => ExitRequestAction::StayInTray,
|
||||||
|
Some(tauri::RESTART_EXIT_CODE) => ExitRequestAction::DeferToTauriRestart,
|
||||||
|
Some(_) => ExitRequestAction::CleanupAndExit,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 在应用主动退出前显式持久化窗口状态
|
// 在应用主动退出前显式持久化窗口状态
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1908,3 +2023,59 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) {
|
|||||||
log::info!("已在退出前保存窗口状态");
|
log::info!("已在退出前保存窗口状态");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 主动释放 single-instance 锁。
|
||||||
|
///
|
||||||
|
/// macOS single-instance 使用 `/tmp/{identifier}.sock`。我们有若干路径会直接
|
||||||
|
/// `std::process::exit(0)`,不会触发插件挂在 `RunEvent::Exit` 上的清理钩子。
|
||||||
|
/// 重启前主动 destroy 可以避免新进程误连旧 listener 后自行退出。
|
||||||
|
pub fn destroy_single_instance_lock(app_handle: &tauri::AppHandle) {
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
|
||||||
|
tauri_plugin_single_instance::destroy(app_handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理托盘图标、释放 single-instance 锁后重启当前应用。
|
||||||
|
///
|
||||||
|
/// 直接走 `tauri::process::restart`(spawn 新进程 + `exit(0)`),不经过事件
|
||||||
|
/// 循环退出,因此 Tauri 内部的 `cleanup_before_exit` 和各插件的
|
||||||
|
/// `RunEvent::Exit` 钩子都不会执行。需要的清理由调用方与本函数显式补偿:
|
||||||
|
/// 窗口状态、代理/Live 恢复(调用方);托盘图标、single-instance 锁(本函数)。
|
||||||
|
///
|
||||||
|
/// 有意不调 `AppHandle::cleanup_before_exit()`:它会在调用线程上 Drop 托盘
|
||||||
|
/// 图标,而 macOS 的 NSStatusItem 操作要求主线程;`set_visible(false)` 走
|
||||||
|
/// `run_item_main_thread` 代理,跨线程安全(见 `remove_tray_icon_before_exit`)。
|
||||||
|
pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||||
|
remove_tray_icon_before_exit(app_handle);
|
||||||
|
destroy_single_instance_lock(app_handle);
|
||||||
|
tauri::process::restart(&app_handle.env());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{classify_exit_request, ExitRequestAction};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_code_keeps_app_alive_in_tray() {
|
||||||
|
assert_eq!(classify_exit_request(None), ExitRequestAction::StayInTray);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restart_exit_code_defers_to_tauri_default_restart() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_exit_request(Some(tauri::RESTART_EXIT_CODE)),
|
||||||
|
ExitRequestAction::DeferToTauriRestart
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn user_exit_codes_run_cleanup_then_exit() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_exit_request(Some(0)),
|
||||||
|
ExitRequestAction::CleanupAndExit
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_exit_request(Some(1)),
|
||||||
|
ExitRequestAction::CleanupAndExit
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use http::header::{HeaderValue, InvalidHeaderValue};
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -108,17 +109,13 @@ impl Provider {
|
|||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve `(base_url, api_key)` for native usage queries (balance /
|
/// Resolve `(base_url, api_key)` for usage queries (native balance /
|
||||||
/// coding-plan) from the stored provider config.
|
/// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback)
|
||||||
|
/// from the stored provider config.
|
||||||
///
|
///
|
||||||
/// Each app persists credentials in a different shape, so callers must pass
|
/// Each app persists credentials in a different shape, so callers must pass
|
||||||
/// the owning app type. This mirrors the frontend `getProviderCredentials`
|
/// the owning app type. This mirrors the frontend `getProviderCredentials`
|
||||||
/// in `UsageScriptModal.tsx`.
|
/// in `UsageScriptModal.tsx`.
|
||||||
///
|
|
||||||
/// TODO: the env-only helpers in `services/provider/usage.rs`
|
|
||||||
/// (`extract_api_key_from_provider` / `extract_base_url_from_provider`)
|
|
||||||
/// duplicate this per-app logic on the JS-script path and could delegate
|
|
||||||
/// here in a follow-up to remove the remaining copy.
|
|
||||||
pub fn resolve_usage_credentials(
|
pub fn resolve_usage_credentials(
|
||||||
&self,
|
&self,
|
||||||
app_type: &crate::app_config::AppType,
|
app_type: &crate::app_config::AppType,
|
||||||
@@ -254,6 +251,14 @@ pub struct UsageScript {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "codingPlanProvider")]
|
#[serde(rename = "codingPlanProvider")]
|
||||||
pub coding_plan_provider: Option<String>,
|
pub coding_plan_provider: Option<String>,
|
||||||
|
/// 火山方舟控制面 OpenAPI 的 AccessKey ID(用量查询签名用,与推理 Key 是两套凭据)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "accessKeyId")]
|
||||||
|
pub access_key_id: Option<String>,
|
||||||
|
/// 火山方舟控制面 OpenAPI 的 SecretAccessKey(同上)
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[serde(rename = "secretAccessKey")]
|
||||||
|
pub secret_access_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用量数据
|
/// 用量数据
|
||||||
@@ -290,21 +295,15 @@ pub struct UsageResult {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 供应商单独的模型测试配置
|
/// 供应商单独的连通检测配置
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderTestConfig {
|
pub struct ProviderTestConfig {
|
||||||
/// 是否启用单独配置(false 时使用全局配置)
|
/// 是否启用单独配置(false 时使用全局配置)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// 测试用的模型名称(覆盖全局配置)
|
|
||||||
#[serde(rename = "testModel", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub test_model: Option<String>,
|
|
||||||
/// 超时时间(秒)
|
/// 超时时间(秒)
|
||||||
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "timeoutSecs", skip_serializing_if = "Option::is_none")]
|
||||||
pub timeout_secs: Option<u64>,
|
pub timeout_secs: Option<u64>,
|
||||||
/// 测试提示词
|
|
||||||
#[serde(rename = "testPrompt", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub test_prompt: Option<String>,
|
|
||||||
/// 降级阈值(毫秒)
|
/// 降级阈值(毫秒)
|
||||||
#[serde(
|
#[serde(
|
||||||
rename = "degradedThresholdMs",
|
rename = "degradedThresholdMs",
|
||||||
@@ -383,6 +382,21 @@ pub struct CodexChatReasoningConfig {
|
|||||||
pub output_format: Option<String>,
|
pub output_format: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local proxy request overrides applied after route/protocol transforms.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct LocalProxyRequestOverrides {
|
||||||
|
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||||
|
pub headers: HashMap<String, String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalProxyRequestOverrides {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.headers.is_empty() && self.body.is_none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 供应商元数据
|
/// 供应商元数据
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct ProviderMeta {
|
pub struct ProviderMeta {
|
||||||
@@ -464,6 +478,15 @@ pub struct ProviderMeta {
|
|||||||
/// Codex Responses -> Chat Completions reasoning capability metadata.
|
/// Codex Responses -> Chat Completions reasoning capability metadata.
|
||||||
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "codexChatReasoning", skip_serializing_if = "Option::is_none")]
|
||||||
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
|
pub codex_chat_reasoning: Option<CodexChatReasoningConfig>,
|
||||||
|
/// Custom User-Agent for local proxy routing.
|
||||||
|
#[serde(rename = "customUserAgent", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub custom_user_agent: Option<String>,
|
||||||
|
/// Local proxy request overrides applied to the transformed upstream request.
|
||||||
|
#[serde(
|
||||||
|
rename = "localProxyRequestOverrides",
|
||||||
|
skip_serializing_if = "Option::is_none"
|
||||||
|
)]
|
||||||
|
pub local_proxy_request_overrides: Option<LocalProxyRequestOverrides>,
|
||||||
/// 累加模式应用中,该 provider 是否已写入 live config。
|
/// 累加模式应用中,该 provider 是否已写入 live config。
|
||||||
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
|
||||||
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
|
||||||
@@ -478,6 +501,30 @@ pub struct ProviderMeta {
|
|||||||
pub github_account_id: Option<String>,
|
pub github_account_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 解析 Provider 级自定义 User-Agent 字符串(单一真理来源)。
|
||||||
|
///
|
||||||
|
/// 转发(forwarder)、流式检测(stream_check)、获取模型列表(model_fetch)三条路径
|
||||||
|
/// 共用同一口径,避免出现"某条路径用了 UA、另一条没用 / 报错"的不一致。
|
||||||
|
///
|
||||||
|
/// 合法性由 `http::HeaderValue::from_str` 按**字节**判定(`b >= 32 && b != 127 || b == '\t'`),
|
||||||
|
/// 与前端 `src/lib/userAgent.ts::isValidUserAgentHeader` 严格一致:
|
||||||
|
/// - `Ok(None)`:未设置或纯空白(trim 后为空)。
|
||||||
|
/// - `Ok(Some(hv))`:合法。制表符、可见 ASCII(0x20–0x7E)、以及任意非 ASCII 字符
|
||||||
|
/// (UTF-8 字节均 ≥ 0x80)都合法。
|
||||||
|
/// - `Err(_)`:仅含控制字符时——除 `\t` 外的 0x00–0x1F(含换行)与 0x7F(DEL)。
|
||||||
|
///
|
||||||
|
/// 非法值的处理:三条运行时路径**均静默忽略**(`.ok().flatten()`,绝不让某条路径报错而
|
||||||
|
/// 另一条放行);前端在输入框处给出非阻断提示。当前**不在保存时阻断**——deeplink 导入等
|
||||||
|
/// 非表单路径应宽容,运行时静默忽略即为安全网。
|
||||||
|
pub fn parse_custom_user_agent(
|
||||||
|
raw: Option<&str>,
|
||||||
|
) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||||
|
match raw.map(str::trim).filter(|s| !s.is_empty()) {
|
||||||
|
Some(ua) => HeaderValue::from_str(ua).map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ProviderMeta {
|
impl ProviderMeta {
|
||||||
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
|
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
|
||||||
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
|
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
|
||||||
@@ -485,6 +532,11 @@ impl ProviderMeta {
|
|||||||
self.codex_fast_mode.unwrap_or(false)
|
self.codex_fast_mode.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 经校验的 Provider 级自定义 User-Agent。见 [`parse_custom_user_agent`]。
|
||||||
|
pub fn custom_user_agent_header(&self) -> Result<Option<HeaderValue>, InvalidHeaderValue> {
|
||||||
|
parse_custom_user_agent(self.custom_user_agent.as_deref())
|
||||||
|
}
|
||||||
|
|
||||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||||
///
|
///
|
||||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||||
@@ -901,10 +953,11 @@ pub struct OpenCodeModelLimit {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, LocalProxyRequestOverrides,
|
||||||
ProviderManager, ProviderMeta, UniversalProvider,
|
OpenCodeProviderConfig, Provider, ProviderManager, ProviderMeta, UniversalProvider,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_meta_serializes_pricing_model_source() {
|
fn provider_meta_serializes_pricing_model_source() {
|
||||||
@@ -932,6 +985,33 @@ mod tests {
|
|||||||
assert!(value.get("pricingModelSource").is_none());
|
assert!(value.get("pricingModelSource").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_meta_roundtrips_local_proxy_request_overrides() {
|
||||||
|
let meta = ProviderMeta {
|
||||||
|
local_proxy_request_overrides: Some(LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([("X-Test".to_string(), "yes".to_string())]),
|
||||||
|
body: Some(json!({ "temperature": 0.2 })),
|
||||||
|
}),
|
||||||
|
..ProviderMeta::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&meta).expect("serialize ProviderMeta");
|
||||||
|
assert_eq!(
|
||||||
|
value["localProxyRequestOverrides"]["headers"]["X-Test"],
|
||||||
|
"yes"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
value["localProxyRequestOverrides"]["body"]["temperature"],
|
||||||
|
0.2
|
||||||
|
);
|
||||||
|
|
||||||
|
let decoded: ProviderMeta =
|
||||||
|
serde_json::from_value(value).expect("deserialize ProviderMeta");
|
||||||
|
let overrides = decoded.local_proxy_request_overrides.unwrap();
|
||||||
|
assert_eq!(overrides.headers.get("X-Test"), Some(&"yes".to_string()));
|
||||||
|
assert_eq!(overrides.body.unwrap()["temperature"], 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn provider_with_id_populates_defaults() {
|
fn provider_with_id_populates_defaults() {
|
||||||
let settings_config = json!({
|
let settings_config = json!({
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
//! HTTP content-encoding 工具。
|
||||||
|
//!
|
||||||
|
//! reqwest 的自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
||||||
|
//! 请求侧(如 Codex Desktop 在登录态发压缩请求体)与响应侧(上游压缩响应体)
|
||||||
|
//! 共用同一套解压逻辑。
|
||||||
|
|
||||||
|
use axum::http::header::HeaderMap;
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
/// 把 content-encoding 值拆成有序 coding 列表(去掉 identity 与空值)。
|
||||||
|
///
|
||||||
|
/// HTTP 允许堆叠编码(如 `gzip, zstd`),各 coding 以逗号分隔;亦允许重复
|
||||||
|
/// content-encoding 头,语义等同逗号拼接(见 [`get_content_encoding`])。
|
||||||
|
fn split_codings(content_encoding: &str) -> Vec<&str> {
|
||||||
|
content_encoding
|
||||||
|
.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|c| !c.is_empty() && *c != "identity")
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单个 coding 是否可被解压。
|
||||||
|
fn is_single_supported(coding: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
coding,
|
||||||
|
"gzip" | "x-gzip" | "deflate" | "br" | "zstd" | "zst"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 解压单个 content-coding。未知编码返回 `Ok(None)`。
|
||||||
|
fn decompress_single(coding: &str, body: &[u8]) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||||
|
match coding {
|
||||||
|
"gzip" | "x-gzip" => {
|
||||||
|
let mut decoder = flate2::read::GzDecoder::new(body);
|
||||||
|
let mut decompressed = Vec::new();
|
||||||
|
decoder.read_to_end(&mut decompressed)?;
|
||||||
|
Ok(Some(decompressed))
|
||||||
|
}
|
||||||
|
"deflate" => {
|
||||||
|
// RFC 9110: deflate 指 zlib 包裹格式;但部分上游 / 客户端发 raw deflate 流。
|
||||||
|
// 先按规范尝试 zlib,失败再回退 raw —— 否则合规来源必然解压失败,
|
||||||
|
// 原始压缩字节会被 fail-open 透传给 JSON 解析(#2234 形态 C 之一)。
|
||||||
|
let mut decompressed = Vec::new();
|
||||||
|
let mut zlib = flate2::read::ZlibDecoder::new(body);
|
||||||
|
match zlib.read_to_end(&mut decompressed) {
|
||||||
|
Ok(_) => Ok(Some(decompressed)),
|
||||||
|
Err(zlib_err) => {
|
||||||
|
log::debug!("deflate 按 zlib 解压失败({zlib_err}),回退 raw deflate");
|
||||||
|
let mut decompressed = Vec::new();
|
||||||
|
let mut raw = flate2::read::DeflateDecoder::new(body);
|
||||||
|
raw.read_to_end(&mut decompressed)?;
|
||||||
|
Ok(Some(decompressed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"br" => {
|
||||||
|
let mut decompressed = Vec::new();
|
||||||
|
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
||||||
|
Ok(Some(decompressed))
|
||||||
|
}
|
||||||
|
"zstd" | "zst" => {
|
||||||
|
// Codex 登录态对请求体启用 zstd(Compression::Zstd);上游也可能 zstd 压缩响应。
|
||||||
|
let decompressed = zstd::stream::decode_all(std::io::Cursor::new(body))?;
|
||||||
|
Ok(Some(decompressed))
|
||||||
|
}
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据 content-encoding 解压 body 字节,支持堆叠编码(如 `gzip, zstd`)。
|
||||||
|
///
|
||||||
|
/// RFC 9110 §8.4:codings 按**应用顺序**列出,故解压须**反向**(最后应用的先解)。
|
||||||
|
/// 返回 `Ok(None)` 表示存在不受支持的编码、原样透传——此时调用方必须保留
|
||||||
|
/// content-encoding 头,否则下游(诊断 / 客户端)会把压缩字节误当明文。
|
||||||
|
pub(crate) fn decompress_body(
|
||||||
|
content_encoding: &str,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<Option<Vec<u8>>, std::io::Error> {
|
||||||
|
let codings = split_codings(content_encoding);
|
||||||
|
if codings.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// 任一 coding 不支持就整体放弃解压、保头透传,避免半解码的脏数据。
|
||||||
|
if !codings.iter().all(|c| is_single_supported(c)) {
|
||||||
|
log::warn!("不支持的 content-encoding: {content_encoding},跳过解压");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 反向解码:列表末尾是最后应用的编码,须最先解。
|
||||||
|
let mut data: Option<Vec<u8>> = None;
|
||||||
|
for coding in codings.iter().rev() {
|
||||||
|
let input = data.as_deref().unwrap_or(body);
|
||||||
|
match decompress_single(coding, input)? {
|
||||||
|
Some(decompressed) => data = Some(decompressed),
|
||||||
|
// 上面 is_single_supported 已校验,理论不会发生;防御性兜底。
|
||||||
|
None => return Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该 content-encoding(含堆叠,如 `gzip, zstd`)是否全部可被解压。
|
||||||
|
///
|
||||||
|
/// 请求侧用它做闸门:无法解压的压缩体不能透传给 JSON 解析,需直接拒绝。
|
||||||
|
pub(crate) fn is_supported_content_encoding(content_encoding: &str) -> bool {
|
||||||
|
let codings = split_codings(content_encoding);
|
||||||
|
!codings.is_empty() && codings.iter().all(|c| is_single_supported(c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 header 提取 content-encoding(合并重复头,忽略 identity 与空值)。
|
||||||
|
///
|
||||||
|
/// HTTP 允许重复 content-encoding 头,语义等同逗号拼接,故用 `get_all` 合并;
|
||||||
|
/// 返回值可能含多个逗号分隔的 coding,交由 [`decompress_body`] 反向解码。
|
||||||
|
pub(crate) fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
||||||
|
let combined = headers
|
||||||
|
.get_all("content-encoding")
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.to_str().ok())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
.to_lowercase();
|
||||||
|
if split_codings(&combined).is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_deflate_handles_zlib_wrapped_per_rfc9110() {
|
||||||
|
// RFC 9110 规范的 deflate = zlib 包裹格式(合规来源发的就是这个)
|
||||||
|
let payload = br#"{"ok":true}"#;
|
||||||
|
let mut encoder =
|
||||||
|
flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||||
|
let compressed = encoder.finish().unwrap();
|
||||||
|
|
||||||
|
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||||
|
assert_eq!(decompressed, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_deflate_falls_back_to_raw_stream() {
|
||||||
|
// 部分来源违规发 raw deflate 流,保持兼容
|
||||||
|
let payload = br#"{"ok":true}"#;
|
||||||
|
let mut encoder =
|
||||||
|
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
std::io::Write::write_all(&mut encoder, payload).unwrap();
|
||||||
|
let compressed = encoder.finish().unwrap();
|
||||||
|
|
||||||
|
let decompressed = decompress_body("deflate", &compressed).unwrap().unwrap();
|
||||||
|
assert_eq!(decompressed, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_zstd_roundtrip() {
|
||||||
|
// Codex 登录态发的就是 zstd 压缩请求体
|
||||||
|
let payload = br#"{"hello":"world","n":42}"#;
|
||||||
|
let compressed = zstd::stream::encode_all(std::io::Cursor::new(&payload[..]), 0).unwrap();
|
||||||
|
let decompressed = decompress_body("zstd", &compressed).unwrap().unwrap();
|
||||||
|
assert_eq!(decompressed, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_stacked_gzip_then_zstd_decodes_in_reverse() {
|
||||||
|
// Content-Encoding: gzip, zstd 表示先 gzip 后 zstd,解压须反向(先 zstd 后 gzip)
|
||||||
|
let payload = br#"{"stacked":true}"#;
|
||||||
|
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||||
|
std::io::Write::write_all(&mut gz, payload).unwrap();
|
||||||
|
let gzipped = gz.finish().unwrap();
|
||||||
|
let stacked = zstd::stream::encode_all(std::io::Cursor::new(&gzipped[..]), 0).unwrap();
|
||||||
|
|
||||||
|
let decompressed = decompress_body("gzip, zstd", &stacked).unwrap().unwrap();
|
||||||
|
assert_eq!(decompressed, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_stacked_with_unsupported_returns_none() {
|
||||||
|
// 堆叠里只要有一个不支持,就整体保头透传
|
||||||
|
let result = decompress_body("snappy, zstd", b"\x00\x01\x02\x03").unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompress_body_unknown_encoding_returns_none_to_keep_headers() {
|
||||||
|
// 未知编码必须返回 None(而非伪装成"已解码"),否则 content-encoding
|
||||||
|
// 头被剥掉,下游诊断会把压缩字节误报成明文
|
||||||
|
let result = decompress_body("snappy", b"\x00\x01\x02\x03").unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_supported_content_encoding_matches_decompressable() {
|
||||||
|
for enc in [
|
||||||
|
"gzip",
|
||||||
|
"x-gzip",
|
||||||
|
"deflate",
|
||||||
|
"br",
|
||||||
|
"zstd",
|
||||||
|
"zst",
|
||||||
|
"gzip, zstd",
|
||||||
|
] {
|
||||||
|
assert!(is_supported_content_encoding(enc), "{enc} 应受支持");
|
||||||
|
}
|
||||||
|
for enc in ["identity", "snappy", "compress", "", "gzip, snappy"] {
|
||||||
|
assert!(!is_supported_content_encoding(enc), "{enc} 不应受支持");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_content_encoding_combines_repeated_headers() {
|
||||||
|
// 重复的 content-encoding 头等同逗号拼接,须用 get_all 合并
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append("content-encoding", HeaderValue::from_static("gzip"));
|
||||||
|
headers.append("content-encoding", HeaderValue::from_static("zstd"));
|
||||||
|
assert_eq!(
|
||||||
|
get_content_encoding(&headers).as_deref(),
|
||||||
|
Some("gzip, zstd")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_content_encoding_ignores_identity_only() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.append("content-encoding", HeaderValue::from_static("identity"));
|
||||||
|
assert_eq!(get_content_encoding(&headers), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
use super::hyper_client::ProxyResponse;
|
use super::hyper_client::ProxyResponse;
|
||||||
use super::{
|
use super::{
|
||||||
body_filter::filter_private_params_with_whitelist,
|
body_filter::filter_private_params_with_whitelist,
|
||||||
|
content_encoding::{decompress_body, get_content_encoding},
|
||||||
error::*,
|
error::*,
|
||||||
failover_switch::FailoverSwitchManager,
|
failover_switch::FailoverSwitchManager,
|
||||||
json_canonical::{canonicalize_value, short_value_hash},
|
json_canonical::{canonicalize_value, short_value_hash},
|
||||||
@@ -24,7 +25,10 @@ use super::{
|
|||||||
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
use crate::commands::{CodexOAuthState, CopilotAuthState};
|
||||||
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
use crate::proxy::providers::codex_oauth_auth::CodexOAuthManager;
|
||||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||||
use crate::{app_config::AppType, provider::Provider};
|
use crate::{
|
||||||
|
app_config::AppType,
|
||||||
|
provider::{LocalProxyRequestOverrides, Provider},
|
||||||
|
};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use http::Extensions;
|
use http::Extensions;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -38,6 +42,11 @@ pub struct ForwardResult {
|
|||||||
pub response: ProxyResponse,
|
pub response: ProxyResponse,
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
pub claude_api_format: Option<String>,
|
pub claude_api_format: Option<String>,
|
||||||
|
/// 实际发往上游的模型名(路由接管/模型映射后的真值)。
|
||||||
|
///
|
||||||
|
/// usage 归因不能依赖 ctx.request_model(映射前的客户端别名):上游响应
|
||||||
|
/// 缺失 model 或回显别名时,接管流量会被记成 claude-* 并按其定价计费。
|
||||||
|
pub outbound_model: Option<String>,
|
||||||
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform,
|
/// 活跃连接 RAII guard:随响应一起流转到 response_processor / handle_claude_transform,
|
||||||
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
|
/// 最终被 move 进流式 body future(或非流式响应作用域),覆盖整个响应生命周期。
|
||||||
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
|
pub(crate) connection_guard: Option<ActiveConnectionGuard>,
|
||||||
@@ -159,7 +168,7 @@ impl RequestForwarder {
|
|||||||
provider_body: &Value,
|
provider_body: &Value,
|
||||||
error: &ProxyError,
|
error: &ProxyError,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
adapter_name == "Claude"
|
matches!(adapter_name, "Claude" | "Codex")
|
||||||
&& self.rectifier_config.enabled
|
&& self.rectifier_config.enabled
|
||||||
&& self.rectifier_config.request_media_fallback
|
&& self.rectifier_config.request_media_fallback
|
||||||
&& !already_retried
|
&& !already_retried
|
||||||
@@ -463,7 +472,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((response, claude_api_format)) => {
|
Ok((response, claude_api_format, outbound_model)) => {
|
||||||
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
|
// 成功:普通闭合熔断状态异步记录,避免阻塞流式首包返回;
|
||||||
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
|
// HalfOpen 探测仍同步等待,保证 permit 与熔断状态及时释放。
|
||||||
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
|
self.record_success_result(&provider.id, app_type_str, used_half_open_permit)
|
||||||
@@ -511,6 +520,7 @@ impl RequestForwarder {
|
|||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
claude_api_format,
|
claude_api_format,
|
||||||
|
outbound_model,
|
||||||
connection_guard: None,
|
connection_guard: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -561,7 +571,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((response, claude_api_format)) => {
|
Ok((response, claude_api_format, outbound_model)) => {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
|
"[{app_type_str}] [Media] Unsupported-image retry succeeded"
|
||||||
);
|
);
|
||||||
@@ -613,6 +623,7 @@ impl RequestForwarder {
|
|||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
claude_api_format,
|
claude_api_format,
|
||||||
|
outbound_model,
|
||||||
connection_guard: None,
|
connection_guard: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -706,7 +717,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((response, claude_api_format)) => {
|
Ok((response, claude_api_format, outbound_model)) => {
|
||||||
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
log::info!("[{app_type_str}] [RECT-002] 整流重试成功");
|
||||||
self.record_success_result(
|
self.record_success_result(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -761,6 +772,7 @@ impl RequestForwarder {
|
|||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
claude_api_format,
|
claude_api_format,
|
||||||
|
outbound_model,
|
||||||
connection_guard: None,
|
connection_guard: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -871,7 +883,7 @@ impl RequestForwarder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((response, claude_api_format)) => {
|
Ok((response, claude_api_format, outbound_model)) => {
|
||||||
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
log::info!("[{app_type_str}] [RECT-011] budget 整流重试成功");
|
||||||
self.record_success_result(
|
self.record_success_result(
|
||||||
&provider.id,
|
&provider.id,
|
||||||
@@ -920,6 +932,7 @@ impl RequestForwarder {
|
|||||||
response,
|
response,
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
claude_api_format,
|
claude_api_format,
|
||||||
|
outbound_model,
|
||||||
connection_guard: None,
|
connection_guard: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1090,9 @@ impl RequestForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 转发单个请求(使用适配器)
|
/// 转发单个请求(使用适配器)
|
||||||
|
///
|
||||||
|
/// 成功时返回 `(response, claude_api_format, outbound_model)`,其中
|
||||||
|
/// `outbound_model` 是最终发往上游的模型名(所有映射/改写之后)。
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn forward(
|
async fn forward(
|
||||||
&self,
|
&self,
|
||||||
@@ -1088,7 +1104,7 @@ impl RequestForwarder {
|
|||||||
headers: &axum::http::HeaderMap,
|
headers: &axum::http::HeaderMap,
|
||||||
extensions: &Extensions,
|
extensions: &Extensions,
|
||||||
adapter: &dyn ProviderAdapter,
|
adapter: &dyn ProviderAdapter,
|
||||||
) -> Result<(ProxyResponse, Option<String>), ProxyError> {
|
) -> Result<(ProxyResponse, Option<String>, Option<String>), ProxyError> {
|
||||||
// 使用适配器提取 base_url
|
// 使用适配器提取 base_url
|
||||||
let mut base_url = adapter.extract_base_url(provider)?;
|
let mut base_url = adapter.extract_base_url(provider)?;
|
||||||
|
|
||||||
@@ -1320,8 +1336,17 @@ impl RequestForwarder {
|
|||||||
adapter.build_url(&base_url, &effective_endpoint)
|
adapter.build_url(&base_url, &effective_endpoint)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 记录映射后的出站模型名(此时 mapped_body 已完成接管映射 / [1m] 剥离 /
|
||||||
|
// Copilot 归一化)。格式转换后若 body 仍带 model 字段会在下方刷新覆盖;
|
||||||
|
// gemini_native 等模型在 URL 中的格式则保留此处的转换前真值。
|
||||||
|
let mut outbound_model = mapped_body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if codex_responses_to_chat {
|
let mut request_body = if codex_responses_to_chat {
|
||||||
let mut mapped_body = mapped_body;
|
let mut mapped_body = mapped_body;
|
||||||
let restored = self
|
let restored = self
|
||||||
.codex_chat_history
|
.codex_chat_history
|
||||||
@@ -1329,7 +1354,7 @@ impl RequestForwarder {
|
|||||||
.await;
|
.await;
|
||||||
if restored > 0 {
|
if restored > 0 {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[Codex] Restored {restored} cached function call(s) for Chat upstream"
|
"[Codex] Restored or enriched {restored} cached function call item(s) for Chat upstream"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
super::providers::apply_codex_chat_upstream_model(provider, &mut mapped_body);
|
||||||
@@ -1359,9 +1384,32 @@ impl RequestForwarder {
|
|||||||
mapped_body
|
mapped_body
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if matches!(app_type, AppType::Codex) {
|
||||||
|
self.apply_media_prevention(&mut request_body, provider);
|
||||||
|
}
|
||||||
|
|
||||||
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
// 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游
|
||||||
// 默认使用空白名单,过滤所有 _ 前缀字段
|
// 默认使用空白名单,过滤所有 _ 前缀字段
|
||||||
let filtered_body = prepare_upstream_request_body(request_body);
|
let mut filtered_body = prepare_upstream_request_body(request_body);
|
||||||
|
if !is_copilot {
|
||||||
|
if let Some(overrides) = provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.local_proxy_request_overrides.as_ref())
|
||||||
|
{
|
||||||
|
if apply_local_proxy_body_overrides(&mut filtered_body, overrides) {
|
||||||
|
filtered_body = prepare_upstream_request_body(filtered_body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 出站 body 定稿后刷新真值(覆盖 Codex chat 上游模型覆写、转换层模型改写)
|
||||||
|
if let Some(m) = filtered_body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
{
|
||||||
|
outbound_model = Some(m.to_string());
|
||||||
|
}
|
||||||
log_prompt_cache_trace(
|
log_prompt_cache_trace(
|
||||||
app_type,
|
app_type,
|
||||||
provider,
|
provider,
|
||||||
@@ -1504,6 +1552,18 @@ impl RequestForwarder {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 自定义 User-Agent:与 stream_check / model_fetch 共用 parse_custom_user_agent,
|
||||||
|
// 运行时静默忽略非法值(前端在输入处给非阻断提示,不在保存时阻断)。
|
||||||
|
// Copilot 指纹 UA 不可覆盖。
|
||||||
|
let custom_user_agent = if is_copilot {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.custom_user_agent_header().ok().flatten())
|
||||||
|
};
|
||||||
|
|
||||||
// --- Copilot 优化器:动态 header 注入 ---
|
// --- Copilot 优化器:动态 header 注入 ---
|
||||||
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
if let Some((ref classification, ref det_request_id, ref interaction_id)) =
|
||||||
copilot_optimization
|
copilot_optimization
|
||||||
@@ -1598,6 +1658,7 @@ impl RequestForwarder {
|
|||||||
let mut ordered_headers = http::HeaderMap::new();
|
let mut ordered_headers = http::HeaderMap::new();
|
||||||
let mut saw_auth = false;
|
let mut saw_auth = false;
|
||||||
let mut saw_accept_encoding = false;
|
let mut saw_accept_encoding = false;
|
||||||
|
let mut saw_user_agent = false;
|
||||||
let mut saw_anthropic_beta = false;
|
let mut saw_anthropic_beta = false;
|
||||||
let mut saw_anthropic_version = false;
|
let mut saw_anthropic_version = false;
|
||||||
|
|
||||||
@@ -1678,6 +1739,19 @@ impl RequestForwarder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- user-agent: provider-level override for local proxy routing ---
|
||||||
|
if !is_copilot && key_str.eq_ignore_ascii_case("user-agent") {
|
||||||
|
if !saw_user_agent {
|
||||||
|
saw_user_agent = true;
|
||||||
|
if let Some(ref ua) = custom_user_agent {
|
||||||
|
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||||
|
} else {
|
||||||
|
ordered_headers.append(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
|
// --- anthropic-beta — 用重建值替换(确保含 claude-code 标记) ---
|
||||||
if key_str.eq_ignore_ascii_case("anthropic-beta") {
|
if key_str.eq_ignore_ascii_case("anthropic-beta") {
|
||||||
if !saw_anthropic_beta {
|
if !saw_anthropic_beta {
|
||||||
@@ -1727,6 +1801,12 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !saw_user_agent {
|
||||||
|
if let Some(ref ua) = custom_user_agent {
|
||||||
|
ordered_headers.append(http::header::USER_AGENT, ua.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
// 如果原始请求中没有 anthropic-beta 且有值需要添加,追加
|
||||||
if !saw_anthropic_beta {
|
if !saw_anthropic_beta {
|
||||||
if let Some(ref beta_val) = anthropic_beta_value {
|
if let Some(ref beta_val) = anthropic_beta_value {
|
||||||
@@ -1768,6 +1848,15 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(
|
||||||
|
&mut ordered_headers,
|
||||||
|
provider
|
||||||
|
.meta
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|meta| meta.local_proxy_request_overrides.as_ref()),
|
||||||
|
is_copilot,
|
||||||
|
);
|
||||||
|
|
||||||
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
reject_proxy_placeholder_for_managed_account_upstream(&url, &ordered_headers)?;
|
||||||
|
|
||||||
// 输出请求信息日志
|
// 输出请求信息日志
|
||||||
@@ -1874,10 +1963,23 @@ impl RequestForwarder {
|
|||||||
let response = self
|
let response = self
|
||||||
.prepare_success_response_for_failover(response, request_is_streaming)
|
.prepare_success_response_for_failover(response, request_is_streaming)
|
||||||
.await?;
|
.await?;
|
||||||
Ok((response, resolved_claude_api_format))
|
Ok((response, resolved_claude_api_format, outbound_model))
|
||||||
} else {
|
} else {
|
||||||
let status_code = status.as_u16();
|
let status_code = status.as_u16();
|
||||||
let body_text = String::from_utf8(response.bytes().await?.to_vec()).ok();
|
// 错误响应同样可能被上游压缩(content-encoding)。reqwest 未启用任何
|
||||||
|
// 自动解压 feature,这里拿到的是原始字节;不解压的话,压缩过的错误体会
|
||||||
|
// 在 from_utf8 处变成非 UTF-8 而被丢弃,隐藏掉上游的限流/鉴权等详情。
|
||||||
|
let encoding = get_content_encoding(response.headers());
|
||||||
|
let raw = response.bytes().await?;
|
||||||
|
let decoded = match encoding {
|
||||||
|
Some(encoding) => match decompress_body(&encoding, &raw) {
|
||||||
|
Ok(Some(decompressed)) => decompressed,
|
||||||
|
// 不支持的编码 / 解压失败:退回原始字节,尽量保留可读信息
|
||||||
|
_ => raw.to_vec(),
|
||||||
|
},
|
||||||
|
None => raw.to_vec(),
|
||||||
|
};
|
||||||
|
let body_text = String::from_utf8(decoded).ok();
|
||||||
|
|
||||||
Err(ProxyError::UpstreamError {
|
Err(ProxyError::UpstreamError {
|
||||||
status: status_code,
|
status: status_code,
|
||||||
@@ -2479,6 +2581,154 @@ fn summarize_text_for_log(text: &str, max_chars: usize) -> String {
|
|||||||
format!("{truncated}...")
|
format!("{truncated}...")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apply_local_proxy_body_overrides(
|
||||||
|
body: &mut Value,
|
||||||
|
overrides: &LocalProxyRequestOverrides,
|
||||||
|
) -> bool {
|
||||||
|
let Some(override_body) = overrides.body.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !override_body.is_object() {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring body override because it is not an object");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
merge_json_override(body, override_body)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_json_override(target: &mut Value, patch: &Value) -> bool {
|
||||||
|
merge_json_override_inner(target, patch, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_json_override_inner(target: &mut Value, patch: &Value, is_top_level: bool) -> bool {
|
||||||
|
match (target, patch) {
|
||||||
|
(Value::Object(target_map), Value::Object(patch_map)) => {
|
||||||
|
let mut changed = false;
|
||||||
|
for (key, patch_value) in patch_map {
|
||||||
|
if is_top_level && key == "stream" {
|
||||||
|
log::warn!(
|
||||||
|
"[LocalProxyOverrides] Ignoring body override for protected field: stream"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match target_map.get_mut(key) {
|
||||||
|
Some(target_value) => {
|
||||||
|
changed |= merge_json_override_inner(target_value, patch_value, false);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
target_map.insert(key.clone(), patch_value.clone());
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
(target_value, patch_value) => {
|
||||||
|
if target_value == patch_value {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
*target_value = patch_value.clone();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_local_proxy_header_overrides(
|
||||||
|
headers: &mut http::HeaderMap,
|
||||||
|
overrides: Option<&LocalProxyRequestOverrides>,
|
||||||
|
is_copilot: bool,
|
||||||
|
) {
|
||||||
|
if is_copilot {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(header_overrides) = overrides.map(|overrides| &overrides.headers) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (raw_name, raw_value) in header_overrides {
|
||||||
|
let header_name = raw_name.trim().to_ascii_lowercase();
|
||||||
|
if header_name.is_empty() {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring header override with empty name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(name) = http::HeaderName::from_bytes(header_name.as_bytes()) else {
|
||||||
|
log::warn!("[LocalProxyOverrides] Ignoring invalid header override name: {raw_name}");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_protected_local_proxy_override_header(&name) {
|
||||||
|
log::debug!(
|
||||||
|
"[LocalProxyOverrides] Ignoring protected header override: {}",
|
||||||
|
name.as_str()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Ok(value) = http::HeaderValue::from_str(raw_value) else {
|
||||||
|
log::warn!(
|
||||||
|
"[LocalProxyOverrides] Ignoring invalid header override value for {}",
|
||||||
|
name.as_str()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
headers.insert(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_protected_local_proxy_override_header(name: &http::HeaderName) -> bool {
|
||||||
|
matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"host"
|
||||||
|
| "content-length"
|
||||||
|
| "transfer-encoding"
|
||||||
|
| "connection"
|
||||||
|
| "proxy-authorization"
|
||||||
|
| "proxy-authenticate"
|
||||||
|
| "te"
|
||||||
|
| "trailer"
|
||||||
|
| "upgrade"
|
||||||
|
| "accept-encoding"
|
||||||
|
| "content-type"
|
||||||
|
| "authorization"
|
||||||
|
| "x-api-key"
|
||||||
|
| "x-goog-api-key"
|
||||||
|
| "chatgpt-account-id"
|
||||||
|
| "session_id"
|
||||||
|
| "x-client-request-id"
|
||||||
|
| "x-codex-window-id"
|
||||||
|
| "x-forwarded-host"
|
||||||
|
| "x-forwarded-port"
|
||||||
|
| "x-forwarded-proto"
|
||||||
|
| "forwarded"
|
||||||
|
| "cf-connecting-ip"
|
||||||
|
| "cf-ipcountry"
|
||||||
|
| "cf-ray"
|
||||||
|
| "cf-visitor"
|
||||||
|
| "true-client-ip"
|
||||||
|
| "fastly-client-ip"
|
||||||
|
| "x-azure-clientip"
|
||||||
|
| "x-azure-fdid"
|
||||||
|
| "x-azure-ref"
|
||||||
|
| "akamai-origin-hop"
|
||||||
|
| "x-akamai-config-log-detail"
|
||||||
|
| "x-request-id"
|
||||||
|
| "x-correlation-id"
|
||||||
|
| "x-trace-id"
|
||||||
|
| "x-amzn-trace-id"
|
||||||
|
| "x-b3-traceid"
|
||||||
|
| "x-b3-spanid"
|
||||||
|
| "x-b3-parentspanid"
|
||||||
|
| "x-b3-sampled"
|
||||||
|
| "traceparent"
|
||||||
|
| "tracestate"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
fn prepare_upstream_request_body(request_body: Value) -> Value {
|
||||||
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
canonicalize_value(filter_private_params_with_whitelist(request_body, &[]))
|
||||||
}
|
}
|
||||||
@@ -2542,6 +2792,7 @@ fn value_for_log(value: &Value) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::database::Database;
|
use crate::database::Database;
|
||||||
|
use crate::provider::LocalProxyRequestOverrides;
|
||||||
use axum::http::header::{HeaderValue, ACCEPT};
|
use axum::http::header::{HeaderValue, ACCEPT};
|
||||||
use axum::http::HeaderMap;
|
use axum::http::HeaderMap;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -2741,6 +2992,116 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_body_overrides_deep_merge_final_body_without_stream() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "before",
|
||||||
|
"stream": false,
|
||||||
|
"metadata": {
|
||||||
|
"keep": true,
|
||||||
|
"temperature": 1
|
||||||
|
},
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }]
|
||||||
|
});
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::new(),
|
||||||
|
body: Some(json!({
|
||||||
|
"model": "after",
|
||||||
|
"stream": true,
|
||||||
|
"metadata": {
|
||||||
|
"temperature": 0.2,
|
||||||
|
"top_p": 0.9
|
||||||
|
},
|
||||||
|
"messages": []
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(apply_local_proxy_body_overrides(&mut body, &overrides));
|
||||||
|
|
||||||
|
assert_eq!(body["model"], "after");
|
||||||
|
assert_eq!(body["stream"], false);
|
||||||
|
assert_eq!(body["metadata"]["keep"], true);
|
||||||
|
assert_eq!(body["metadata"]["temperature"], 0.2);
|
||||||
|
assert_eq!(body["metadata"]["top_p"], 0.9);
|
||||||
|
assert_eq!(body["messages"], json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_header_overrides_replace_allowed_headers_only() {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::USER_AGENT,
|
||||||
|
http::HeaderValue::from_static("original"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::header::AUTHORIZATION,
|
||||||
|
http::HeaderValue::from_static("Bearer good"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
http::header::CONTENT_TYPE,
|
||||||
|
http::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([
|
||||||
|
("User-Agent".to_string(), "custom".to_string()),
|
||||||
|
("X-Test".to_string(), "ok".to_string()),
|
||||||
|
("Authorization".to_string(), "Bearer bad".to_string()),
|
||||||
|
("Content-Type".to_string(), "text/plain".to_string()),
|
||||||
|
("X-Bad".to_string(), "bad\nvalue".to_string()),
|
||||||
|
]),
|
||||||
|
body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), false);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("custom")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("Bearer good")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("application/json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
headers.get("x-test").and_then(|value| value.to_str().ok()),
|
||||||
|
Some("ok")
|
||||||
|
);
|
||||||
|
assert!(headers.get("x-bad").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_proxy_header_overrides_are_skipped_for_copilot() {
|
||||||
|
let mut headers = http::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
http::header::USER_AGENT,
|
||||||
|
http::HeaderValue::from_static("copilot"),
|
||||||
|
);
|
||||||
|
let overrides = LocalProxyRequestOverrides {
|
||||||
|
headers: HashMap::from([("User-Agent".to_string(), "custom".to_string())]),
|
||||||
|
body: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
apply_local_proxy_header_overrides(&mut headers, Some(&overrides), true);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
headers
|
||||||
|
.get(http::header::USER_AGENT)
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("copilot")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
async fn non_streaming_success_is_buffered_before_marking_provider_successful() {
|
||||||
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
let forwarder = test_forwarder(Duration::from_secs(1), Duration::from_secs(1));
|
||||||
@@ -3298,6 +3659,18 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn body_with_codex_input_image(model: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"model": model,
|
||||||
|
"input": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn image_unsupported_error() -> ProxyError {
|
fn image_unsupported_error() -> ProxyError {
|
||||||
ProxyError::UpstreamError {
|
ProxyError::UpstreamError {
|
||||||
status: 400,
|
status: 400,
|
||||||
@@ -3385,6 +3758,21 @@ mod tests {
|
|||||||
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
assert!(fwd.media_retry_should_trigger("Claude", false, &body, &image_unsupported_error()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reactive_triggers_for_codex_image_url_deserialize_errors() {
|
||||||
|
let fwd = forwarder_with_rectifier(RectifierConfig::default());
|
||||||
|
let body = body_with_codex_input_image("deepseek-v4-flash");
|
||||||
|
let error = ProxyError::UpstreamError {
|
||||||
|
status: 400,
|
||||||
|
body: Some(
|
||||||
|
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(fwd.media_retry_should_trigger("Codex", false, &body, &error));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reactive_skipped_when_media_fallback_off() {
|
fn reactive_skipped_when_media_fallback_off() {
|
||||||
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
// 关闭 request_media_fallback:上游报图片错误也不触发兜底重试。
|
||||||
|
|||||||
@@ -60,37 +60,40 @@ fn gemini_stream_usage_event_filter(data: &str) -> bool {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Claude 流式响应模型提取(优先使用 usage.model)
|
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||||
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
///
|
||||||
|
/// 空字符串模型名视为缺失(转换层对无回显上游会合成 model:""),
|
||||||
|
/// 落到 fallback_model(映射后的出站模型或客户端请求模型)。
|
||||||
|
fn claude_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||||
// 首先尝试从解析的 usage 中获取模型
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||||
if let Some(model) = usage.model {
|
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
request_model.to_string()
|
fallback_model.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
fn openai_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||||
// 首先尝试从解析的 usage 中获取模型
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||||
if let Some(model) = usage.model {
|
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 回退:从事件中直接提取
|
// 回退:从事件中直接提取
|
||||||
events
|
events
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| e.get("model")?.as_str())
|
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||||
.unwrap_or(request_model)
|
.unwrap_or(fallback_model)
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex 智能流式响应模型提取(自动检测格式)
|
/// Codex 智能流式响应模型提取(自动检测格式)
|
||||||
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
fn codex_auto_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||||
// 首先尝试从解析的 usage 中获取模型
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
|
||||||
if let Some(model) = usage.model {
|
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,28 +102,33 @@ fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| {
|
.find_map(|e| {
|
||||||
if e.get("type")?.as_str()? == "response.completed" {
|
if e.get("type")?.as_str()? == "response.completed" {
|
||||||
e.get("response")?.get("model")?.as_str()
|
e.get("response")?
|
||||||
|
.get("model")?
|
||||||
|
.as_str()
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// 再回退:从 OpenAI 格式事件中提取
|
// 再回退:从 OpenAI 格式事件中提取
|
||||||
events.iter().find_map(|e| e.get("model")?.as_str())
|
events
|
||||||
|
.iter()
|
||||||
|
.find_map(|e| e.get("model")?.as_str().filter(|m| !m.is_empty()))
|
||||||
})
|
})
|
||||||
.unwrap_or(request_model)
|
.unwrap_or(fallback_model)
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
/// Gemini 流式响应模型提取(优先使用 usage.model)
|
||||||
fn gemini_model_extractor(events: &[Value], request_model: &str) -> String {
|
fn gemini_model_extractor(events: &[Value], fallback_model: &str) -> String {
|
||||||
// 首先尝试从解析的 usage 中获取模型
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
if let Some(usage) = TokenUsage::from_gemini_stream_chunks(events) {
|
||||||
if let Some(model) = usage.model {
|
if let Some(model) = usage.model.filter(|m| !m.is_empty()) {
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
request_model.to_string()
|
fallback_model.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ pub struct RequestContext {
|
|||||||
pub current_provider_id: String,
|
pub current_provider_id: String,
|
||||||
/// 请求中的模型名称
|
/// 请求中的模型名称
|
||||||
pub request_model: String,
|
pub request_model: String,
|
||||||
|
/// 实际发往上游的模型名(路由接管/模型映射后的真值,forward 成功后回填)。
|
||||||
|
///
|
||||||
|
/// usage 归因的兜底顺序:上游响应回显 → outbound_model → request_model。
|
||||||
|
/// 不能直接用 request_model 兜底:接管场景下它是映射前的客户端别名。
|
||||||
|
pub outbound_model: Option<String>,
|
||||||
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
/// 日志标签(如 "Claude"、"Codex"、"Gemini")
|
||||||
pub tag: &'static str,
|
pub tag: &'static str,
|
||||||
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
/// 应用类型字符串(如 "claude"、"codex"、"gemini")
|
||||||
@@ -159,6 +164,7 @@ impl RequestContext {
|
|||||||
providers,
|
providers,
|
||||||
current_provider_id,
|
current_provider_id,
|
||||||
request_model,
|
request_model,
|
||||||
|
outbound_model: None,
|
||||||
tag,
|
tag,
|
||||||
app_type_str,
|
app_type_str,
|
||||||
app_type,
|
app_type,
|
||||||
|
|||||||
@@ -223,7 +223,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
|||||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||||
.no_gzip()
|
.no_gzip()
|
||||||
.no_brotli()
|
.no_brotli()
|
||||||
.no_deflate();
|
.no_deflate()
|
||||||
|
.no_zstd();
|
||||||
|
|
||||||
// 有代理地址则使用代理,否则跟随系统代理
|
// 有代理地址则使用代理,否则跟随系统代理
|
||||||
if let Some(url) = proxy_url {
|
if let Some(url) = proxy_url {
|
||||||
|
|||||||
@@ -41,14 +41,7 @@ pub fn replace_images_for_text_only_model(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn contains_image_blocks(body: &Value) -> bool {
|
pub fn contains_image_blocks(body: &Value) -> bool {
|
||||||
body.get("messages")
|
messages_have_image_blocks(body) || responses_input_has_image_blocks(body.get("input"))
|
||||||
.and_then(Value::as_array)
|
|
||||||
.is_some_and(|messages| {
|
|
||||||
messages
|
|
||||||
.iter()
|
|
||||||
.filter_map(|message| message.get("content"))
|
|
||||||
.any(content_has_image_blocks)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
pub fn replace_image_blocks_with_marker(body: &mut Value) -> usize {
|
||||||
@@ -95,6 +88,7 @@ pub fn is_unsupported_image_error(error: &ProxyError) -> bool {
|
|||||||
"text-only",
|
"text-only",
|
||||||
"invalid content type",
|
"invalid content type",
|
||||||
"invalid message content",
|
"invalid message content",
|
||||||
|
"unknown variant",
|
||||||
"unknown content type",
|
"unknown content type",
|
||||||
"unrecognized content type",
|
"unrecognized content type",
|
||||||
"cannot process",
|
"cannot process",
|
||||||
@@ -113,51 +107,124 @@ fn content_has_image_blocks(content: &Value) -> bool {
|
|||||||
};
|
};
|
||||||
|
|
||||||
blocks.iter().any(|block| {
|
blocks.iter().any(|block| {
|
||||||
block.get("type").and_then(Value::as_str) == Some("image")
|
is_image_block_type(block.get("type").and_then(Value::as_str))
|
||||||
|| block.get("content").is_some_and(content_has_image_blocks)
|
|| block.get("content").is_some_and(content_has_image_blocks)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_images_in_body(body: &mut Value) -> usize {
|
fn replace_images_in_body(body: &mut Value) -> usize {
|
||||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
let message_replacements = body
|
||||||
return 0;
|
.get_mut("messages")
|
||||||
};
|
.and_then(Value::as_array_mut)
|
||||||
|
.map(|messages| {
|
||||||
|
messages
|
||||||
|
.iter_mut()
|
||||||
|
.filter_map(|message| message.get_mut("content"))
|
||||||
|
.map(replace_images_in_content)
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
messages
|
message_replacements
|
||||||
.iter_mut()
|
+ body
|
||||||
.filter_map(|message| message.get_mut("content"))
|
.get_mut("input")
|
||||||
.map(replace_images_in_content)
|
.map(replace_images_in_responses_input)
|
||||||
.sum()
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_images_in_content(content: &mut Value) -> usize {
|
fn replace_images_in_content(content: &mut Value) -> usize {
|
||||||
|
replace_images_in_content_with_text_type(content, "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_content_with_text_type(content: &mut Value, text_type: &str) -> usize {
|
||||||
let Some(blocks) = content.as_array_mut() else {
|
let Some(blocks) = content.as_array_mut() else {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut replaced = 0usize;
|
let mut replaced = 0usize;
|
||||||
for block in blocks {
|
for block in blocks {
|
||||||
if block.get("type").and_then(Value::as_str) == Some("image") {
|
if is_image_block_type(block.get("type").and_then(Value::as_str)) {
|
||||||
let cache_control = block.get("cache_control").cloned();
|
replace_image_block_with_text_marker(block, text_type);
|
||||||
*block = json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": UNSUPPORTED_IMAGE_MARKER
|
|
||||||
});
|
|
||||||
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
|
|
||||||
object.insert("cache_control".to_string(), cache_control);
|
|
||||||
}
|
|
||||||
replaced += 1;
|
replaced += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(nested_content) = block.get_mut("content") {
|
if let Some(nested_content) = block.get_mut("content") {
|
||||||
replaced += replace_images_in_content(nested_content);
|
replaced += replace_images_in_content_with_text_type(nested_content, text_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced
|
replaced
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn messages_have_image_blocks(body: &Value) -> bool {
|
||||||
|
body.get("messages")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|messages| {
|
||||||
|
messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|message| message.get("content"))
|
||||||
|
.any(content_has_image_blocks)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn responses_input_has_image_blocks(input: Option<&Value>) -> bool {
|
||||||
|
match input {
|
||||||
|
Some(Value::Array(items)) => items.iter().any(responses_input_item_has_image_blocks),
|
||||||
|
Some(item @ Value::Object(_)) => responses_input_item_has_image_blocks(item),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn responses_input_item_has_image_blocks(item: &Value) -> bool {
|
||||||
|
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.get("content").is_some_and(content_has_image_blocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_responses_input(input: &mut Value) -> usize {
|
||||||
|
match input {
|
||||||
|
Value::Array(items) => items
|
||||||
|
.iter_mut()
|
||||||
|
.map(replace_images_in_responses_input_item)
|
||||||
|
.sum(),
|
||||||
|
Value::Object(_) => replace_images_in_responses_input_item(input),
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_images_in_responses_input_item(item: &mut Value) -> usize {
|
||||||
|
let mut replaced = 0usize;
|
||||||
|
|
||||||
|
if item.get("type").and_then(Value::as_str) == Some("input_image") {
|
||||||
|
replace_image_block_with_text_marker(item, "input_text");
|
||||||
|
replaced += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(content) = item.get_mut("content") {
|
||||||
|
replaced += replace_images_in_content_with_text_type(content, "input_text");
|
||||||
|
}
|
||||||
|
|
||||||
|
replaced
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_image_block_type(block_type: Option<&str>) -> bool {
|
||||||
|
matches!(block_type, Some("image" | "image_url" | "input_image"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_image_block_with_text_marker(block: &mut Value, text_type: &str) {
|
||||||
|
let cache_control = block.get("cache_control").cloned();
|
||||||
|
*block = json!({
|
||||||
|
"type": text_type,
|
||||||
|
"text": UNSUPPORTED_IMAGE_MARKER
|
||||||
|
});
|
||||||
|
if let (Some(cache_control), Some(object)) = (cache_control, block.as_object_mut()) {
|
||||||
|
object.insert("cache_control".to_string(), cache_control);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
|
fn explicit_model_image_support(provider: &Provider, model: &str) -> Option<bool> {
|
||||||
let settings = &provider.settings_config;
|
let settings = &provider.settings_config;
|
||||||
[
|
[
|
||||||
@@ -369,6 +436,54 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_text_only_models_replace_chat_image_url_before_send() {
|
||||||
|
let provider = provider(json!({}));
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "text", "text": "look" },
|
||||||
|
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,abc" } }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(body["messages"][0]["content"][1]["type"], "text");
|
||||||
|
assert_eq!(
|
||||||
|
body["messages"][0]["content"][1]["text"],
|
||||||
|
UNSUPPORTED_IMAGE_MARKER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_text_only_models_replace_codex_input_image_before_send() {
|
||||||
|
let provider = provider(json!({}));
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-flash",
|
||||||
|
"input": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{ "type": "input_text", "text": "look" },
|
||||||
|
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let count = replace_images_for_text_only_model(&mut body, &provider, true);
|
||||||
|
|
||||||
|
assert_eq!(count, 1);
|
||||||
|
assert_eq!(body["input"][0]["content"][1]["type"], "input_text");
|
||||||
|
assert_eq!(
|
||||||
|
body["input"][0]["content"][1]["text"],
|
||||||
|
UNSUPPORTED_IMAGE_MARKER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_text_modalities_replace_images_before_send() {
|
fn explicit_text_modalities_replace_images_before_send() {
|
||||||
let provider = provider(json!({
|
let provider = provider(json!({
|
||||||
@@ -654,6 +769,19 @@ mod tests {
|
|||||||
assert!(is_unsupported_image_error(&attachment_error));
|
assert!(is_unsupported_image_error(&attachment_error));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_chat_content_unknown_variant_image_url_errors() {
|
||||||
|
let error = ProxyError::UpstreamError {
|
||||||
|
status: 400,
|
||||||
|
body: Some(
|
||||||
|
r#"{"error":{"message":"Failed to deserialize the JSON body into the target type: messages[11]: unknown variant image_url, expected text"}}"#
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(is_unsupported_image_error(&error));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
|
fn heuristic_disabled_keeps_images_for_listed_text_only_models() {
|
||||||
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
// allow_heuristic = false:内置列表不再预测性剥图,避免误判多模态模型时静默丢图。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
pub mod body_filter;
|
pub mod body_filter;
|
||||||
pub mod cache_injector;
|
pub mod cache_injector;
|
||||||
pub mod circuit_breaker;
|
pub mod circuit_breaker;
|
||||||
|
pub(crate) mod content_encoding;
|
||||||
pub mod copilot_optimizer;
|
pub mod copilot_optimizer;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod error_mapper;
|
pub mod error_mapper;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub struct ModelMapping {
|
|||||||
pub haiku_model: Option<String>,
|
pub haiku_model: Option<String>,
|
||||||
pub sonnet_model: Option<String>,
|
pub sonnet_model: Option<String>,
|
||||||
pub opus_model: Option<String>,
|
pub opus_model: Option<String>,
|
||||||
|
pub fable_model: Option<String>,
|
||||||
pub default_model: Option<String>,
|
pub default_model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ impl ModelMapping {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
|
fable_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_FABLE_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
default_model: env
|
default_model: env
|
||||||
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -48,6 +54,7 @@ impl ModelMapping {
|
|||||||
self.haiku_model.is_some()
|
self.haiku_model.is_some()
|
||||||
|| self.sonnet_model.is_some()
|
|| self.sonnet_model.is_some()
|
||||||
|| self.opus_model.is_some()
|
|| self.opus_model.is_some()
|
||||||
|
|| self.fable_model.is_some()
|
||||||
|| self.default_model.is_some()
|
|| self.default_model.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +63,16 @@ impl ModelMapping {
|
|||||||
let model_lower = original_model.to_lowercase();
|
let model_lower = original_model.to_lowercase();
|
||||||
|
|
||||||
// 1. 按模型类型匹配
|
// 1. 按模型类型匹配
|
||||||
|
if model_lower.contains("fable") {
|
||||||
|
if let Some(ref m) = self.fable_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
// 未单独配置 fable 档时归入 opus 档,与 Claude Code 官方
|
||||||
|
// 分类器降级方向一致(fable→opus),避免落到 default 失去层级。
|
||||||
|
if let Some(ref m) = self.opus_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
if model_lower.contains("haiku") {
|
if model_lower.contains("haiku") {
|
||||||
if let Some(ref m) = self.haiku_model {
|
if let Some(ref m) = self.haiku_model {
|
||||||
return m.clone();
|
return m.clone();
|
||||||
@@ -154,7 +171,8 @@ mod tests {
|
|||||||
"ANTHROPIC_MODEL": "default-model",
|
"ANTHROPIC_MODEL": "default-model",
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||||
|
"ANTHROPIC_DEFAULT_FABLE_MODEL": "fable-mapped"
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
website_url: None,
|
website_url: None,
|
||||||
@@ -214,6 +232,54 @@ mod tests {
|
|||||||
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fable_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-fable-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "fable-mapped");
|
||||||
|
assert_eq!(mapped, Some("fable-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fable_with_one_m_suffix_mapping() {
|
||||||
|
// Claude Code 实际会发 claude-fable-5[1m] 形态(issue #3980)
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-fable-5[1m]"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "fable-mapped");
|
||||||
|
assert_eq!(mapped, Some("fable-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fable_falls_back_to_opus_when_unset() {
|
||||||
|
let mut provider = create_provider_with_mapping();
|
||||||
|
provider.settings_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_MODEL": "default-model",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let body = json!({"model": "claude-fable-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "opus-mapped");
|
||||||
|
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fable_falls_back_to_default_without_opus() {
|
||||||
|
let mut provider = create_provider_with_mapping();
|
||||||
|
provider.settings_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_MODEL": "default-model"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let body = json!({"model": "claude-fable-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "default-model");
|
||||||
|
assert_eq!(mapped, Some("default-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_thinking_does_not_affect_model_mapping() {
|
fn test_thinking_does_not_affect_model_mapping() {
|
||||||
// Issue #2081: thinking 参数不应影响模型映射
|
// Issue #2081: thinking 参数不应影响模型映射
|
||||||
|
|||||||
@@ -145,6 +145,77 @@ pub fn normalize_anthropic_tool_thinking_history_for_provider(
|
|||||||
normalize_anthropic_tool_thinking_history(body)
|
normalize_anthropic_tool_thinking_history(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// DeepSeek official Anthropic-compatible endpoint URL
|
||||||
|
const DEEPSEEK_OFFICIAL_ANTHROPIC_URL: &str = "https://api.deepseek.com/anthropic";
|
||||||
|
|
||||||
|
/// Check whether the provider is configured to use DeepSeek's official
|
||||||
|
/// Anthropic-compatible endpoint.
|
||||||
|
fn is_deepseek_official_anthropic_endpoint(provider: &Provider) -> bool {
|
||||||
|
let settings = &provider.settings_config;
|
||||||
|
let base_url = settings
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.or_else(|| settings.get("base_url").and_then(|v| v.as_str()))
|
||||||
|
.or_else(|| settings.get("baseURL").and_then(|v| v.as_str()))
|
||||||
|
.or_else(|| settings.get("apiEndpoint").and_then(|v| v.as_str()));
|
||||||
|
|
||||||
|
base_url.map(|u| u.trim_end_matches('/')) == Some(DEEPSEEK_OFFICIAL_ANTHROPIC_URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DeepSeek's official Anthropic-compatible endpoint treats
|
||||||
|
/// `thinking: { type: "disabled" }` and effort parameters (`output_config.effort`
|
||||||
|
/// or `reasoning_effort`) as mutually exclusive, returning HTTP 400:
|
||||||
|
/// "thinking options type cannot be disabled when reasoning_effort is set".
|
||||||
|
/// This breaks Claude Code 2.1.166+ Workflow/Dynamic Workflow features.
|
||||||
|
///
|
||||||
|
/// Rather than overriding Claude Code's intentional `thinking: disabled` for
|
||||||
|
/// sub-agents, we respect that decision and remove the conflicting effort
|
||||||
|
/// parameters instead. `thinking: disabled` means "don't output thinking
|
||||||
|
/// blocks", which is the correct behavior for sub-agents that don't need
|
||||||
|
/// to display reasoning to the user.
|
||||||
|
///
|
||||||
|
/// <https://github.com/deepseek-ai/DeepSeek-V3/issues/1397>
|
||||||
|
pub fn normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
body: &mut Value,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> bool {
|
||||||
|
if !is_deepseek_official_anthropic_endpoint(provider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let thinking_type = body
|
||||||
|
.get("thinking")
|
||||||
|
.and_then(|t| t.get("type"))
|
||||||
|
.and_then(|t| t.as_str());
|
||||||
|
|
||||||
|
if thinking_type != Some("disabled") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
// Remove output_config.effort (Anthropic format)
|
||||||
|
if let Some(oc) = body
|
||||||
|
.get_mut("output_config")
|
||||||
|
.and_then(|v| v.as_object_mut())
|
||||||
|
{
|
||||||
|
changed |= oc.remove("effort").is_some();
|
||||||
|
// Clean up empty output_config
|
||||||
|
if oc.is_empty() {
|
||||||
|
body.as_object_mut().unwrap().remove("output_config");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove reasoning_effort (OpenAI format, may be present in passthrough)
|
||||||
|
if body.get("reasoning_effort").is_some() {
|
||||||
|
body.as_object_mut().unwrap().remove("reasoning_effort");
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
pub fn normalize_anthropic_messages_for_provider(
|
pub fn normalize_anthropic_messages_for_provider(
|
||||||
body: &mut Value,
|
body: &mut Value,
|
||||||
provider: &Provider,
|
provider: &Provider,
|
||||||
@@ -154,77 +225,12 @@ pub fn normalize_anthropic_messages_for_provider(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut changed = normalize_anthropic_system_role_messages(body);
|
let mut changed =
|
||||||
changed |= normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
normalize_anthropic_tool_thinking_history_for_provider(body, provider, api_format);
|
||||||
|
changed |= normalize_deepseek_thinking_disabled_strip_effort(body, provider);
|
||||||
changed
|
changed
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_anthropic_system_role_messages(body: &mut Value) -> bool {
|
|
||||||
let mut system_parts = Vec::new();
|
|
||||||
let changed = {
|
|
||||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
let original_len = messages.len();
|
|
||||||
let mut kept_messages = Vec::with_capacity(messages.len());
|
|
||||||
for message in std::mem::take(messages) {
|
|
||||||
if message.get("role").and_then(Value::as_str) == Some("system") {
|
|
||||||
if let Some(content) = message.get("content") {
|
|
||||||
append_anthropic_system_parts(content, &mut system_parts);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
kept_messages.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let changed = kept_messages.len() != original_len;
|
|
||||||
*messages = kept_messages;
|
|
||||||
changed
|
|
||||||
};
|
|
||||||
|
|
||||||
if !changed || system_parts.is_empty() {
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut merged_parts = Vec::new();
|
|
||||||
if let Some(existing) = body.get("system") {
|
|
||||||
append_anthropic_system_parts(existing, &mut merged_parts);
|
|
||||||
}
|
|
||||||
merged_parts.extend(system_parts);
|
|
||||||
|
|
||||||
if !merged_parts.is_empty() {
|
|
||||||
body["system"] = Value::Array(merged_parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_anthropic_system_parts(content: &Value, parts: &mut Vec<Value>) {
|
|
||||||
match content {
|
|
||||||
Value::String(text) if !text.trim().is_empty() => {
|
|
||||||
parts.push(json!({
|
|
||||||
"type": "text",
|
|
||||||
"text": text
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Value::Array(items) => {
|
|
||||||
for item in items {
|
|
||||||
append_anthropic_system_parts(item, parts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Value::Object(obj)
|
|
||||||
if obj
|
|
||||||
.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.is_some_and(|text| !text.trim().is_empty()) =>
|
|
||||||
{
|
|
||||||
parts.push(Value::Object(obj.clone()));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
fn normalize_anthropic_tool_thinking_history(body: &mut Value) -> bool {
|
||||||
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -409,6 +415,10 @@ pub fn transform_claude_request_for_api_format(
|
|||||||
{
|
{
|
||||||
result["prompt_cache_key"] = serde_json::json!(key);
|
result["prompt_cache_key"] = serde_json::json!(key);
|
||||||
}
|
}
|
||||||
|
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游
|
||||||
|
// 不在 SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 →
|
||||||
|
// 整笔 input/output/cache 漏记(与 Codex Responses→Chat 路径同源)。
|
||||||
|
super::transform::inject_openai_stream_include_usage(&mut result);
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||||
@@ -1617,6 +1627,43 @@ mod tests {
|
|||||||
assert!(transformed.get("max_output_tokens").is_some());
|
assert!(transformed.get("max_output_tokens").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transform_claude_request_openai_chat_streaming_injects_include_usage() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||||
|
}));
|
||||||
|
// 流式请求必须注入 stream_options.include_usage,否则 OpenAI 兼容上游不在
|
||||||
|
// SSE 末尾吐 usage → 转换出的 Anthropic message_delta 全 0 → 整笔 usage 漏记。
|
||||||
|
let body = json!({
|
||||||
|
"model": "moonshotai/kimi-k2",
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }],
|
||||||
|
"max_tokens": 128,
|
||||||
|
"stream": true
|
||||||
|
});
|
||||||
|
let transformed =
|
||||||
|
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(transformed["stream"], true);
|
||||||
|
assert_eq!(transformed["stream_options"]["include_usage"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_transform_claude_request_openai_chat_non_streaming_omits_stream_options() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1" }
|
||||||
|
}));
|
||||||
|
// 非流式请求不应注入 stream_options(usage 在非流式响应体里恒有)。
|
||||||
|
let body = json!({
|
||||||
|
"model": "moonshotai/kimi-k2",
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }],
|
||||||
|
"max_tokens": 128
|
||||||
|
});
|
||||||
|
let transformed =
|
||||||
|
transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None)
|
||||||
|
.unwrap();
|
||||||
|
assert!(transformed.get("stream_options").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
|
||||||
let provider = create_provider_with_meta(
|
let provider = create_provider_with_meta(
|
||||||
@@ -2076,7 +2123,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_anthropic_system_role_messages_move_to_top_level_system() {
|
fn test_anthropic_messages_no_longer_hoists_system_role_messages() {
|
||||||
|
// After reverting #3775, role=system messages are left in `messages[]`
|
||||||
|
// (DeepSeek's endpoint accepts them natively) and the top-level `system`
|
||||||
|
// field is untouched, preserving the request prefix.
|
||||||
let provider = create_provider(json!({
|
let provider = create_provider(json!({
|
||||||
"env": {
|
"env": {
|
||||||
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||||
@@ -2098,15 +2148,13 @@ mod tests {
|
|||||||
|
|
||||||
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
let changed = normalize_anthropic_messages_for_provider(&mut body, &provider, "anthropic");
|
||||||
|
|
||||||
assert!(changed);
|
assert!(!changed);
|
||||||
let messages = body["messages"].as_array().unwrap();
|
let messages = body["messages"].as_array().unwrap();
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 3);
|
||||||
assert_eq!(messages[0]["role"], "user");
|
assert_eq!(messages[0]["role"], "system");
|
||||||
|
assert_eq!(messages[1]["role"], "user");
|
||||||
let system = body["system"].as_array().unwrap();
|
assert_eq!(messages[2]["role"], "system");
|
||||||
assert_eq!(system[0]["text"], "Existing top-level system.");
|
assert_eq!(body["system"], "Existing top-level system.");
|
||||||
assert_eq!(system[1]["text"], "Message system one.");
|
|
||||||
assert_eq!(system[2]["text"], "Message system two.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2259,4 +2307,245 @@ mod tests {
|
|||||||
assert!(!changed);
|
assert!(!changed);
|
||||||
assert_eq!(body, original);
|
assert_eq!(body, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== normalize_deepseek_thinking_disabled_strip_effort 测试 ====================
|
||||||
|
|
||||||
|
fn deepseek_official_provider() -> Provider {
|
||||||
|
create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic",
|
||||||
|
"ANTHROPIC_API_KEY": "test-key"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_strips_output_config_effort() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(body["thinking"]["type"], "disabled");
|
||||||
|
assert!(body.get("output_config").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_strips_reasoning_effort() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(body["thinking"]["type"], "disabled");
|
||||||
|
assert!(body.get("reasoning_effort").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_strips_both_effort_fields() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(body["thinking"]["type"], "disabled");
|
||||||
|
assert!(body.get("output_config").is_none());
|
||||||
|
assert!(body.get("reasoning_effort").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_no_effort_no_change() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!changed);
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_preserves_output_config_other_fields() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max", "temperature": 0.5 },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(body["output_config"]["temperature"], 0.5);
|
||||||
|
assert!(body["output_config"].get("effort").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_non_disabled_not_modified() {
|
||||||
|
let cases = vec![
|
||||||
|
(
|
||||||
|
"enabled",
|
||||||
|
json!({ "type": "enabled", "budget_tokens": 16000 }),
|
||||||
|
),
|
||||||
|
("adaptive", json!({ "type": "adaptive" })),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (label, thinking_value) in cases {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": thinking_value,
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!changed, "should not modify thinking.type={label}");
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
// missing thinking field entirely
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
assert!(!normalize_deepseek_thinking_disabled_strip_effort(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider()
|
||||||
|
));
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_url_with_trailing_slash() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/",
|
||||||
|
"ANTHROPIC_API_KEY": "test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert!(body.get("output_config").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deepseek_official_detected_via_base_url_fallback() {
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"base_url": "https://api.deepseek.com/anthropic",
|
||||||
|
"ANTHROPIC_API_KEY": "test-key"
|
||||||
|
}));
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"reasoning_effort": "high",
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert!(body.get("reasoning_effort").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_non_deepseek_endpoint_not_modified() {
|
||||||
|
let providers = vec![
|
||||||
|
create_provider(json!({
|
||||||
|
"env": { "ANTHROPIC_BASE_URL": "https://other-api.com/anthropic", "ANTHROPIC_API_KEY": "test-key" }
|
||||||
|
})),
|
||||||
|
create_provider(json!({
|
||||||
|
"env": { "ANTHROPIC_BASE_URL": "https://api.anthropic.com", "ANTHROPIC_API_KEY": "test-key" }
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
for provider in providers {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000
|
||||||
|
});
|
||||||
|
let original = body.clone();
|
||||||
|
|
||||||
|
let changed = normalize_deepseek_thinking_disabled_strip_effort(&mut body, &provider);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!changed,
|
||||||
|
"should not modify for {}",
|
||||||
|
provider.settings_config["env"]["ANTHROPIC_BASE_URL"]
|
||||||
|
);
|
||||||
|
assert_eq!(body, original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_messages_pipeline_strips_effort_for_deepseek() {
|
||||||
|
let mut body = json!({
|
||||||
|
"model": "deepseek-v4-pro",
|
||||||
|
"thinking": { "type": "disabled" },
|
||||||
|
"output_config": { "effort": "max" },
|
||||||
|
"max_tokens": 100000,
|
||||||
|
"messages": [{ "role": "user", "content": "hello" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
let changed = normalize_anthropic_messages_for_provider(
|
||||||
|
&mut body,
|
||||||
|
&deepseek_official_provider(),
|
||||||
|
"anthropic",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(changed);
|
||||||
|
assert_eq!(body["thinking"]["type"], "disabled");
|
||||||
|
assert!(body.get("output_config").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ impl CodexChatHistoryStore {
|
|||||||
Some(item_type) if is_call_item_type(item_type) => {
|
Some(item_type) if is_call_item_type(item_type) => {
|
||||||
if let Some(call_id) = response_item_call_id(&item) {
|
if let Some(call_id) = response_item_call_id(&item) {
|
||||||
if let Some(cached) = lookup.call(&call_id) {
|
if let Some(cached) = lookup.call(&call_id) {
|
||||||
if enrich_call_item_reasoning(&mut item, cached) {
|
if enrich_call_item_from_cache(&mut item, cached) {
|
||||||
enriched += 1;
|
enriched += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,17 +466,26 @@ fn is_call_output_item_type(item_type: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn enrich_call_item_reasoning(item: &mut Value, cached: &Value) -> bool {
|
fn enrich_call_item_from_cache(item: &mut Value, cached: &Value) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for key in ["reasoning_content", "reasoning"] {
|
for key in [
|
||||||
|
"name",
|
||||||
|
"namespace",
|
||||||
|
"arguments",
|
||||||
|
"input",
|
||||||
|
"status",
|
||||||
|
"execution",
|
||||||
|
"reasoning_content",
|
||||||
|
"reasoning",
|
||||||
|
] {
|
||||||
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
|
if item.get(key).is_some_and(|value| !is_empty_value(value)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(reasoning) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
let Some(value) = cached.get(key).filter(|value| !is_empty_value(value)) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if let Some(object) = item.as_object_mut() {
|
if let Some(object) = item.as_object_mut() {
|
||||||
object.insert(key.to_string(), reasoning.clone());
|
object.insert(key.to_string(), value.clone());
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -675,6 +684,48 @@ mod tests {
|
|||||||
assert_eq!(input.len(), 2);
|
assert_eq!(input.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enriches_existing_function_call_missing_name_and_arguments() {
|
||||||
|
let history = CodexChatHistoryStore::default();
|
||||||
|
history
|
||||||
|
.record_response(&json!({
|
||||||
|
"id": "resp_1",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"name": "read_file",
|
||||||
|
"arguments": "{\"path\":\"README.md\"}",
|
||||||
|
"reasoning_content": "Need to inspect the file."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut request = json!({
|
||||||
|
"previous_response_id": "resp_1",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"type": "function_call",
|
||||||
|
"call_id": "call_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function_call_output",
|
||||||
|
"call_id": "call_1",
|
||||||
|
"output": "ok"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(history.enrich_request(&mut request).await, 1);
|
||||||
|
let input = request["input"].as_array().unwrap();
|
||||||
|
assert_eq!(input[0]["type"], "function_call");
|
||||||
|
assert_eq!(input[0]["name"], "read_file");
|
||||||
|
assert_eq!(input[0]["arguments"], "{\"path\":\"README.md\"}");
|
||||||
|
assert_eq!(input[0]["reasoning_content"], "Need to inspect the file.");
|
||||||
|
assert_eq!(input[1]["type"], "function_call_output");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restores_parallel_tool_calls_as_one_assistant_group() {
|
async fn restores_parallel_tool_calls_as_one_assistant_group() {
|
||||||
let history = CodexChatHistoryStore::default();
|
let history = CodexChatHistoryStore::default();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识
|
//! - 通过 JWT id_token 提取 chatgpt_account_id 作为账号唯一标识
|
||||||
|
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -230,7 +230,6 @@ pub struct CodexOAuthManager {
|
|||||||
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
|
/// 进行中的 Device Code 流程:device_auth_id -> {user_code, expires_at_ms}
|
||||||
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
|
/// 过期条目会在 start_device_flow 时被清理,防止放弃的登录流程导致无界增长
|
||||||
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
|
pending_device_codes: Arc<RwLock<HashMap<String, PendingDeviceCode>>>,
|
||||||
http_client: Client,
|
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +243,6 @@ impl CodexOAuthManager {
|
|||||||
access_tokens: Arc::new(RwLock::new(HashMap::new())),
|
access_tokens: Arc::new(RwLock::new(HashMap::new())),
|
||||||
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
refresh_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||||
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
|
pending_device_codes: Arc::new(RwLock::new(HashMap::new())),
|
||||||
http_client: Client::new(),
|
|
||||||
storage_path,
|
storage_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,8 +264,7 @@ impl CodexOAuthManager {
|
|||||||
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CodexOAuthError> {
|
pub async fn start_device_flow(&self) -> Result<GitHubDeviceCodeResponse, CodexOAuthError> {
|
||||||
log::info!("[CodexOAuth] 启动 Device Code 流程");
|
log::info!("[CodexOAuth] 启动 Device Code 流程");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(DEVICE_AUTH_USERCODE_URL)
|
.post(DEVICE_AUTH_USERCODE_URL)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("User-Agent", CODEX_USER_AGENT)
|
.header("User-Agent", CODEX_USER_AGENT)
|
||||||
@@ -349,8 +346,7 @@ impl CodexOAuthManager {
|
|||||||
|
|
||||||
log::debug!("[CodexOAuth] 轮询 Device Code");
|
log::debug!("[CodexOAuth] 轮询 Device Code");
|
||||||
|
|
||||||
let poll_response = self
|
let poll_response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(DEVICE_AUTH_TOKEN_URL)
|
.post(DEVICE_AUTH_TOKEN_URL)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.header("User-Agent", CODEX_USER_AGENT)
|
.header("User-Agent", CODEX_USER_AGENT)
|
||||||
@@ -431,8 +427,7 @@ impl CodexOAuthManager {
|
|||||||
code: &str,
|
code: &str,
|
||||||
code_verifier: &str,
|
code_verifier: &str,
|
||||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(OAUTH_TOKEN_URL)
|
.post(OAUTH_TOKEN_URL)
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.header("User-Agent", CODEX_USER_AGENT)
|
.header("User-Agent", CODEX_USER_AGENT)
|
||||||
@@ -465,8 +460,7 @@ impl CodexOAuthManager {
|
|||||||
&self,
|
&self,
|
||||||
refresh_token: &str,
|
refresh_token: &str,
|
||||||
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
) -> Result<OAuthTokenResponse, CodexOAuthError> {
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(OAUTH_TOKEN_URL)
|
.post(OAUTH_TOKEN_URL)
|
||||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
.header("User-Agent", CODEX_USER_AGENT)
|
.header("User-Agent", CODEX_USER_AGENT)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
//! - Provider 通过 meta.authBinding 关联账号
|
//! - Provider 通过 meta.authBinding 关联账号
|
||||||
//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式
|
//! - 自动迁移 v1 单账号格式到 v3 多账号 + 默认账号格式
|
||||||
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -424,8 +423,6 @@ pub struct CopilotAuthManager {
|
|||||||
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
api_endpoints: Arc<RwLock<HashMap<String, String>>>,
|
||||||
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
/// 每个账号的端点拉取锁,避免并发拉取重复打 GitHub API
|
||||||
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
endpoint_locks: Arc<RwLock<HashMap<String, Arc<Mutex<()>>>>>,
|
||||||
/// HTTP 客户端
|
|
||||||
http_client: Client,
|
|
||||||
/// 存储路径
|
/// 存储路径
|
||||||
storage_path: PathBuf,
|
storage_path: PathBuf,
|
||||||
/// 待迁移的旧格式 token
|
/// 待迁移的旧格式 token
|
||||||
@@ -447,7 +444,6 @@ impl CopilotAuthManager {
|
|||||||
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
copilot_models: Arc::new(RwLock::new(HashMap::new())),
|
||||||
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
api_endpoints: Arc::new(RwLock::new(HashMap::new())),
|
||||||
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
endpoint_locks: Arc::new(RwLock::new(HashMap::new())),
|
||||||
http_client: Client::new(),
|
|
||||||
storage_path,
|
storage_path,
|
||||||
pending_migration: Arc::new(RwLock::new(None)),
|
pending_migration: Arc::new(RwLock::new(None)),
|
||||||
migration_error: Arc::new(RwLock::new(None)),
|
migration_error: Arc::new(RwLock::new(None)),
|
||||||
@@ -602,8 +598,7 @@ impl CopilotAuthManager {
|
|||||||
};
|
};
|
||||||
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
|
log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(github_device_code_url(&domain))
|
.post(github_device_code_url(&domain))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("User-Agent", COPILOT_USER_AGENT)
|
.header("User-Agent", COPILOT_USER_AGENT)
|
||||||
@@ -647,8 +642,7 @@ impl CopilotAuthManager {
|
|||||||
};
|
};
|
||||||
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
|
log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.post(github_oauth_token_url(&domain))
|
.post(github_oauth_token_url(&domain))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("User-Agent", COPILOT_USER_AGENT)
|
.header("User-Agent", COPILOT_USER_AGENT)
|
||||||
@@ -831,8 +825,7 @@ impl CopilotAuthManager {
|
|||||||
|
|
||||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.get(&models_url)
|
.get(&models_url)
|
||||||
.header("Authorization", format!("Bearer {copilot_token}"))
|
.header("Authorization", format!("Bearer {copilot_token}"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
@@ -919,8 +912,7 @@ impl CopilotAuthManager {
|
|||||||
|
|
||||||
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.get(copilot_usage_url(&domain))
|
.get(copilot_usage_url(&domain))
|
||||||
.header("Authorization", format!("token {github_token}"))
|
.header("Authorization", format!("token {github_token}"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
@@ -1034,8 +1026,7 @@ impl CopilotAuthManager {
|
|||||||
|
|
||||||
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.get(copilot_usage_url(&domain))
|
.get(copilot_usage_url(&domain))
|
||||||
.header("Authorization", format!("token {github_token}"))
|
.header("Authorization", format!("token {github_token}"))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
@@ -1312,8 +1303,7 @@ impl CopilotAuthManager {
|
|||||||
github_token: &str,
|
github_token: &str,
|
||||||
domain: &str,
|
domain: &str,
|
||||||
) -> Result<GitHubUser, CopilotAuthError> {
|
) -> Result<GitHubUser, CopilotAuthError> {
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.get(github_user_url(domain))
|
.get(github_user_url(domain))
|
||||||
.header("Authorization", format!("token {github_token}"))
|
.header("Authorization", format!("token {github_token}"))
|
||||||
.header("User-Agent", COPILOT_USER_AGENT)
|
.header("User-Agent", COPILOT_USER_AGENT)
|
||||||
@@ -1345,8 +1335,7 @@ impl CopilotAuthManager {
|
|||||||
) -> Result<(), CopilotAuthError> {
|
) -> Result<(), CopilotAuthError> {
|
||||||
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
|
log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})");
|
||||||
|
|
||||||
let response = self
|
let response = crate::proxy::http_client::get()
|
||||||
.http_client
|
|
||||||
.get(copilot_token_url(domain))
|
.get(copilot_token_url(domain))
|
||||||
.header("Authorization", format!("token {github_token}"))
|
.header("Authorization", format!("token {github_token}"))
|
||||||
.header("User-Agent", COPILOT_USER_AGENT)
|
.header("User-Agent", COPILOT_USER_AGENT)
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ pub use claude::{
|
|||||||
pub use codex::CodexAdapter;
|
pub use codex::CodexAdapter;
|
||||||
pub use codex::{
|
pub use codex::{
|
||||||
apply_codex_chat_upstream_model, codex_provider_upstream_model,
|
apply_codex_chat_upstream_model, codex_provider_upstream_model,
|
||||||
codex_provider_uses_chat_completions, is_origin_only_url, resolve_codex_chat_reasoning_config,
|
resolve_codex_chat_reasoning_config, should_convert_codex_responses_to_chat,
|
||||||
should_convert_codex_responses_to_chat,
|
|
||||||
};
|
};
|
||||||
pub use gemini::GeminiAdapter;
|
pub use gemini::GeminiAdapter;
|
||||||
|
|
||||||
|
|||||||
@@ -100,15 +100,23 @@ struct ToolBlockState {
|
|||||||
const INFINITE_WHITESPACE_THRESHOLD: usize = 500;
|
const INFINITE_WHITESPACE_THRESHOLD: usize = 500;
|
||||||
|
|
||||||
fn build_anthropic_usage_json(usage: &Usage) -> Value {
|
fn build_anthropic_usage_json(usage: &Usage) -> Value {
|
||||||
|
// OpenAI prompt_tokens 含缓存,Anthropic input_tokens 不含,需减去 cache_read 与 cache_creation
|
||||||
|
// (三桶互斥,恒等 input + cache_read + cache_creation == prompt_tokens)。
|
||||||
|
let cached = extract_cache_read_tokens(usage).unwrap_or(0);
|
||||||
|
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||||
|
let input_tokens = usage
|
||||||
|
.prompt_tokens
|
||||||
|
.saturating_sub(cached)
|
||||||
|
.saturating_sub(cache_creation);
|
||||||
let mut usage_json = json!({
|
let mut usage_json = json!({
|
||||||
"input_tokens": usage.prompt_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": usage.completion_tokens
|
"output_tokens": usage.completion_tokens
|
||||||
});
|
});
|
||||||
if let Some(cached) = extract_cache_read_tokens(usage) {
|
if cached > 0 {
|
||||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||||
}
|
}
|
||||||
if let Some(created) = usage.cache_creation_input_tokens {
|
if cache_creation > 0 {
|
||||||
usage_json["cache_creation_input_tokens"] = json!(created);
|
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
|
||||||
}
|
}
|
||||||
usage_json
|
usage_json
|
||||||
}
|
}
|
||||||
@@ -223,12 +231,20 @@ pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
|||||||
"output_tokens": 0
|
"output_tokens": 0
|
||||||
});
|
});
|
||||||
if let Some(u) = &chunk.usage {
|
if let Some(u) = &chunk.usage {
|
||||||
start_usage["input_tokens"] = json!(u.prompt_tokens);
|
let cached = extract_cache_read_tokens(u).unwrap_or(0);
|
||||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
let cache_creation =
|
||||||
|
u.cache_creation_input_tokens.unwrap_or(0);
|
||||||
|
let input = u
|
||||||
|
.prompt_tokens
|
||||||
|
.saturating_sub(cached)
|
||||||
|
.saturating_sub(cache_creation);
|
||||||
|
start_usage["input_tokens"] = json!(input);
|
||||||
|
if cached > 0 {
|
||||||
start_usage["cache_read_input_tokens"] = json!(cached);
|
start_usage["cache_read_input_tokens"] = json!(cached);
|
||||||
}
|
}
|
||||||
if let Some(created) = u.cache_creation_input_tokens {
|
if cache_creation > 0 {
|
||||||
start_usage["cache_creation_input_tokens"] = json!(created);
|
start_usage["cache_creation_input_tokens"] =
|
||||||
|
json!(cache_creation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,7 +1038,7 @@ mod tests {
|
|||||||
message_delta
|
message_delta
|
||||||
.pointer("/usage/input_tokens")
|
.pointer("/usage/input_tokens")
|
||||||
.and_then(|v| v.as_u64()),
|
.and_then(|v| v.as_u64()),
|
||||||
Some(13312)
|
Some(13212)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
message_delta
|
message_delta
|
||||||
@@ -1038,6 +1054,81 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_usage_chunk_subtracts_cache_read_and_creation_from_input() {
|
||||||
|
// prompt_tokens(1000) 含 cache_read(600) 与 cache_creation(300);转 Anthropic 后
|
||||||
|
// input 应为 fresh,守恒:input(100) + cache_read(600) + cache_creation(300) == prompt(1000)。
|
||||||
|
let input = concat!(
|
||||||
|
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
|
||||||
|
"data: {\"id\":\"chatcmpl_cc\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||||
|
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":600},\"cache_creation_input_tokens\":300}}\n\n",
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = collect_anthropic_events(input).await;
|
||||||
|
let message_delta = events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event_type(event) == Some("message_delta"))
|
||||||
|
.expect("should emit message_delta with usage");
|
||||||
|
|
||||||
|
// fresh input = 1000 - 600 - 300 = 100
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(100)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(600)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(300)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt() {
|
||||||
|
// prompt(100) < cache_read(80)+cache_creation(50)=130:saturating 钳到 0,防下溢。
|
||||||
|
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||||
|
let input = concat!(
|
||||||
|
"data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"tool-1\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",\"arguments\":\"{\\\"command\\\":\\\"pwd\\\"}\"}}]}}]}\n\n",
|
||||||
|
"data: {\"id\":\"chatcmpl_uf\",\"model\":\"glm-5.1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
|
||||||
|
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":100,\"completion_tokens\":50,\"prompt_tokens_details\":{\"cached_tokens\":80},\"cache_creation_input_tokens\":50}}\n\n",
|
||||||
|
"data: [DONE]\n\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = collect_anthropic_events(input).await;
|
||||||
|
let message_delta = events
|
||||||
|
.iter()
|
||||||
|
.find(|event| event_type(event) == Some("message_delta"))
|
||||||
|
.expect("should emit message_delta with usage");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(80)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
message_delta
|
||||||
|
.pointer("/usage/cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64()),
|
||||||
|
Some(50)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
|
async fn test_message_delta_includes_zero_usage_when_stream_has_no_usage() {
|
||||||
let input = concat!(
|
let input = concat!(
|
||||||
|
|||||||
@@ -429,8 +429,10 @@ impl ChatToResponsesState {
|
|||||||
if let Some(id) = id_delta {
|
if let Some(id) = id_delta {
|
||||||
state.call_id = id;
|
state.call_id = id;
|
||||||
}
|
}
|
||||||
if let Some(name) = name_delta {
|
if let Some(ref name) = name_delta {
|
||||||
state.name = name;
|
if !name.is_empty() {
|
||||||
|
state.name.clone_from(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !args_delta.is_empty() {
|
if !args_delta.is_empty() {
|
||||||
state.arguments.push_str(&args_delta);
|
state.arguments.push_str(&args_delta);
|
||||||
@@ -442,7 +444,7 @@ impl ChatToResponsesState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.added && (!state.call_id.is_empty() || !state.name.is_empty()) {
|
if !state.added && !state.call_id.is_empty() && !state.name.is_empty() {
|
||||||
should_add = true;
|
should_add = true;
|
||||||
pending_arguments = state.arguments.clone();
|
pending_arguments = state.arguments.clone();
|
||||||
} else if state.added {
|
} else if state.added {
|
||||||
@@ -464,9 +466,6 @@ impl ChatToResponsesState {
|
|||||||
if state.call_id.is_empty() {
|
if state.call_id.is_empty() {
|
||||||
state.call_id = format!("call_{chat_index}");
|
state.call_id = format!("call_{chat_index}");
|
||||||
}
|
}
|
||||||
if state.name.is_empty() {
|
|
||||||
state.name = "unknown_tool".to_string();
|
|
||||||
}
|
|
||||||
state.output_index = Some(assigned);
|
state.output_index = Some(assigned);
|
||||||
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
|
let is_custom_tool = self.tool_context.is_custom_tool_chat_name(&state.name);
|
||||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||||
@@ -699,6 +698,21 @@ impl ChatToResponsesState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip tool calls with missing names (defensive: some models generate
|
||||||
|
// tool call deltas without providing a valid function name)
|
||||||
|
let has_bad_name = self
|
||||||
|
.tools
|
||||||
|
.get(&key)
|
||||||
|
.map(|state| state.name.is_empty())
|
||||||
|
.unwrap_or(true);
|
||||||
|
if has_bad_name {
|
||||||
|
if let Some(state) = self.tools.get_mut(&key) {
|
||||||
|
state.done = true;
|
||||||
|
}
|
||||||
|
log::warn!("[Codex] Skipping streaming tool call with missing name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if self
|
if self
|
||||||
.tools
|
.tools
|
||||||
.get(&key)
|
.get(&key)
|
||||||
@@ -713,9 +727,6 @@ impl ChatToResponsesState {
|
|||||||
if state.call_id.is_empty() {
|
if state.call_id.is_empty() {
|
||||||
state.call_id = format!("call_{key}");
|
state.call_id = format!("call_{key}");
|
||||||
}
|
}
|
||||||
if state.name.is_empty() {
|
|
||||||
state.name = "unknown_tool".to_string();
|
|
||||||
}
|
|
||||||
state.output_index = Some(assigned);
|
state.output_index = Some(assigned);
|
||||||
state.item_id = response_tool_call_item_id_from_chat_name(
|
state.item_id = response_tool_call_item_id_from_chat_name(
|
||||||
&state.call_id,
|
&state.call_id,
|
||||||
|
|||||||
@@ -112,6 +112,10 @@ pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
/// Anthropic 请求 → OpenAI Chat Completions 请求
|
||||||
|
///
|
||||||
|
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
|
||||||
|
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||||
anthropic_to_openai_with_reasoning_content(body, false)
|
anthropic_to_openai_with_reasoning_content(body, false)
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,33 @@ pub fn anthropic_to_openai_with_reasoning_content(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 为 OpenAI Chat Completions 流式请求注入 `stream_options.include_usage`。
|
||||||
|
///
|
||||||
|
/// OpenAI 兼容上游在流式下默认不在 SSE 里返回 usage,必须显式声明 include_usage
|
||||||
|
/// 才会在末尾吐 usage chunk。缺这一注入会导致流式请求的 token/成本/缓存全部漏记
|
||||||
|
/// (input/output/cache 全为 0)。保留客户端可能透传的其它 stream_options 字段,
|
||||||
|
/// 仅补 include_usage;非流式请求不动。
|
||||||
|
///
|
||||||
|
/// 由 Claude→openai_chat(claude.rs)与 Codex Responses→Chat(transform_codex_chat.rs)
|
||||||
|
/// 两条转换路径共用,确保两个客户端方向行为一致。
|
||||||
|
pub(crate) fn inject_openai_stream_include_usage(result: &mut Value) {
|
||||||
|
let is_stream = result
|
||||||
|
.get("stream")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !is_stream {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result.get_mut("stream_options") {
|
||||||
|
Some(Value::Object(opts)) => {
|
||||||
|
opts.insert("include_usage".to_string(), json!(true));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
result["stream_options"] = json!({ "include_usage": true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
|
/// Translate an Anthropic `tool_choice` into the OpenAI Chat Completions form.
|
||||||
///
|
///
|
||||||
/// Anthropic forms:
|
/// Anthropic forms:
|
||||||
@@ -617,10 +648,31 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|||||||
|
|
||||||
// usage — map cache tokens from OpenAI format to Anthropic format
|
// usage — map cache tokens from OpenAI format to Anthropic format
|
||||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||||
|
// OpenAI prompt_tokens 含缓存命中,Anthropic input_tokens 不含 → 减去 cache_read 与
|
||||||
|
// cache_creation,使 input 成为 fresh input。本路径以 app_type="claude" 记账(calculator
|
||||||
|
// 不再扣减),若不减则缓存会被计入 input 与各 cache 桶两次。三桶互斥,恒等:
|
||||||
|
// input + cache_read + cache_creation == prompt_tokens(inclusive 上游)。
|
||||||
|
// 与流式 build_anthropic_usage_json (#2774) 及 transform_gemini 的 saturating_sub 对称。
|
||||||
|
// 最终 cache_read:直传字段优先于 nested;cache_creation 仅来自直传字段(OpenAI 无此概念)。
|
||||||
|
let cached = usage
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.or_else(|| {
|
||||||
|
usage
|
||||||
|
.pointer("/prompt_tokens_details/cached_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cache_creation = usage
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
let input_tokens = usage
|
let input_tokens = usage
|
||||||
.get("prompt_tokens")
|
.get("prompt_tokens")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0)
|
||||||
|
.saturating_sub(cached)
|
||||||
|
.saturating_sub(cache_creation) as u32;
|
||||||
let output_tokens = usage
|
let output_tokens = usage
|
||||||
.get("completion_tokens")
|
.get("completion_tokens")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
@@ -631,19 +683,11 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
|||||||
"output_tokens": output_tokens
|
"output_tokens": output_tokens
|
||||||
});
|
});
|
||||||
|
|
||||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
if cached > 0 {
|
||||||
if let Some(cached) = usage
|
|
||||||
.pointer("/prompt_tokens_details/cached_tokens")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
{
|
|
||||||
usage_json["cache_read_input_tokens"] = json!(cached);
|
usage_json["cache_read_input_tokens"] = json!(cached);
|
||||||
}
|
}
|
||||||
// Some compatible servers return these fields directly
|
if cache_creation > 0 {
|
||||||
if let Some(v) = usage.get("cache_read_input_tokens") {
|
usage_json["cache_creation_input_tokens"] = json!(cache_creation);
|
||||||
usage_json["cache_read_input_tokens"] = v.clone();
|
|
||||||
}
|
|
||||||
if let Some(v) = usage.get("cache_creation_input_tokens") {
|
|
||||||
usage_json["cache_creation_input_tokens"] = v.clone();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = json!({
|
let result = json!({
|
||||||
@@ -1287,7 +1331,8 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let result = openai_to_anthropic(input).unwrap();
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
// prompt_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||||
}
|
}
|
||||||
@@ -1311,10 +1356,38 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let result = openai_to_anthropic(input).unwrap();
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
// cache_read(60)+cache_creation(20) 均从 prompt(100) 扣除,fresh = 100 - 60 - 20 = 20
|
||||||
|
// 守恒:input(20) + cache_read(60) + cache_creation(20) == prompt(100)
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt() {
|
||||||
|
// prompt(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。
|
||||||
|
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||||
|
let input = json!({
|
||||||
|
"id": "chatcmpl-uf",
|
||||||
|
"model": "gpt-4",
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": {"role": "assistant", "content": "x"},
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 100,
|
||||||
|
"completion_tokens": 10,
|
||||||
|
"cache_read_input_tokens": 60,
|
||||||
|
"cache_creation_input_tokens": 50
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let result = openai_to_anthropic(input).unwrap();
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 0);
|
||||||
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||||
|
assert_eq!(result["usage"]["cache_creation_input_tokens"], 50);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
||||||
let input = json!({
|
let input = json!({
|
||||||
|
|||||||
@@ -336,21 +336,8 @@ pub fn responses_to_chat_completions_with_reasoning(
|
|||||||
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
|
// include_usage 才会在末尾吐 usage chunk。Codex CLI 用 Responses 协议、
|
||||||
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
|
// 自身不带 stream_options,缺这一注入会导致 kimi/MiniMax 等第三方流式请求的
|
||||||
// token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。
|
// token/成本/缓存命中率全部漏记(input/output/cache 全为 0)。
|
||||||
let is_stream = result
|
// 与 Claude→openai_chat 路径共用同一 helper,保证两个客户端方向一致。
|
||||||
.get("stream")
|
super::transform::inject_openai_stream_include_usage(&mut result);
|
||||||
.and_then(|v| v.as_bool())
|
|
||||||
.unwrap_or(false);
|
|
||||||
if is_stream {
|
|
||||||
match result.get_mut("stream_options") {
|
|
||||||
// 保留客户端可能透传的其它 stream_options 字段,仅补 include_usage。
|
|
||||||
Some(Value::Object(opts)) => {
|
|
||||||
opts.insert("include_usage".to_string(), json!(true));
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
result["stream_options"] = json!({ "include_usage": true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -1411,6 +1398,14 @@ fn chat_tool_calls_to_response_output_items(
|
|||||||
|
|
||||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
if let Some(tool_calls) = message.get("tool_calls").and_then(|v| v.as_array()) {
|
||||||
for (index, tool_call) in tool_calls.iter().enumerate() {
|
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||||
|
// Skip tool calls with missing function names (defensive: some models
|
||||||
|
// may generate tool calls without providing a valid name)
|
||||||
|
let function = tool_call.get("function").unwrap_or(&Value::Null);
|
||||||
|
let name = function.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
if name.is_empty() {
|
||||||
|
log::warn!("[Codex] Skipping tool call with missing name");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
output.push(chat_tool_call_to_response_item(
|
output.push(chat_tool_call_to_response_item(
|
||||||
tool_call,
|
tool_call,
|
||||||
index,
|
index,
|
||||||
@@ -1419,11 +1414,11 @@ fn chat_tool_calls_to_response_output_items(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
} else if let Some(function_call) = message.get("function_call") {
|
} else if let Some(function_call) = message.get("function_call") {
|
||||||
output.push(chat_legacy_function_call_to_response_item(
|
if let Some(item) =
|
||||||
function_call,
|
chat_legacy_function_call_to_response_item(function_call, reasoning, tool_context)
|
||||||
reasoning,
|
{
|
||||||
tool_context,
|
output.push(item);
|
||||||
));
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
output
|
output
|
||||||
@@ -1461,7 +1456,7 @@ fn chat_legacy_function_call_to_response_item(
|
|||||||
function_call: &Value,
|
function_call: &Value,
|
||||||
reasoning: Option<&str>,
|
reasoning: Option<&str>,
|
||||||
tool_context: &CodexToolContext,
|
tool_context: &CodexToolContext,
|
||||||
) -> Value {
|
) -> Option<Value> {
|
||||||
let call_id = function_call
|
let call_id = function_call
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -1471,10 +1466,18 @@ fn chat_legacy_function_call_to_response_item(
|
|||||||
.get("name")
|
.get("name")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Skip legacy function calls with missing names (defensive: some models
|
||||||
|
// may generate function_call without providing a valid name)
|
||||||
|
if name.is_empty() {
|
||||||
|
log::warn!("[Codex] Skipping legacy function_call with missing name");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
|
let arguments = canonicalize_tool_arguments(function_call.get("arguments"));
|
||||||
|
|
||||||
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
|
let item_id = response_tool_call_item_id_from_chat_name(call_id, name, tool_context);
|
||||||
response_tool_call_item_from_chat_name(
|
Some(response_tool_call_item_from_chat_name(
|
||||||
&item_id,
|
&item_id,
|
||||||
"completed",
|
"completed",
|
||||||
call_id,
|
call_id,
|
||||||
@@ -1482,7 +1485,7 @@ fn chat_legacy_function_call_to_response_item(
|
|||||||
&arguments,
|
&arguments,
|
||||||
reasoning,
|
reasoning,
|
||||||
tool_context,
|
tool_context,
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn response_tool_call_item_id_from_chat_name(
|
pub(crate) fn response_tool_call_item_id_from_chat_name(
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool {
|
|||||||
id.starts_with(SYNTHESIZED_ID_PREFIX)
|
id.starts_with(SYNTHESIZED_ID_PREFIX)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Anthropic 请求 → Gemini 原生请求。
|
||||||
|
///
|
||||||
|
/// 转换工具库 API:当前无生产调用方(连通性检查不再发真实请求,曾是其唯一 crate 内
|
||||||
|
/// 消费者),但保留其转换逻辑与下方测试套件,供代理转换路径复用 / 未来接线。
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
|
pub fn anthropic_to_gemini(body: Value) -> Result<Value, ProxyError> {
|
||||||
anthropic_to_gemini_with_shadow(body, None, None, None)
|
anthropic_to_gemini_with_shadow(body, None, None, None)
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1106,7 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let input_tokens = usage
|
let prompt_tokens = usage
|
||||||
.get("promptTokenCount")
|
.get("promptTokenCount")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(|value| value.as_u64())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
@@ -1109,18 +1114,26 @@ pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value {
|
|||||||
.get("totalTokenCount")
|
.get("totalTokenCount")
|
||||||
.and_then(|value| value.as_u64())
|
.and_then(|value| value.as_u64())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let output_tokens = total_tokens.saturating_sub(input_tokens);
|
let cached_tokens = usage
|
||||||
|
.get("cachedContentTokenCount")
|
||||||
|
.and_then(|value| value.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
// Gemini 的 promptTokenCount 含缓存命中(cachedContentTokenCount);而 Anthropic
|
||||||
|
// 语义下 input_tokens 必须是不含 cache 的 fresh input、cache_read 单列。本路径转成
|
||||||
|
// Anthropic 后以 app_type=claude 记账,calculator 对 claude 设 input_includes_cache_read
|
||||||
|
// =false 不再从 input 扣 cache,因此这里必须先扣减,否则缓存 token 会被双重计费
|
||||||
|
// (一次按完整 input 价、一次按 cache_read 价)。output 仍按 total-prompt 计算
|
||||||
|
// (prompt 是总输入,扣减只作用于 input/cache 的拆分,不影响 output)。
|
||||||
|
let input_tokens = prompt_tokens.saturating_sub(cached_tokens);
|
||||||
|
let output_tokens = total_tokens.saturating_sub(prompt_tokens);
|
||||||
|
|
||||||
let mut result = json!({
|
let mut result = json!({
|
||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens
|
"output_tokens": output_tokens
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(cached) = usage
|
if cached_tokens > 0 {
|
||||||
.get("cachedContentTokenCount")
|
result["cache_read_input_tokens"] = json!(cached_tokens);
|
||||||
.and_then(|value| value.as_u64())
|
|
||||||
{
|
|
||||||
result["cache_read_input_tokens"] = json!(cached);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
@@ -1370,7 +1383,11 @@ mod tests {
|
|||||||
assert_eq!(result["content"][0]["type"], "text");
|
assert_eq!(result["content"][0]["type"], "text");
|
||||||
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
|
assert_eq!(result["content"][0]["text"], "Hello from Gemini");
|
||||||
assert_eq!(result["stop_reason"], "end_turn");
|
assert_eq!(result["stop_reason"], "end_turn");
|
||||||
assert_eq!(result["usage"]["input_tokens"], 12);
|
// input_tokens = promptTokenCount(12) - cachedContentTokenCount(3) = 9(fresh input)。
|
||||||
|
// Gemini 的 promptTokenCount 含缓存命中,但 Anthropic 语义要求 input 不含 cache、
|
||||||
|
// cache_read 单列;二者相加(9+3)=总输入 12。扣减避免本路径以 app_type=claude
|
||||||
|
// 记账时把缓存 token 双重计费。
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 9);
|
||||||
assert_eq!(result["usage"]["output_tokens"], 8);
|
assert_eq!(result["usage"]["output_tokens"], 8);
|
||||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,23 @@ pub(crate) fn build_anthropic_usage_from_responses(usage: Option<&Value>) -> Val
|
|||||||
result["cache_creation_input_tokens"] = v.clone();
|
result["cache_creation_input_tokens"] = v.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAI/Responses 的 input(prompt_tokens/input_tokens)含缓存命中,Anthropic input_tokens 不含
|
||||||
|
// → 减去 cache_read 与 cache_creation,使其成为 fresh input。本函数在计量意义上是 claude 专属
|
||||||
|
// (Codex Responses 透传走 from_codex_response_*,不调用本函数),故可安全在此扣减。三桶互斥,
|
||||||
|
// 恒等:input + cache_read + cache_creation == 上游 input(inclusive)。与 build_anthropic_usage_json
|
||||||
|
// (#2774) 及 transform_gemini 的 saturating_sub 对称;一处同时覆盖非流式与流式(streaming_responses)。
|
||||||
|
let cached = result
|
||||||
|
.get("cache_read_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let cache_creation = result
|
||||||
|
.get("cache_creation_input_tokens")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
if cached > 0 || cache_creation > 0 {
|
||||||
|
result["input_tokens"] = json!(input.saturating_sub(cached).saturating_sub(cache_creation));
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,7 +1173,8 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let result = responses_to_anthropic(input).unwrap();
|
let result = responses_to_anthropic(input).unwrap();
|
||||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
// input_tokens(100) 含 cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||||
}
|
}
|
||||||
@@ -1180,6 +1198,9 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let result = responses_to_anthropic(input).unwrap();
|
let result = responses_to_anthropic(input).unwrap();
|
||||||
|
// cache_read(60)+cache_creation(20) 均从 input(100) 扣除,fresh = 100 - 60 - 20 = 20
|
||||||
|
// 守恒:input(20) + cache_read(60) + cache_creation(20) == 上游 input(100)
|
||||||
|
assert_eq!(result["usage"]["input_tokens"], 20);
|
||||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||||
}
|
}
|
||||||
@@ -1642,7 +1663,8 @@ mod tests {
|
|||||||
"cached_tokens": 80
|
"cached_tokens": 80
|
||||||
}
|
}
|
||||||
})));
|
})));
|
||||||
assert_eq!(result["input_tokens"], json!(100));
|
// input_tokens(100) 含 nested cached(80),转换后 input 应为 fresh = 100 - 80 = 20
|
||||||
|
assert_eq!(result["input_tokens"], json!(20));
|
||||||
assert_eq!(result["output_tokens"], json!(50));
|
assert_eq!(result["output_tokens"], json!(50));
|
||||||
assert_eq!(result["cache_read_input_tokens"], json!(80));
|
assert_eq!(result["cache_read_input_tokens"], json!(80));
|
||||||
}
|
}
|
||||||
@@ -1657,9 +1679,26 @@ mod tests {
|
|||||||
},
|
},
|
||||||
"cache_read_input_tokens": 100
|
"cache_read_input_tokens": 100
|
||||||
})));
|
})));
|
||||||
|
// 直传 cache_read(100) 优先于 nested(80);input(100) - 100 = 0(fresh)
|
||||||
|
assert_eq!(result["input_tokens"], json!(0));
|
||||||
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
|
assert_eq!(result["cache_read_input_tokens"], json!(100)); // Direct field overrides nested
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_usage_clamps_input_when_cache_exceeds_input() {
|
||||||
|
// input(100) < cache_read(60)+cache_creation(50)=110:saturating 钳到 0,防下溢。
|
||||||
|
// 钉桩:阻止未来把 saturating_sub 误改成普通减法(debug panic / release wrap)。
|
||||||
|
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 10,
|
||||||
|
"cache_read_input_tokens": 60,
|
||||||
|
"cache_creation_input_tokens": 50
|
||||||
|
})));
|
||||||
|
assert_eq!(result["input_tokens"], json!(0));
|
||||||
|
assert_eq!(result["cache_read_input_tokens"], json!(60));
|
||||||
|
assert_eq!(result["cache_creation_input_tokens"], json!(50));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_build_usage_cache_tokens_without_input_output() {
|
fn test_build_usage_cache_tokens_without_input_output() {
|
||||||
let result = build_anthropic_usage_from_responses(Some(&json!({
|
let result = build_anthropic_usage_from_responses(Some(&json!({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! 统一处理流式和非流式 API 响应
|
//! 统一处理流式和非流式 API 响应
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
content_encoding::{decompress_body, get_content_encoding},
|
||||||
forwarder::ActiveConnectionGuard,
|
forwarder::ActiveConnectionGuard,
|
||||||
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
handler_config::{StreamUsageEventFilter, UsageParserConfig},
|
||||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||||
@@ -19,7 +20,6 @@ use bytes::Bytes;
|
|||||||
use futures::stream::{Stream, StreamExt};
|
use futures::stream::{Stream, StreamExt};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{
|
use std::{
|
||||||
io::Read,
|
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
@@ -29,47 +29,9 @@ use std::{
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 响应解压
|
// 响应头处理
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// 根据 content-encoding 解压响应体字节
|
|
||||||
///
|
|
||||||
/// reqwest 自动解压已禁用(为了透传 accept-encoding),需要手动解压。
|
|
||||||
fn decompress_body(content_encoding: &str, body: &[u8]) -> Result<Vec<u8>, std::io::Error> {
|
|
||||||
match content_encoding {
|
|
||||||
"gzip" | "x-gzip" => {
|
|
||||||
let mut decoder = flate2::read::GzDecoder::new(body);
|
|
||||||
let mut decompressed = Vec::new();
|
|
||||||
decoder.read_to_end(&mut decompressed)?;
|
|
||||||
Ok(decompressed)
|
|
||||||
}
|
|
||||||
"deflate" => {
|
|
||||||
let mut decoder = flate2::read::DeflateDecoder::new(body);
|
|
||||||
let mut decompressed = Vec::new();
|
|
||||||
decoder.read_to_end(&mut decompressed)?;
|
|
||||||
Ok(decompressed)
|
|
||||||
}
|
|
||||||
"br" => {
|
|
||||||
let mut decompressed = Vec::new();
|
|
||||||
brotli::BrotliDecompress(&mut std::io::Cursor::new(body), &mut decompressed)?;
|
|
||||||
Ok(decompressed)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
log::warn!("未知的 content-encoding: {content_encoding},跳过解压");
|
|
||||||
Ok(body.to_vec())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从响应头提取 content-encoding(忽略 identity 和 chunked)
|
|
||||||
fn get_content_encoding(headers: &HeaderMap) -> Option<String> {
|
|
||||||
headers
|
|
||||||
.get("content-encoding")
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.trim().to_lowercase())
|
|
||||||
.filter(|s| !s.is_empty() && s != "identity")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。
|
||||||
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[
|
||||||
"connection",
|
"connection",
|
||||||
@@ -150,10 +112,13 @@ pub(crate) async fn read_decoded_body(
|
|||||||
if let Some(encoding) = get_content_encoding(&headers) {
|
if let Some(encoding) = get_content_encoding(&headers) {
|
||||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||||
match decompress_body(&encoding, &raw_bytes) {
|
match decompress_body(&encoding, &raw_bytes) {
|
||||||
Ok(decompressed) => {
|
Ok(Some(decompressed)) => {
|
||||||
body_bytes = Bytes::from(decompressed);
|
body_bytes = Bytes::from(decompressed);
|
||||||
decoded = true;
|
decoded = true;
|
||||||
}
|
}
|
||||||
|
// 不支持的编码:原样透传且保留 content-encoding 头,
|
||||||
|
// 让下游诊断/客户端知道这仍是压缩字节
|
||||||
|
Ok(None) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||||
}
|
}
|
||||||
@@ -270,14 +235,21 @@ pub async fn handle_non_streaming(
|
|||||||
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
if let Ok(json_value) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||||
// 解析使用量
|
// 解析使用量
|
||||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||||
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
// 归因优先级:usage 解析出的模型 → 响应 model 字段 → 映射后的出站
|
||||||
let model = if let Some(ref m) = usage.model {
|
// 模型(路由接管真值)→ 客户端请求模型。空字符串视为缺失。
|
||||||
m.clone()
|
let model = usage
|
||||||
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
.model
|
||||||
m.to_string()
|
.clone()
|
||||||
} else {
|
.filter(|m| !m.is_empty())
|
||||||
ctx.request_model.clone()
|
.or_else(|| {
|
||||||
};
|
json_value
|
||||||
|
.get("model")
|
||||||
|
.and_then(|m| m.as_str())
|
||||||
|
.filter(|m| !m.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.or_else(|| ctx.outbound_model.clone())
|
||||||
|
.unwrap_or_else(|| ctx.request_model.clone());
|
||||||
|
|
||||||
spawn_log_usage(
|
spawn_log_usage(
|
||||||
state,
|
state,
|
||||||
@@ -292,8 +264,10 @@ pub async fn handle_non_streaming(
|
|||||||
let model = json_value
|
let model = json_value
|
||||||
.get("model")
|
.get("model")
|
||||||
.and_then(|m| m.as_str())
|
.and_then(|m| m.as_str())
|
||||||
.unwrap_or(&ctx.request_model)
|
.filter(|m| !m.is_empty())
|
||||||
.to_string();
|
.map(str::to_string)
|
||||||
|
.or_else(|| ctx.outbound_model.clone())
|
||||||
|
.unwrap_or_else(|| ctx.request_model.clone());
|
||||||
spawn_log_usage(
|
spawn_log_usage(
|
||||||
state,
|
state,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -318,7 +292,7 @@ pub async fn handle_non_streaming(
|
|||||||
state,
|
state,
|
||||||
ctx,
|
ctx,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
&ctx.request_model,
|
ctx.outbound_model.as_deref().unwrap_or(&ctx.request_model),
|
||||||
&ctx.request_model,
|
&ctx.request_model,
|
||||||
status.as_u16(),
|
status.as_u16(),
|
||||||
false,
|
false,
|
||||||
@@ -500,7 +474,16 @@ fn create_usage_collector(
|
|||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = ctx.provider.id.clone();
|
let provider_id = ctx.provider.id.clone();
|
||||||
let request_model = ctx.request_model.clone();
|
let request_model = ctx.request_model.clone();
|
||||||
let app_type_str = parser_config.app_type_str;
|
// 流式事件缺失模型名时的归因兜底:映射后的出站模型(路由接管真值)优先,
|
||||||
|
// 其次才是客户端请求别名
|
||||||
|
let fallback_model = ctx
|
||||||
|
.outbound_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| ctx.request_model.clone());
|
||||||
|
// 用 ctx 的 app_type 而不是 parser_config 的:Claude Desktop 流式透传复用
|
||||||
|
// CLAUDE_PARSER_CONFIG(app_type_str="claude"),按 parser_config 记账会把
|
||||||
|
// claude-desktop 的行错记到 claude 名下,导致供应商计价覆盖解析不到。
|
||||||
|
let app_type_str = ctx.app_type_str;
|
||||||
let tag = ctx.tag;
|
let tag = ctx.tag;
|
||||||
let start_time = ctx.start_time;
|
let start_time = ctx.start_time;
|
||||||
let stream_parser = parser_config.stream_parser;
|
let stream_parser = parser_config.stream_parser;
|
||||||
@@ -512,13 +495,14 @@ fn create_usage_collector(
|
|||||||
parser_config.stream_event_filter,
|
parser_config.stream_event_filter,
|
||||||
move |events, first_token_ms| {
|
move |events, first_token_ms| {
|
||||||
if let Some(usage) = stream_parser(&events) {
|
if let Some(usage) = stream_parser(&events) {
|
||||||
let model = model_extractor(&events, &request_model);
|
let model = model_extractor(&events, &fallback_model);
|
||||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
let request_model = request_model.clone();
|
let request_model = request_model.clone();
|
||||||
|
let outbound_model = fallback_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -527,6 +511,7 @@ fn create_usage_collector(
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
&request_model,
|
&request_model,
|
||||||
|
&outbound_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -537,12 +522,13 @@ fn create_usage_collector(
|
|||||||
.await;
|
.await;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let model = model_extractor(&events, &request_model);
|
let model = model_extractor(&events, &fallback_model);
|
||||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
let provider_id = provider_id.clone();
|
let provider_id = provider_id.clone();
|
||||||
let session_id = session_id.clone();
|
let session_id = session_id.clone();
|
||||||
let request_model = request_model.clone();
|
let request_model = request_model.clone();
|
||||||
|
let outbound_model = fallback_model.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
log_usage_internal(
|
log_usage_internal(
|
||||||
@@ -551,6 +537,7 @@ fn create_usage_collector(
|
|||||||
app_type_str,
|
app_type_str,
|
||||||
&model,
|
&model,
|
||||||
&request_model,
|
&request_model,
|
||||||
|
&outbound_model,
|
||||||
TokenUsage::default(),
|
TokenUsage::default(),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
first_token_ms,
|
first_token_ms,
|
||||||
@@ -588,6 +575,11 @@ fn spawn_log_usage(
|
|||||||
let app_type_str = ctx.app_type_str.to_string();
|
let app_type_str = ctx.app_type_str.to_string();
|
||||||
let model = model.to_string();
|
let model = model.to_string();
|
||||||
let request_model = request_model.to_string();
|
let request_model = request_model.to_string();
|
||||||
|
// 「按请求计价」模式的锚点:映射后的出站模型,无映射时等于 request_model
|
||||||
|
let outbound_model = ctx
|
||||||
|
.outbound_model
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| ctx.request_model.clone());
|
||||||
let latency_ms = ctx.latency_ms();
|
let latency_ms = ctx.latency_ms();
|
||||||
let session_id = ctx.session_id.clone();
|
let session_id = ctx.session_id.clone();
|
||||||
|
|
||||||
@@ -598,6 +590,7 @@ fn spawn_log_usage(
|
|||||||
&app_type_str,
|
&app_type_str,
|
||||||
&model,
|
&model,
|
||||||
&request_model,
|
&request_model,
|
||||||
|
&outbound_model,
|
||||||
usage,
|
usage,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
None,
|
None,
|
||||||
@@ -618,6 +611,11 @@ pub(crate) fn usage_logging_enabled(state: &ProxyState) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 内部使用量记录函数
|
/// 内部使用量记录函数
|
||||||
|
///
|
||||||
|
/// `outbound_model` 是「按请求计价」模式的锚点:实际发往上游的模型
|
||||||
|
/// (路由接管映射后的真值,无映射时等于 request_model)。该模式的语义是
|
||||||
|
/// 「按代理发出的请求计价、不信任上游回显」,接管场景下发出的请求模型是
|
||||||
|
/// 映射后的 Y 而非客户端别名 X,按 X 计价会用错定价表行。
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn log_usage_internal(
|
async fn log_usage_internal(
|
||||||
state: &ProxyState,
|
state: &ProxyState,
|
||||||
@@ -625,6 +623,7 @@ async fn log_usage_internal(
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
request_model: &str,
|
request_model: &str,
|
||||||
|
outbound_model: &str,
|
||||||
usage: TokenUsage,
|
usage: TokenUsage,
|
||||||
latency_ms: u64,
|
latency_ms: u64,
|
||||||
first_token_ms: Option<u64>,
|
first_token_ms: Option<u64>,
|
||||||
@@ -638,7 +637,7 @@ async fn log_usage_internal(
|
|||||||
let (multiplier, pricing_model_source) =
|
let (multiplier, pricing_model_source) =
|
||||||
logger.resolve_pricing_config(provider_id, app_type).await;
|
logger.resolve_pricing_config(provider_id, app_type).await;
|
||||||
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
let pricing_model = if pricing_model_source == PRICING_SOURCE_REQUEST {
|
||||||
request_model
|
outbound_model
|
||||||
} else {
|
} else {
|
||||||
model
|
model
|
||||||
};
|
};
|
||||||
@@ -1015,6 +1014,7 @@ mod tests {
|
|||||||
app_type,
|
app_type,
|
||||||
"resp-model",
|
"resp-model",
|
||||||
"req-model",
|
"req-model",
|
||||||
|
"req-model",
|
||||||
usage,
|
usage,
|
||||||
10,
|
10,
|
||||||
None,
|
None,
|
||||||
@@ -1047,6 +1047,95 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_request_pricing_mode_anchors_to_outbound_model() -> Result<(), AppError> {
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
let app_type = "claude";
|
||||||
|
|
||||||
|
db.set_pricing_model_source(app_type, "request").await?;
|
||||||
|
seed_pricing(&db)?;
|
||||||
|
{
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO model_pricing (model_id, display_name, input_cost_per_million, output_cost_per_million)
|
||||||
|
VALUES ('outbound-model', 'Outbound Model', '4.0', '0')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
insert_provider(&db, "provider-3", app_type, ProviderMeta::default())?;
|
||||||
|
|
||||||
|
let state = build_state(db.clone());
|
||||||
|
let usage = TokenUsage {
|
||||||
|
input_tokens: 1_000_000,
|
||||||
|
output_tokens: 0,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_creation_tokens: 0,
|
||||||
|
model: None,
|
||||||
|
message_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 路由接管场景:客户端请求 req-model($2/M),代理实际发出 outbound-model
|
||||||
|
// ($4/M),上游回显 resp-model。「按请求计价」必须锚定实际发出的模型。
|
||||||
|
log_usage_internal(
|
||||||
|
&state,
|
||||||
|
"provider-3",
|
||||||
|
app_type,
|
||||||
|
"resp-model",
|
||||||
|
"req-model",
|
||||||
|
"outbound-model",
|
||||||
|
usage,
|
||||||
|
10,
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let conn = crate::database::lock_conn!(db.conn);
|
||||||
|
let (model, request_model, total_cost): (String, String, String) = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT model, request_model, total_cost_usd
|
||||||
|
FROM proxy_request_logs WHERE provider_id = ?1",
|
||||||
|
["provider-3"],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// model / request_model 列不受计价锚点影响
|
||||||
|
assert_eq!(model, "resp-model");
|
||||||
|
assert_eq!(request_model, "req-model");
|
||||||
|
// 按 outbound-model($4/M)计价,而不是 req-model($2/M)或 resp-model($1/M)
|
||||||
|
assert_eq!(
|
||||||
|
Decimal::from_str(&total_cost).unwrap(),
|
||||||
|
Decimal::from_str("4").unwrap()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_claude_desktop_inherits_claude_global_defaults() -> Result<(), AppError> {
|
||||||
|
use crate::proxy::usage::logger::UsageLogger;
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory()?);
|
||||||
|
|
||||||
|
// 全局计费配置只有 claude/codex/gemini 三行;claude-desktop 的
|
||||||
|
// 全局默认必须继承 claude,而不是静默落回工厂默认(1 / response)
|
||||||
|
db.set_default_cost_multiplier("claude", "1.5").await?;
|
||||||
|
db.set_pricing_model_source("claude", "request").await?;
|
||||||
|
|
||||||
|
let logger = UsageLogger::new(&db);
|
||||||
|
let (multiplier, source) = logger
|
||||||
|
.resolve_pricing_config("nonexistent-provider", "claude-desktop")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(multiplier, Decimal::from_str("1.5").unwrap());
|
||||||
|
assert_eq!(source, "request");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
async fn test_log_usage_falls_back_to_global_defaults() -> Result<(), AppError> {
|
||||||
let db = Arc::new(Database::memory()?);
|
let db = Arc::new(Database::memory()?);
|
||||||
@@ -1075,6 +1164,7 @@ mod tests {
|
|||||||
app_type,
|
app_type,
|
||||||
"resp-model",
|
"resp-model",
|
||||||
"req-model",
|
"req-model",
|
||||||
|
"req-model",
|
||||||
usage,
|
usage,
|
||||||
10,
|
10,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ pub struct RequestLog {
|
|||||||
pub app_type: String,
|
pub app_type: String,
|
||||||
pub model: String,
|
pub model: String,
|
||||||
pub request_model: String,
|
pub request_model: String,
|
||||||
|
/// 写入时实际用于计价的模型名(pricing_model_source 解析后的结果)。
|
||||||
|
/// 落库供回填使用:缺价行补价后必须按写入时的基准重算,而不是
|
||||||
|
/// 用 model/request_model 猜——路由接管下三者可能各不相同。
|
||||||
|
/// 错误行(未计价)为空字符串。
|
||||||
|
pub pricing_model: String,
|
||||||
pub usage: TokenUsage,
|
pub usage: TokenUsage,
|
||||||
pub cost: Option<CostBreakdown>,
|
pub cost: Option<CostBreakdown>,
|
||||||
pub latency_ms: u64,
|
pub latency_ms: u64,
|
||||||
@@ -68,18 +73,19 @@ impl<'a> UsageLogger<'a> {
|
|||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO proxy_request_logs (
|
"INSERT OR REPLACE INTO proxy_request_logs (
|
||||||
request_id, provider_id, app_type, model, request_model,
|
request_id, provider_id, app_type, model, request_model, pricing_model,
|
||||||
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
|
||||||
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd,
|
||||||
latency_ms, first_token_ms, status_code, error_message, session_id,
|
latency_ms, first_token_ms, status_code, error_message, session_id,
|
||||||
provider_type, is_streaming, cost_multiplier, created_at
|
provider_type, is_streaming, cost_multiplier, created_at
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
log.request_id,
|
log.request_id,
|
||||||
log.provider_id,
|
log.provider_id,
|
||||||
log.app_type,
|
log.app_type,
|
||||||
log.model,
|
log.model,
|
||||||
log.request_model,
|
log.request_model,
|
||||||
|
log.pricing_model,
|
||||||
log.usage.input_tokens,
|
log.usage.input_tokens,
|
||||||
log.usage.output_tokens,
|
log.usage.output_tokens,
|
||||||
log.usage.cache_read_tokens,
|
log.usage.cache_read_tokens,
|
||||||
@@ -129,6 +135,8 @@ impl<'a> UsageLogger<'a> {
|
|||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
request_model,
|
request_model,
|
||||||
|
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||||
|
pricing_model: String::new(),
|
||||||
usage: TokenUsage::default(),
|
usage: TokenUsage::default(),
|
||||||
cost: None,
|
cost: None,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -168,6 +176,8 @@ impl<'a> UsageLogger<'a> {
|
|||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
request_model,
|
request_model,
|
||||||
|
// 错误行未经过计价,留空(回填的 has_usage 闸门也不会碰全 0 行)
|
||||||
|
pricing_model: String::new(),
|
||||||
usage: TokenUsage::default(),
|
usage: TokenUsage::default(),
|
||||||
cost: None,
|
cost: None,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
@@ -203,13 +213,22 @@ impl<'a> UsageLogger<'a> {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> (Decimal, String) {
|
) -> (Decimal, String) {
|
||||||
let default_multiplier_raw = match self.db.get_default_cost_multiplier(app_type).await {
|
// Claude Desktop 网关没有独立的全局计费配置(proxy_config 的 CHECK 仅
|
||||||
Ok(value) => value,
|
// 允许 claude/codex/gemini,前端也只暴露三项),全局默认继承 claude;
|
||||||
Err(e) => {
|
// 供应商级 meta 覆盖仍按 claude-desktop 查找(providers 表按该 app_type 存)。
|
||||||
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
let default_app_type = if app_type == "claude-desktop" {
|
||||||
"1".to_string()
|
"claude"
|
||||||
}
|
} else {
|
||||||
|
app_type
|
||||||
};
|
};
|
||||||
|
let default_multiplier_raw =
|
||||||
|
match self.db.get_default_cost_multiplier(default_app_type).await {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[USG-003] 获取默认倍率失败 (app_type={app_type}): {e}");
|
||||||
|
"1".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
let default_multiplier = match Decimal::from_str(&default_multiplier_raw) {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -220,13 +239,14 @@ impl<'a> UsageLogger<'a> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let default_pricing_source_raw = match self.db.get_pricing_model_source(app_type).await {
|
let default_pricing_source_raw =
|
||||||
Ok(value) => value,
|
match self.db.get_pricing_model_source(default_app_type).await {
|
||||||
Err(e) => {
|
Ok(value) => value,
|
||||||
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
Err(e) => {
|
||||||
PRICING_SOURCE_RESPONSE.to_string()
|
log::warn!("[USG-003] 获取默认计费模式失败 (app_type={app_type}): {e}");
|
||||||
}
|
PRICING_SOURCE_RESPONSE.to_string()
|
||||||
};
|
}
|
||||||
|
};
|
||||||
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
let default_pricing_source = if default_pricing_source_raw == PRICING_SOURCE_RESPONSE
|
||||||
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
|| default_pricing_source_raw == PRICING_SOURCE_REQUEST
|
||||||
{
|
{
|
||||||
@@ -325,6 +345,7 @@ impl<'a> UsageLogger<'a> {
|
|||||||
app_type,
|
app_type,
|
||||||
model,
|
model,
|
||||||
request_model,
|
request_model,
|
||||||
|
pricing_model,
|
||||||
usage,
|
usage,
|
||||||
cost,
|
cost,
|
||||||
latency_ms,
|
latency_ms,
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ impl TokenUsage {
|
|||||||
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
.map(|mid| format!("{SESSION_REQUEST_ID_PREFIX}{mid}"))
|
||||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 是否产生了任一计费维度的 token。
|
||||||
|
///
|
||||||
|
/// 用于在写入前过滤全 0 的空 usage:当 OpenAI 兼容上游在流式下省略 usage 时,
|
||||||
|
/// 转换器会合成一个全 0 的终止事件,若无 message_id 则 `dedup_request_id`
|
||||||
|
/// 退化为随机 UUID,导致每笔请求插入一条无意义的空行、虚增请求数。
|
||||||
|
pub fn has_billable_tokens(&self) -> bool {
|
||||||
|
self.input_tokens > 0
|
||||||
|
|| self.output_tokens > 0
|
||||||
|
|| self.cache_read_tokens > 0
|
||||||
|
|| self.cache_creation_tokens > 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API 类型
|
/// API 类型
|
||||||
@@ -185,7 +197,11 @@ impl TokenUsage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
// 用 has_billable_tokens 而非仅看 input/output:完全缓存命中、无输出的流式请求
|
||||||
|
// (input==0 && output==0 但 cache_read>0)是真实的 cache-read 计费,必须保留。
|
||||||
|
// Gemini→Anthropic 路径在 input 改为 fresh(promptTokenCount - cachedContentTokenCount)
|
||||||
|
// 后尤其会出现这种全缓存场景;旧 gate 会把它当成"无 usage"丢弃。
|
||||||
|
if usage.has_billable_tokens() {
|
||||||
usage.model = model;
|
usage.model = model;
|
||||||
usage.message_id = message_id;
|
usage.message_id = message_id;
|
||||||
Some(usage)
|
Some(usage)
|
||||||
@@ -522,6 +538,71 @@ mod tests {
|
|||||||
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_billable_tokens_gates_empty_usage() {
|
||||||
|
// 全 0 usage(如上游省略 usage 时合成的全 0 终止事件)不应计费——
|
||||||
|
// 这是 Codex 流式空行多记修复(D)的闸门依据。
|
||||||
|
assert!(!TokenUsage::default().has_billable_tokens());
|
||||||
|
// 仅有 cache_read 也属于真实计费 token,必须计入。
|
||||||
|
let only_cache = TokenUsage {
|
||||||
|
cache_read_tokens: 100,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(only_cache.has_billable_tokens());
|
||||||
|
let normal = TokenUsage {
|
||||||
|
input_tokens: 10,
|
||||||
|
output_tokens: 5,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(normal.has_billable_tokens());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_stream_cache_only_request_is_recorded() {
|
||||||
|
// P2 回归:完全缓存命中、无输出的流式请求(input==0 && output==0 但 cache_read>0)
|
||||||
|
// 是真实计费,必须保留——旧 gate `input>0 || output>0` 会把它丢弃。
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"id": "msg_cacheonly",
|
||||||
|
"model": "claude-opus-4-8",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 0,
|
||||||
|
"cache_read_input_tokens": 50000,
|
||||||
|
"cache_creation_input_tokens": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"usage": { "output_tokens": 0 }
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events)
|
||||||
|
.expect("cache-only 流式请求必须被记录,不能被 input/output gate 丢弃");
|
||||||
|
assert_eq!(usage.input_tokens, 0);
|
||||||
|
assert_eq!(usage.output_tokens, 0);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 50000);
|
||||||
|
assert_eq!(usage.message_id, Some("msg_cacheonly".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codex_response_auto_returns_some_for_synthetic_all_zero() {
|
||||||
|
// P3 回归:上游非流式 Chat 省略 usage 时转换器合成的全 0 usage,from_codex_response_auto
|
||||||
|
// 仍返回 Some(字段存在、无 positivity check)——证明 handlers 必须用 has_billable_tokens
|
||||||
|
// 闸门才能挡住空行,单靠 `if let Some` 不够。
|
||||||
|
let synthetic = json!({
|
||||||
|
"usage": { "input_tokens": 0, "output_tokens": 0, "total_tokens": 0 }
|
||||||
|
});
|
||||||
|
let usage = TokenUsage::from_codex_response_auto(&synthetic)
|
||||||
|
.expect("全 0 usage 字段存在时 from_codex_response_auto 返回 Some");
|
||||||
|
assert!(
|
||||||
|
!usage.has_billable_tokens(),
|
||||||
|
"全 0 usage 必须被 has_billable_tokens 判为非计费,由 handlers 闸门跳过"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_claude_response_parsing_no_model() {
|
fn test_claude_response_parsing_no_model() {
|
||||||
let response = json!({
|
let response = json!({
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
|
|||||||
.get("https://api.deepseek.com/user/balance")
|
.get("https://api.deepseek.com/user/balance")
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(15))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
|
|||||||
.get("https://api.stepfun.com/v1/accounts")
|
.get("https://api.stepfun.com/v1/accounts")
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(15))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
|||||||
.get(&url)
|
.get(&url)
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(15))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
|
|||||||
.get("https://openrouter.ai/api/v1/credits")
|
.get("https://openrouter.ai/api/v1/credits")
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(15))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -327,7 +327,7 @@ async fn query_novita(api_key: &str) -> UsageResult {
|
|||||||
.get("https://api.novita.ai/v3/user/balance")
|
.get("https://api.novita.ai/v3/user/balance")
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(15))
|
||||||
.send()
|
.send()
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
|
//! 主要面向第三方聚合站(硅基流动、OpenRouter 等),以及把 Anthropic
|
||||||
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
|
//! 协议挂在兼容子路径上的官方供应商(DeepSeek、Kimi、智谱 GLM 等)。
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderValue, USER_AGENT};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -55,6 +56,7 @@ pub async fn fetch_models(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
is_full_url: bool,
|
is_full_url: bool,
|
||||||
models_url_override: Option<&str>,
|
models_url_override: Option<&str>,
|
||||||
|
user_agent: Option<HeaderValue>,
|
||||||
) -> Result<Vec<FetchedModel>, String> {
|
) -> Result<Vec<FetchedModel>, String> {
|
||||||
if api_key.is_empty() {
|
if api_key.is_empty() {
|
||||||
return Err("API Key is required to fetch models".to_string());
|
return Err("API Key is required to fetch models".to_string());
|
||||||
@@ -66,13 +68,16 @@ pub async fn fetch_models(
|
|||||||
|
|
||||||
for url in &candidates {
|
for url in &candidates {
|
||||||
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
log::debug!("[ModelFetch] Trying endpoint: {url}");
|
||||||
let response = match client
|
let mut request = client
|
||||||
.get(url)
|
.get(url)
|
||||||
.header("Authorization", format!("Bearer {api_key}"))
|
.header("Authorization", format!("Bearer {api_key}"))
|
||||||
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
|
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS));
|
||||||
.send()
|
// 自定义 User-Agent:部分 /models 端点同样有 UA 白名单(如 Kimi Coding Plan),
|
||||||
.await
|
// 与转发 / 检测路径共用同一 UA,避免"代理可用但取模型失败"。
|
||||||
{
|
if let Some(ua) = &user_agent {
|
||||||
|
request = request.header(USER_AGENT, ua.clone());
|
||||||
|
}
|
||||||
|
let response = match request.send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return Err(format!("Request failed: {e}"));
|
return Err(format!("Request failed: {e}"));
|
||||||
|
|||||||
@@ -596,6 +596,19 @@ fn restore_live_settings_for_provider_backfill(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 统一会话开关注入的共享 `custom` 路由只属于 live 配置;切换回填时
|
||||||
|
// 必须剥掉,否则官方供应商的存储配置被污染,关闭开关后无法还原。
|
||||||
|
if provider.category.as_deref() == Some("official") {
|
||||||
|
if let Err(err) =
|
||||||
|
crate::codex_config::strip_codex_unified_session_bucket_from_settings(&mut settings)
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Failed to strip unified session bucket while backfilling '{}': {err}",
|
||||||
|
provider.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's
|
// `modelCatalog` is a cc-switch–private field whose SSOT is the DB. Live's
|
||||||
// `config.toml` only carries a lossy projection (`model_catalog_json` →
|
// `config.toml` only carries a lossy projection (`model_catalog_json` →
|
||||||
// generated catalog file) that proxy takeover/restore cycles and Codex.app
|
// generated catalog file) that proxy takeover/restore cycles and Codex.app
|
||||||
@@ -1133,6 +1146,22 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 拒绝把"被代理接管的 Live"导入为供应商:接管期间 Live 里只有
|
||||||
|
// PROXY_MANAGED 占位符和本地代理地址,不是用户的真实配置。一旦导入,
|
||||||
|
// 它会成为 current provider(SSOT),后续"无备份恢复"路径会把占位符
|
||||||
|
// 当真实配置写回 Live,永久卡在已失效的本地代理上。
|
||||||
|
// 典型触发场景:代理接管开启时切换 app_config_dir 并重启,新数据库首启导入。
|
||||||
|
if state
|
||||||
|
.proxy_service
|
||||||
|
.detect_takeover_in_live_config_for_app(&app_type)
|
||||||
|
{
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"provider.import.live_taken_over",
|
||||||
|
"Live 配置当前处于代理接管状态(包含占位符),不能导入为供应商。请先关闭代理接管或恢复 Live 配置后重试。",
|
||||||
|
"The live config is currently taken over by the proxy (contains placeholders) and cannot be imported as a provider. Disable proxy takeover or restore the live config first.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let settings_config = match app_type {
|
let settings_config = match app_type {
|
||||||
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
|
|||||||
@@ -42,6 +42,48 @@ use live::{
|
|||||||
};
|
};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
|
/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的
|
||||||
|
/// live 配置,使开关即时生效(无需等下一次切换)。
|
||||||
|
/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,
|
||||||
|
/// 第三方 live 配置不受开关影响。
|
||||||
|
pub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {
|
||||||
|
let current_id = ProviderService::current(state, AppType::Codex)?;
|
||||||
|
if current_id.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let providers = state.db.get_all_providers(AppType::Codex.as_str())?;
|
||||||
|
let Some(provider) = providers.get(¤t_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if provider.category.as_deref() != Some("official") {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,
|
||||||
|
// 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,
|
||||||
|
// 只更新备份,注入后的配置由接管释放时的恢复路径落盘。
|
||||||
|
let has_live_backup =
|
||||||
|
futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
let live_taken_over = state
|
||||||
|
.proxy_service
|
||||||
|
.detect_takeover_in_live_config_for_app(&AppType::Codex);
|
||||||
|
if has_live_backup || live_taken_over {
|
||||||
|
futures::executor::block_on(
|
||||||
|
state
|
||||||
|
.proxy_service
|
||||||
|
.update_live_backup_from_provider(AppType::Codex.as_str(), provider),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
/// Provider business logic service
|
/// Provider business logic service
|
||||||
pub struct ProviderService;
|
pub struct ProviderService;
|
||||||
|
|
||||||
@@ -421,6 +463,7 @@ base_url = "http://localhost:8080"
|
|||||||
|
|
||||||
db.update_proxy_config(ProxyConfig {
|
db.update_proxy_config(ProxyConfig {
|
||||||
live_takeover_active: true,
|
live_takeover_active: true,
|
||||||
|
listen_port: 0,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -449,7 +492,7 @@ base_url = "http://localhost:8080"
|
|||||||
)
|
)
|
||||||
.expect("seed taken-over live file");
|
.expect("seed taken-over live file");
|
||||||
|
|
||||||
state
|
let proxy_info = state
|
||||||
.proxy_service
|
.proxy_service
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
@@ -502,7 +545,7 @@ base_url = "http://localhost:8080"
|
|||||||
live.get("env")
|
live.get("env")
|
||||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||||
.and_then(|v| v.as_str()),
|
.and_then(|v| v.as_str()),
|
||||||
Some("http://127.0.0.1:15721"),
|
Some(format!("http://127.0.0.1:{}", proxy_info.port).as_str()),
|
||||||
"proxy base URL should stay intact"
|
"proxy base URL should stay intact"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -81,32 +81,33 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract API key from provider configuration
|
/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty
|
||||||
fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
/// script values win, otherwise fall back to the provider's stored config via
|
||||||
if let Some(env) = provider.settings_config.get("env") {
|
/// `Provider::resolve_usage_credentials` — the same per-app resolver the
|
||||||
// Try multiple possible API key fields
|
/// native balance/coding-plan path and the frontend `getProviderCredentials`
|
||||||
env.get("ANTHROPIC_AUTH_TOKEN")
|
/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them.
|
||||||
.or_else(|| env.get("ANTHROPIC_API_KEY"))
|
fn resolve_script_credentials(
|
||||||
.or_else(|| env.get("OPENROUTER_API_KEY"))
|
app_type: &AppType,
|
||||||
.or_else(|| env.get("GOOGLE_API_KEY"))
|
provider: &crate::provider::Provider,
|
||||||
.and_then(|v| v.as_str())
|
api_key: Option<&str>,
|
||||||
.map(|s| s.to_string())
|
base_url: Option<&str>,
|
||||||
} else {
|
) -> (String, String) {
|
||||||
None
|
let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract base URL from provider configuration
|
let api_key = api_key
|
||||||
fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option<String> {
|
.map(str::trim)
|
||||||
if let Some(env) = provider.settings_config.get("env") {
|
.filter(|value| !value.is_empty())
|
||||||
// Try multiple possible base URL fields
|
.map(str::to_owned)
|
||||||
env.get("ANTHROPIC_BASE_URL")
|
.unwrap_or(provider_api_key);
|
||||||
.or_else(|| env.get("GOOGLE_GEMINI_BASE_URL"))
|
|
||||||
.and_then(|v| v.as_str())
|
let base_url = base_url
|
||||||
.map(|s| s.trim_end_matches('/').to_string())
|
.map(str::trim)
|
||||||
} else {
|
.filter(|value| !value.is_empty())
|
||||||
None
|
// Trim like the provider path so `{{baseUrl}}/path` never doubles the slash.
|
||||||
}
|
.map(|value| value.trim_end_matches('/').to_owned())
|
||||||
|
.unwrap_or(provider_base_url);
|
||||||
|
|
||||||
|
(api_key, base_url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Query provider usage (using saved script configuration)
|
/// Query provider usage (using saved script configuration)
|
||||||
@@ -145,19 +146,12 @@ pub async fn query_usage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get credentials: prioritize UsageScript values, fallback to provider config
|
// Get credentials: prioritize UsageScript values, fallback to provider config
|
||||||
let api_key = usage_script
|
let (api_key, base_url) = resolve_script_credentials(
|
||||||
.api_key
|
&app_type,
|
||||||
.clone()
|
provider,
|
||||||
.filter(|k| !k.is_empty())
|
usage_script.api_key.as_deref(),
|
||||||
.or_else(|| extract_api_key_from_provider(provider))
|
usage_script.base_url.as_deref(),
|
||||||
.unwrap_or_default();
|
);
|
||||||
|
|
||||||
let base_url = usage_script
|
|
||||||
.base_url
|
|
||||||
.clone()
|
|
||||||
.filter(|u| !u.is_empty())
|
|
||||||
.or_else(|| extract_base_url_from_provider(provider))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
(
|
(
|
||||||
usage_script.code.clone(),
|
usage_script.code.clone(),
|
||||||
@@ -185,9 +179,9 @@ pub async fn query_usage(
|
|||||||
/// Test usage script (using temporary script content, not saved)
|
/// Test usage script (using temporary script content, not saved)
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn test_usage_script(
|
pub async fn test_usage_script(
|
||||||
_state: &AppState,
|
state: &AppState,
|
||||||
_app_type: AppType,
|
app_type: AppType,
|
||||||
_provider_id: &str,
|
provider_id: &str,
|
||||||
script_code: &str,
|
script_code: &str,
|
||||||
timeout: u64,
|
timeout: u64,
|
||||||
api_key: Option<&str>,
|
api_key: Option<&str>,
|
||||||
@@ -196,11 +190,23 @@ pub async fn test_usage_script(
|
|||||||
user_id: Option<&str>,
|
user_id: Option<&str>,
|
||||||
template_type: Option<&str>,
|
template_type: Option<&str>,
|
||||||
) -> Result<UsageResult, AppError> {
|
) -> Result<UsageResult, AppError> {
|
||||||
// Use provided credential parameters directly for testing
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
|
let provider = providers.get(provider_id).ok_or_else(|| {
|
||||||
|
AppError::localized(
|
||||||
|
"provider.not_found",
|
||||||
|
format!("供应商不存在: {provider_id}"),
|
||||||
|
format!("Provider not found: {provider_id}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Resolve like the real query so testing matches what a saved script does:
|
||||||
|
// explicit values win, empty ones fall back to the provider config.
|
||||||
|
let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url);
|
||||||
|
|
||||||
execute_and_format_usage_result(
|
execute_and_format_usage_result(
|
||||||
script_code,
|
script_code,
|
||||||
api_key.unwrap_or(""),
|
&api_key,
|
||||||
base_url.unwrap_or(""),
|
&base_url,
|
||||||
timeout,
|
timeout,
|
||||||
access_token,
|
access_token,
|
||||||
user_id,
|
user_id,
|
||||||
@@ -226,3 +232,76 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::resolve_script_credentials;
|
||||||
|
use crate::app_config::AppType;
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn provider_with_settings(settings_config: serde_json::Value) -> Provider {
|
||||||
|
Provider::with_id(
|
||||||
|
"provider-1".to_string(),
|
||||||
|
"Provider".to_string(),
|
||||||
|
settings_config,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn script_values_override_provider_credentials() {
|
||||||
|
let provider = provider_with_settings(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "provider-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let (api_key, base_url) = resolve_script_credentials(
|
||||||
|
&AppType::Claude,
|
||||||
|
&provider,
|
||||||
|
Some(" script-key "),
|
||||||
|
Some(" https://script.example.com/ "),
|
||||||
|
);
|
||||||
|
assert_eq!(api_key, "script-key");
|
||||||
|
assert_eq!(base_url, "https://script.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_script_values_fall_back_to_provider_credentials() {
|
||||||
|
let provider = provider_with_settings(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "provider-key",
|
||||||
|
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let (api_key, base_url) =
|
||||||
|
resolve_script_credentials(&AppType::Claude, &provider, Some(""), None);
|
||||||
|
assert_eq!(api_key, "provider-key");
|
||||||
|
assert_eq!(base_url, "https://provider.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_fallback_reads_auth_and_config_toml() {
|
||||||
|
let provider = provider_with_settings(json!({
|
||||||
|
"auth": {
|
||||||
|
"OPENAI_API_KEY": "openai-key"
|
||||||
|
},
|
||||||
|
"config": r#"model_provider = "azure"
|
||||||
|
|
||||||
|
[model_providers.azure]
|
||||||
|
base_url = "https://azure.example.com/v1/"
|
||||||
|
|
||||||
|
[model_providers.other]
|
||||||
|
base_url = "https://other.example.com/v1"
|
||||||
|
"#
|
||||||
|
}));
|
||||||
|
|
||||||
|
let (api_key, base_url) =
|
||||||
|
resolve_script_credentials(&AppType::Codex, &provider, None, None);
|
||||||
|
assert_eq!(api_key, "openai-key");
|
||||||
|
assert_eq!(base_url, "https://azure.example.com/v1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||