Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a433b0ff7 | |||
| 1555dbc55e | |||
| 3551e3c496 | |||
| 210bff96c2 | |||
| 08014f99e6 | |||
| 3b33e6921b | |||
| 8d5f72757e | |||
| 3006c6a23d | |||
| 9b14721d4c | |||
| 65c96db0d1 | |||
| e5867ca2d1 | |||
| 8cae7b7b73 | |||
| 854f19d0e1 | |||
| 905f7ccbfe | |||
| 3e4c87278f | |||
| f4e960253e | |||
| 324a1da8e6 | |||
| 49f66bcc9a | |||
| 4084b53834 | |||
| 2c2c72271a | |||
| 6a1ba46f2a | |||
| eaf83f4fbe | |||
| 90812e7f3a | |||
| 8c42ec48ef | |||
| 761038f0ea | |||
| 155a226e6a | |||
| 10be929f33 | |||
| 141010332b | |||
| 15989effb4 | |||
| d4edf30747 | |||
| 44b6eacf87 | |||
| 0a301a497c | |||
| 8aa6ec784b | |||
| bd3cfb7741 | |||
| 117dbf1386 | |||
| 72f570b99e | |||
| 2296c41497 | |||
| fe3f9b60de | |||
| 3e78fe8305 | |||
| 6f170305b8 | |||
| 552f7abee4 | |||
| fd2b232f1c | |||
| 82c75de51c | |||
| 8ccfbd36d6 | |||
| 36bbdc36f5 | |||
| fc08a5d364 | |||
| 333c9f277b | |||
| 9336001746 | |||
| 04254d6ffe | |||
| 28afbea917 | |||
| 81897ac17e | |||
| bb23ab918b | |||
| f38facd430 | |||
| 5c03de53f7 | |||
| d8a7bc32db | |||
| f1cad25777 | |||
| 9439153f05 | |||
| 7097a0d710 | |||
| f1d2c6045b | |||
| 9e5a3b2dc9 | |||
| 2466873db3 | |||
| 3c902b4599 | |||
| 1582d33705 | |||
| 305c0f2e08 | |||
| 8e1204b1ee | |||
| 3568c98f57 | |||
| 7ca33ff901 | |||
| e561084f62 | |||
| 3dad255a2a | |||
| 51825dac20 | |||
| ce985763f0 | |||
| 19dca7cd2b | |||
| 70632249a8 | |||
| 55210d90d2 | |||
| 239c6fb2d7 | |||
| 4ac7e28b10 | |||
| 47e956bb63 | |||
| 0bcbffb8a0 | |||
| 273a756869 | |||
| c2b60623a6 | |||
| f4ad17d314 | |||
| 236f96b583 | |||
| 75b4ef2299 | |||
| fab9874b2c | |||
| 84668e2307 | |||
| b4033fdd29 | |||
| 471c0d9990 | |||
| a9c36381fc | |||
| 5c32ec58be | |||
| 625dea9c62 | |||
| 333b82b4c4 | |||
| 92f62e884c | |||
| 44096b0e44 | |||
| 99ef870077 | |||
| 82438decf6 | |||
| e5838e0a10 | |||
| cc15d7b1e3 | |||
| dd971246be | |||
| 55509286eb | |||
| 5014c2a744 | |||
| 9084e1ecc8 | |||
| 02669cfbac | |||
| 9092e97bc2 | |||
| 032a8203fd | |||
| 6d078e7f33 | |||
| fc6f2af4c6 | |||
| c54515742f | |||
| 7dbceeafe6 | |||
| 31bfecde39 | |||
| 41c3f845cb | |||
| c0737f2cfe | |||
| bf40b0138c | |||
| 3f711d6504 | |||
| 8c3f18a9bd | |||
| e18db31752 | |||
| c0fe0d6e07 | |||
| 7e6f803035 | |||
| b4fdd5fc0d | |||
| 1573474e3a | |||
| 50a2bd29e6 | |||
| 11f70f676e | |||
| a30e2096bb | |||
| fb8996d19c | |||
| 33d5f6985d | |||
| 95a74020e1 | |||
| 078a81b867 | |||
| a89f433dde | |||
| 8217bfff50 |
@@ -150,9 +150,76 @@ jobs:
|
||||
fi
|
||||
echo "✅ Tauri signing key prepared"
|
||||
|
||||
- name: Import Apple signing certificate
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Decode .p12 certificate from base64
|
||||
CERT_PATH="$RUNNER_TEMP/certificate.p12"
|
||||
printf '%s' "${{ secrets.APPLE_CERTIFICATE }}" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH"
|
||||
|
||||
# Save original default keychain for cleanup
|
||||
ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs)
|
||||
echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV"
|
||||
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
|
||||
security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security default-keychain -s "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
|
||||
# Import certificate
|
||||
security import "$CERT_PATH" \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "${{ secrets.KEYCHAIN_PASSWORD }}" "$KEYCHAIN_PATH"
|
||||
|
||||
# Dynamically resolve signing identity (must be "Developer ID Application")
|
||||
IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
|
||||
| grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"')
|
||||
if [ -z "$IDENTITY" ]; then
|
||||
echo "❌ No 'Developer ID Application' identity found — listing all identities:" >&2
|
||||
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Signing identity: $IDENTITY"
|
||||
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
|
||||
|
||||
# Cleanup certificate file
|
||||
rm -f "$CERT_PATH"
|
||||
|
||||
- name: Build Tauri App (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: pnpm tauri build --target universal-apple-darwin
|
||||
shell: bash
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "=== macOS build/notarization attempt ${attempt}/${max_attempts} ==="
|
||||
if pnpm tauri build --target universal-apple-darwin; then
|
||||
echo "✅ macOS build/notarization succeeded"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "❌ macOS build/notarization failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 60))
|
||||
echo "⚠️ macOS build/notarization failed, retrying in ${sleep_seconds}s..."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Build Tauri App (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
@@ -169,7 +236,8 @@ jobs:
|
||||
set -euxo pipefail
|
||||
mkdir -p release-assets
|
||||
VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0
|
||||
echo "Looking for updater artifact (.tar.gz) and .app for zip..."
|
||||
|
||||
# Locate bundle artifacts
|
||||
TAR_GZ=""; APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
@@ -177,28 +245,150 @@ jobs:
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$TAR_GZ" ] && TAR_GZ=$(find "$path" -maxdepth 1 -name "*.tar.gz" -type f | head -1 || true)
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$TAR_GZ" ]; then
|
||||
echo "No macOS .tar.gz updater artifact found" >&2
|
||||
echo "❌ No macOS .tar.gz updater artifact found" >&2
|
||||
exit 1
|
||||
fi
|
||||
# 重命名 tar.gz 为统一格式
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "❌ No .app found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Staple notarization ticket to .app (Tauri already notarized it)
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
echo "✅ .app stapled"
|
||||
|
||||
# 1) Collect .tar.gz (updater artifact)
|
||||
NEW_TAR_GZ="CC-Switch-${VERSION}-macOS.tar.gz"
|
||||
cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ"
|
||||
[ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet"
|
||||
echo "macOS updater artifact copied: $NEW_TAR_GZ"
|
||||
if [ -n "$APP_PATH" ]; then
|
||||
APP_DIR=$(dirname "$APP_PATH"); APP_NAME=$(basename "$APP_PATH")
|
||||
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||
cd "$APP_DIR"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "$NEW_ZIP"
|
||||
mv "$NEW_ZIP" "$GITHUB_WORKSPACE/release-assets/"
|
||||
echo "macOS zip ready: $NEW_ZIP"
|
||||
|
||||
# 2) Collect .app as zip
|
||||
NEW_ZIP="CC-Switch-${VERSION}-macOS.zip"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/$NEW_ZIP"
|
||||
echo "macOS zip ready: $NEW_ZIP"
|
||||
|
||||
# 3) Create styled DMG with create-dmg (Tauri's built-in DMG styling doesn't work on CI)
|
||||
if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then
|
||||
echo "❌ APPLE_SIGNING_IDENTITY is missing before DMG creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HOMEBREW_NO_AUTO_UPDATE=1 brew install create-dmg
|
||||
NEW_DMG="CC-Switch-${VERSION}-macOS.dmg"
|
||||
DMG_STAGE_DIR="$RUNNER_TEMP/dmg-stage"
|
||||
rm -rf "$DMG_STAGE_DIR"
|
||||
mkdir -p "$DMG_STAGE_DIR"
|
||||
ditto "$APP_PATH" "$DMG_STAGE_DIR/CC Switch.app"
|
||||
|
||||
create-dmg \
|
||||
--volname "CC Switch" \
|
||||
--background "src-tauri/icons/dmg-background.png" \
|
||||
--window-size 660 400 \
|
||||
--window-pos 200 120 \
|
||||
--icon-size 80 \
|
||||
--icon "CC Switch.app" 180 220 \
|
||||
--hide-extension "CC Switch.app" \
|
||||
--app-drop-link 480 220 \
|
||||
--codesign "$APPLE_SIGNING_IDENTITY" \
|
||||
--no-internet-enable \
|
||||
"release-assets/$NEW_DMG" \
|
||||
"$DMG_STAGE_DIR"
|
||||
|
||||
rm -rf "$DMG_STAGE_DIR"
|
||||
echo "✅ Styled DMG created: $NEW_DMG"
|
||||
|
||||
- name: Notarize macOS DMG
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
|
||||
if [ -z "$DMG_PATH" ]; then
|
||||
echo "❌ No .dmg found in release-assets/ to notarize" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Notarizing DMG: $DMG_PATH ==="
|
||||
max_attempts=3
|
||||
for attempt in $(seq 1 "$max_attempts"); do
|
||||
echo "=== DMG notarization attempt ${attempt}/${max_attempts} ==="
|
||||
if xcrun notarytool submit "$DMG_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait; then
|
||||
echo "✅ DMG notarization succeeded"
|
||||
xcrun stapler staple "$DMG_PATH"
|
||||
echo "✅ DMG stapled"
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$attempt" -eq "$max_attempts" ]; then
|
||||
echo "❌ DMG notarization failed after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep_seconds=$((attempt * 60))
|
||||
echo "⚠️ DMG notarization failed, retrying in ${sleep_seconds}s..."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
|
||||
- name: Verify macOS code signing and notarization
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Verify .app (from Tauri bundle)
|
||||
APP_PATH=""
|
||||
for path in \
|
||||
"src-tauri/target/universal-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/aarch64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/x86_64-apple-darwin/release/bundle/macos" \
|
||||
"src-tauri/target/release/bundle/macos"; do
|
||||
if [ -d "$path" ]; then
|
||||
[ -z "$APP_PATH" ] && APP_PATH=$(find "$path" -maxdepth 1 -name "*.app" -type d | head -1 || true)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "❌ No .app found for verification" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Verifying .app: $APP_PATH ==="
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
echo "✅ codesign verification passed"
|
||||
spctl -a -t exec -vv "$APP_PATH"
|
||||
echo "✅ spctl assessment passed"
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
echo "✅ .app stapler validation passed"
|
||||
|
||||
# Verify .dmg (from release-assets/, created by create-dmg + notarized)
|
||||
DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true)
|
||||
if [ -n "$DMG_PATH" ]; then
|
||||
echo "=== Verifying .dmg: $DMG_PATH ==="
|
||||
codesign --verify --verbose=2 "$DMG_PATH"
|
||||
echo "✅ .dmg codesign verification passed"
|
||||
spctl -a -t open --context context:primary-signature -vv "$DMG_PATH"
|
||||
echo "✅ .dmg spctl assessment passed"
|
||||
xcrun stapler validate "$DMG_PATH"
|
||||
echo "✅ .dmg stapler validation passed"
|
||||
else
|
||||
echo "No .app found to zip (optional)" >&2
|
||||
echo "❌ No .dmg found for verification — release would ship without verified DMG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Prepare Windows Assets
|
||||
@@ -299,6 +489,51 @@ jobs:
|
||||
echo "Collected signatures (if any alongside artifacts):"
|
||||
ls -la release-assets/*.sig || echo "No signatures found"
|
||||
|
||||
- name: Upload release artifacts to workflow
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-assets-${{ runner.os }}-${{ matrix.arch || runner.arch }}
|
||||
path: release-assets/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
- name: Clean up Apple signing keychain
|
||||
if: runner.os == 'macOS' && always()
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${ORIGINAL_DEFAULT_KEYCHAIN:-}" ]; then
|
||||
security default-keychain -s "$ORIGINAL_DEFAULT_KEYCHAIN" || true
|
||||
fi
|
||||
if [ -f "$RUNNER_TEMP/build.keychain-db" ]; then
|
||||
security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true
|
||||
fi
|
||||
|
||||
publish-release:
|
||||
name: Publish GitHub Release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download built release artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-assets-*
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: List downloaded release artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ls -la release-assets
|
||||
|
||||
- name: Upload Release Assets
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
@@ -312,28 +547,23 @@ jobs:
|
||||
|
||||
### 下载
|
||||
|
||||
- **macOS**: `CC-Switch-${{ github.ref_name }}-macOS.zip`(解压即用)或 `CC-Switch-${{ github.ref_name }}-macOS.tar.gz`(Homebrew)
|
||||
- **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`(绿色版)
|
||||
- **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`
|
||||
|
||||
> `.tar.gz` 为 Tauri updater 自动更新专用,无需手动下载。
|
||||
|
||||
---
|
||||
提示:macOS 如遇"已损坏"提示,可在终端执行:`xattr -cr "/Applications/CC Switch.app"`
|
||||
macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
files: release-assets/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: List generated bundles (debug)
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Listing bundles in src-tauri/target..."
|
||||
find src-tauri/target -maxdepth 4 -type f -name "*.*" 2>/dev/null || true
|
||||
|
||||
assemble-latest-json:
|
||||
name: Assemble latest.json
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
needs: publish-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -24,3 +24,8 @@ flatpak/cc-switch.deb
|
||||
flatpak-build/
|
||||
flatpak-repo/
|
||||
.worktrees/
|
||||
.spec-workflow/
|
||||
copilot-api
|
||||
.history
|
||||
CODEBUDDY.md
|
||||
.github
|
||||
|
||||
@@ -1 +1 @@
|
||||
22.12.0
|
||||
22.12.0
|
||||
|
||||
@@ -9,6 +9,265 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
---
|
||||
|
||||
## [3.12.3] - 2026-03-24
|
||||
|
||||
Major release adding GitHub Copilot reverse proxy support, macOS code signing & Apple notarization, intelligent reasoning effort mapping for o-series models, skill backup/restore lifecycle, proxy gzip compression, and critical fixes for WebDAV password safety, tool message parsing, and dark mode.
|
||||
|
||||
**Stats**: 36 commits | 107 files changed | +9,124 insertions | -802 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **GitHub Copilot Reverse Proxy**: Full GitHub Copilot integration as a Claude Code provider via OAuth Device Code flow; includes multi-account management, automatic token refresh, Anthropic ↔ OpenAI format conversion, real-time model list fetching, and usage statistics (#930)
|
||||
- **Copilot Auth Center**: New Auth Center panel in Settings for managing GitHub accounts globally, with per-provider account binding via `meta.authBinding`
|
||||
- **Tool Search Toggle**: Added `ENABLE_TOOL_SEARCH` env var support for Claude 2.1.76+; exposed as a checkbox in the provider Common Config editor (#930)
|
||||
- **Reasoning Effort Mapping**: Two-tier `resolve_reasoning_effort()` for OpenAI o-series and GPT-5+ models — explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high); covers both Chat Completions and Responses API paths with 17 unit tests
|
||||
- **OpenCode SQLite Backend**: Added SQLite session storage support for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts, atomic session deletion, and path validation (#1401)
|
||||
- **Skill Auto-Backup**: Skill files are automatically backed up to `~/.cc-switch/skill-backups/` before uninstall, with metadata preserved in `meta.json`; old backups pruned to keep at most 20
|
||||
- **Skill Backup Restore & Delete**: Added list/restore/delete commands for skill backups; restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
|
||||
- **macOS Code Signing & Notarization**: CI now imports an Apple Developer ID certificate, signs the universal binary, submits for Apple notarization, and staples the ticket to both `.app` and `.dmg`; a hard-fail verification step (`codesign --verify` + `spctl -a` + `stapler validate`) gates the release for both artifacts
|
||||
- **Codex 1M Context Window Toggle**: One-click checkbox in Codex config editor to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit = 900000`; unchecking removes both fields
|
||||
- **Disable Auto-Upgrade Toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
|
||||
|
||||
### Changed
|
||||
|
||||
- **Skills Cache Strategy**: Replaced `invalidateQueries` with direct `setQueryData` updates for skill install/uninstall/import operations; added `staleTime: Infinity` with `keepPreviousData` to eliminate loading flicker (#1573)
|
||||
- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors
|
||||
- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)
|
||||
- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
|
||||
- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config
|
||||
- **Claude 4.6 Context Window**: Updated Claude Opus 4.6 and Sonnet 4.6 context window from 200K to 1M across OpenClaw and OpenCode presets (GA release)
|
||||
- **MiniMax Model Upgrade**: Updated MiniMax presets from M2.5 to M2.7 across Claude, OpenClaw, and OpenCode configurations with updated partner descriptions in all three locales
|
||||
- **Xiaomi MiMo Model Upgrade**: Updated MiMo presets from mimo-v2-flash to mimo-v2-pro across all supported applications
|
||||
- **AddProviderDialog Simplification**: Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
|
||||
- **Provider Form Advanced Options Collapse**: Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty; auto-expands when any value is set or when a preset fills them in
|
||||
|
||||
### Fixed
|
||||
|
||||
- **WebDAV Password Silent Clear**: Fixed WebDAV password being silently wiped when ProviderList or UsageScriptModal saved settings by stripping `webdavSync` from frontend payloads and adding backend backfill logic in `merge_settings_for_save()` to preserve existing passwords
|
||||
- **Tool Message Parsing**: Fixed tool_use/tool_result message classification across Claude (tool_result content blocks), Codex (function_call/function_call_output payloads), and Gemini (array content + toolCalls extraction) session providers (#1401)
|
||||
- **Dark Mode Selector**: Changed Tailwind `darkMode` from `["selector", "class"]` to `["selector", ".dark"]` to ensure correct dark mode activation (#1596)
|
||||
- **Copilot Request Fingerprint**: Unified Copilot request fingerprint headers across all API call sites to prevent User-Agent leakage and stream check mismatches
|
||||
- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`
|
||||
- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)
|
||||
- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506)
|
||||
- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)
|
||||
- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude
|
||||
- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)
|
||||
- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)
|
||||
- **Import Skills Dialog White Screen**: Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
|
||||
- **Panel Bottom Blank Area**: Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values
|
||||
|
||||
### Docs
|
||||
|
||||
- **Pricing Model ID Normalization**: Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@`→`-` replacement) in EN/ZH/JA user manuals (#1591)
|
||||
- **macOS Signed & Notarized**: Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
|
||||
|
||||
---
|
||||
|
||||
## [3.12.2] - 2026-03-12
|
||||
|
||||
Post-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.
|
||||
|
||||
**Stats**: 5 commits | 22 files changed | +1,716 insertions | -288 deletions
|
||||
|
||||
### Added
|
||||
|
||||
- **Empty State Guidance**: Improved first-run experience with detailed import instructions and a conditional Common Config snippet hint for Claude/Codex/Gemini providers
|
||||
|
||||
### Changed
|
||||
|
||||
- **Proxy Takeover Restore Flow**: Proxy takeover hot-switch and provider sync now refresh the restore backup instead of overwriting live config files, rebuilding effective provider settings with Common Config applied so rollback preserves the real user configuration
|
||||
- **Codex TOML Editing Engine**: Refactored Codex `config.toml` updates onto shared section-aware TOML helpers in Rust and TypeScript, covering `base_url` and `model` field edits across provider forms and takeover cleanup
|
||||
- **Common Config Initialization Lifecycle**: Startup now auto-extracts Common Config snippets from clean live configs before takeover restoration, tracks explicit "snippet cleared" state, and persists a one-time legacy migration flag to avoid repeated backfills
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Common Config Loss During Takeover**: Fixed cases where proxy takeover could drop Common Config changes, overwrite live configs during sync, or produce incomplete restore snapshots when switching providers
|
||||
- **Codex Restore Snapshot Preservation**: Fixed Codex takeover restore backups so existing `mcp_servers` blocks survive provider hot-switches instead of being discarded; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while live-only servers are retained
|
||||
- **Cleared Snippet Resurrection**: Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared
|
||||
- **Codex `base_url` Misplacement**: Fixed Codex `base_url` extraction and editing to target the active `[model_providers.<name>]` section instead of appending to the file tail or confusing `mcp_servers.*.base_url` entries for provider endpoints
|
||||
|
||||
---
|
||||
|
||||
## [3.12.1] - 2026-03-12
|
||||
|
||||
### Patch Release
|
||||
|
||||
Stability-focused patch release fixing the Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, several i18n interpolation issues, and a Windows toolbar compact mode bug. Also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades Gemini to **3.1-pro**, and welcomes four new sponsor partners.
|
||||
|
||||
**Stats**: 19 commits | 56 files changed | +1,429 insertions | -396 deletions
|
||||
|
||||
### Added
|
||||
|
||||
#### Provider Presets
|
||||
|
||||
- **StepFun**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model across supported applications (#1369, thanks @hengm3467)
|
||||
|
||||
#### OpenClaw Enhancements
|
||||
|
||||
- **Input Type Selection**: Added input type selection dropdown for model Advanced Options in OpenClaw configuration form (#1368, thanks @liuxxxu)
|
||||
- **authHeader Field**: Added optional `authHeader` boolean to OpenClawProviderConfig for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type
|
||||
|
||||
#### Sponsor Partners
|
||||
|
||||
- **Micu API**: Added Micu API as sponsor partner with affiliate links
|
||||
- **XCodeAPI**: Added XCodeAPI as sponsor partner
|
||||
- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links
|
||||
- **CTok**: Added CTok as sponsor partner
|
||||
|
||||
### Changed
|
||||
|
||||
- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)
|
||||
- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page
|
||||
- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Common Config & UI
|
||||
|
||||
- **Common Config Modal Loop**: Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits
|
||||
- **Toolbar Compact Mode (Windows)**: Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)
|
||||
- **Session Search Index**: Fixed session search index not syncing with query data, causing stale list display after session deletion
|
||||
|
||||
#### Sync & Data
|
||||
|
||||
- **WebDAV Provider Health FK**: Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync
|
||||
|
||||
#### Provider & Preset
|
||||
|
||||
- **Longcat authHeader**: Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)
|
||||
- **OpenClaw Tool Permissions**: Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)
|
||||
- **X-Code API URL**: Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`
|
||||
|
||||
#### i18n & Localization
|
||||
|
||||
- **Stream Check Toast**: Fixed stream check toast i18n interpolation keys not matching translation placeholders
|
||||
- **Proxy Startup Toast**: Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)
|
||||
- **OpenCode API Format Label**: Renamed OpenCode API format label from "OpenAI" to "OpenAI Responses" for accuracy
|
||||
|
||||
---
|
||||
|
||||
## [3.12.0] - 2026-03-09
|
||||
|
||||
### Feature Release
|
||||
|
||||
This release restores the **Model Health Check (Stream Check)** UI, adds **OpenAI Responses API** format conversion, introduces the **Bedrock Optimizer** for thinking + cache injection, expands provider presets (Ucloud, Micu, X-Code API, Novita, Bailian For Coding), overhauls **OpenClaw config panels** with a JSON5 round-trip write engine, enhances **WebDAV sync** with dual-layer versioning, and delivers a comprehensive **i18n audit** fixing 69 missing keys alongside 20+ bug fixes.
|
||||
|
||||
**Stats**: 56 commits | 221 files changed | +20,582 insertions | -8,026 deletions
|
||||
|
||||
### Added
|
||||
|
||||
#### Stream Check (Model Health Check)
|
||||
|
||||
- **Restore Stream Check UI**: Brought back the model health check (Stream Check) panel for testing provider endpoint availability with live streaming validation
|
||||
- **First-Run Confirmation**: Added a confirmation dialog on first use of Stream Check to inform users about the feature's purpose and network requests
|
||||
- **OpenAI Chat Format Support**: Stream Check now supports `openai_chat` api_format, enabling health checks for providers using OpenAI-compatible endpoints
|
||||
|
||||
#### OpenAI Responses API
|
||||
|
||||
- **Responses API Format Conversion**: New `api_format = "openai_responses"` option enabling Anthropic Messages ↔ OpenAI Responses API bidirectional conversion for providers that implement the Responses API
|
||||
- **Responses API Deduplication**: Deduplicated and improved the Responses API conversion logic, consolidating shared transformation code
|
||||
|
||||
#### Bedrock Optimizer
|
||||
|
||||
- **Bedrock Request Optimizer**: PRE-SEND optimizer that injects thinking parameters and cache control blocks into AWS Bedrock requests, enabling extended thinking and prompt caching on Bedrock endpoints (#1301)
|
||||
|
||||
#### OpenClaw Enhancements
|
||||
|
||||
- **JSON5 Round-Trip Write Engine**: Overhauled OpenClaw config panels with a JSON5 round-trip write engine that preserves comments, formatting, and ordering when saving configuration changes
|
||||
- **Config Panel Improvements**: Redesigned EnvPanel as a full JSON editor, added `tools.profile` selection to ToolsPanel, introduced OpenClawHealthBanner for config validation warnings, and added legacy timeout migration support in Agents Defaults
|
||||
- **Agent Model Dropdown**: Replaced text inputs with dropdown selects for OpenClaw agent model configuration, offering a curated list of available models
|
||||
- **User-Agent Toggle**: Added a User-Agent header toggle for OpenClaw, defaulting to off to avoid potential compatibility issues with certain providers
|
||||
|
||||
#### Provider Presets
|
||||
|
||||
- **Ucloud**: Added Ucloud partner provider preset for Claude, Codex, and OpenClaw with endpointCandidates, unified apiKeyUrl, refreshed model defaults, and OpenClaw `templateValues` / `suggestedDefaults`
|
||||
- **Micu**: Added Micu partner provider preset for Claude, Codex, OpenClaw, and OpenCode with OpenClaw `templateValues` / `suggestedDefaults`
|
||||
- **X-Code API**: Added X-Code API partner provider preset for Claude, Codex, and OpenCode with endpointCandidates
|
||||
- **Novita**: Added Novita provider presets and icon across all supported apps (#1192)
|
||||
- **Bailian For Coding**: Added Bailian For Coding preset configuration (#1263)
|
||||
- **SiliconFlow Partner Badge**: Added partner badge designation for SiliconFlow provider presets
|
||||
- **Model Role Badges**: Added model role badges (e.g., Opus, Sonnet) to provider presets and reordered presets to prioritize Opus models
|
||||
|
||||
#### WebDAV Sync
|
||||
|
||||
- **Dual-Layer Versioning**: Added protocol v2 + db-v6 dual-layer versioning to WebDAV sync, enabling backward-compatible sync format evolution and automatic migration detection
|
||||
- **Auto-Sync Confirmation**: Added a confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes
|
||||
|
||||
#### Usage & Data
|
||||
|
||||
- **Daily Rollups & Auto-Vacuum**: Added usage daily rollups for aggregated statistics, incremental auto-vacuum for storage management, and sync-aware backup that coordinates with WebDAV sync cycles
|
||||
- **UsageFooter Extra Fields**: Added extra field display in UsageFooter component for normal mode, showing additional usage metadata (#1137)
|
||||
|
||||
#### Session Management
|
||||
|
||||
- **Session Deletion**: Added session deletion with per-provider cleanup and path safety validation, allowing users to remove individual conversation sessions
|
||||
|
||||
#### UI & Config
|
||||
|
||||
- **Auth Field Selector**: Restored Claude provider auth field selector supporting both AUTH_TOKEN and API_KEY authentication modes
|
||||
- **Failover Toggle**: Moved failover toggle to display independently on the main page with a confirmation dialog for enabling/disabling
|
||||
- **Common Config Auto-Extract**: Auto-extract Common Config Snippets from live configuration files on first run, seeding initial common config without manual setup
|
||||
- **New Provider Page Improvements**: Improved the new provider page with API endpoint and model name fields (#1155)
|
||||
|
||||
### Changed
|
||||
|
||||
#### Architecture
|
||||
|
||||
- **Common Config Runtime Overlay**: Common Config is now applied as a runtime overlay during provider switching instead of being materialized (merged) into each provider's stored config. This preserves the original provider config in the database and applies common settings dynamically at write time
|
||||
- **First-Run Auto-Extract**: On first run, Common Config Snippets are automatically extracted from the current live configuration files, eliminating the need for manual initial setup
|
||||
|
||||
### Fixed
|
||||
|
||||
#### Proxy & Streaming
|
||||
|
||||
- **OpenAI Streaming Conversion**: Fixed OpenAI ChatCompletion → Anthropic Messages streaming conversion that could produce malformed events under certain response structures
|
||||
- **Codex /responses/compact Route**: Added support for Codex `/responses/compact` route in proxy forwarding (#1194)
|
||||
- **Codex Common Config TOML Merge**: Fixed Codex Common Config to use structural TOML merge/subset instead of raw string comparison, correctly handling key ordering and formatting differences
|
||||
- **Proxy Forwarder Failure Logs**: Improved proxy forwarder failure logging with more descriptive error messages
|
||||
|
||||
#### Provider & Preset
|
||||
|
||||
- **X-Code Rename**: Renamed "X-Code" provider to "X-Code API" for consistency with the official branding
|
||||
- **SSSAiCode Missing /v1**: Added missing `/v1` path to SSSAiCode default endpoint for Codex and OpenCode
|
||||
- **AICoding URL Fix**: Removed `www` prefix from aicoding.sh provider URLs to match the correct domain
|
||||
- **New Provider Page Input Handling**: Fixed the new provider page so API endpoint / model fields handle line-break deletion correctly and added the missing `codexConfig.modelNameHint` i18n key for zh/en/ja
|
||||
|
||||
#### Platform
|
||||
|
||||
- **Cache Hit Token Statistics**: Fixed missing token statistics for cache hits in streaming responses (#1244)
|
||||
- **Minimize-to-Tray Auto Exit**: Fixed issue where the application would automatically exit after being minimized to the system tray for a period of time (#1245)
|
||||
|
||||
#### i18n & Localization
|
||||
|
||||
- **Comprehensive i18n Audit**: Added 69 missing i18n keys and fixed hardcoded Chinese strings across the application, improving localization coverage for all three languages (zh/en/ja)
|
||||
- **Model Test Panel i18n**: Corrected i18n key paths for model test panel title and description
|
||||
- **JSON5 Slash Escaping**: Normalized JSON5 slash escaping and added i18n support for OpenClaw panel labels
|
||||
|
||||
#### UI
|
||||
|
||||
- **Skills Count Display**: Fixed skills count not displaying correctly when adding new skills (#1295)
|
||||
- **Endpoint Speed Test**: Removed HTTP status code display from endpoint speed test results to reduce visual noise
|
||||
- **Outline Button Text Tone**: Aligned outline button text color tone with usage refresh control for visual consistency (#1222)
|
||||
|
||||
### Performance
|
||||
|
||||
- **OpenClaw Config Write Skip**: Skip backup and atomic write when OpenClaw configuration content is unchanged, avoiding unnecessary I/O operations
|
||||
|
||||
### Documentation
|
||||
|
||||
- **User Manual i18n**: Restructured user manual for internationalization and added complete EN/JA translations alongside the existing ZH documentation
|
||||
- **User Manual OpenClaw**: Added OpenClaw coverage and completed settings documentation for the user manual
|
||||
- **UCloud CompShare Sponsor**: Added UCloud CompShare as a sponsor partner
|
||||
- **Docs Directory Reorganization**: Reorganized docs directory structure, added user manual links to all three README files, removed cross-language links from user manual sections, and synced README features across EN/ZH/JA
|
||||
|
||||
### Maintenance
|
||||
|
||||
- **Periodic Maintenance Timer**: Consolidated periodic maintenance timers into a unified scheduler, combining vacuum and rollup operations into a single timer
|
||||
- **OpenClaw Save Toast**: Removed backup path display from OpenClaw save toasts for cleaner notification messages
|
||||
|
||||
---
|
||||
|
||||
## [3.11.1] - 2026-02-28
|
||||
|
||||
### Hotfix Release
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### The All-in-One Manager for Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@ English | [中文](README_ZH.md) | [日本語](README_JA.md) | [Changelog](CHANG
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
MiniMax-M2.5 is a SOTA large language model designed for real-world productivity. Trained in a diverse range of complex real-world digital working environments, M2.5 builds upon the coding expertise of M2.1 to extend into general office work, reaching fluency in generating and operating Word, Excel, and Powerpoint files, context switching between diverse software environments, and working across different agent and human teams. Scoring 80.2% on SWE-Bench Verified, 51.3% on Multi-SWE-Bench, and 76.3% on BrowseComp, M2.5 is also more token efficient than previous generations, having been trained to optimize its actions and output through planning.
|
||||
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.
|
||||
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Coding Plan!
|
||||
[Click](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link) to get an exclusive 12% off the MiniMax Token Plan!
|
||||
|
||||
---
|
||||
|
||||
@@ -34,6 +34,11 @@ MiniMax-M2.5 is a SOTA large language model designed for real-world productivity
|
||||
<td>Thanks to PackyCode for sponsoring this project! PackyCode is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. PackyCode provides special discounts for our software users: register using <a href="https://www.packyapi.com/register?aff=cc-switch">this link</a> and enter the "cc-switch" promo code during first recharge to get 10% off.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>Thanks to SiliconFlow for sponsoring this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via <a href="https://cloud.siliconflow.cn/i/drGuwc9k">this link</a> and complete real-name verification to receive ¥20 in bonus credit, usable across models on the platform. SiliconFlow is also now compatible with OpenClaw, allowing users to connect a SiliconFlow API key and call major AI models for free.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for CC Switch users: if you register via <a href="https://aigocode.com/invite/CC-SWITCH">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
@@ -56,8 +61,8 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>Thanks to UCloud CompShare for sponsoring this project! CompShare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Thanks to Compshare for sponsoring this project! Compshare is UCloud's AI cloud platform, providing stable and comprehensive domestic and international model APIs with just one key. Featuring cost-effective monthly and pay-as-you-go Coding Plan packages at 60-80% off official prices. Supports Claude Code, Codex, and API access. Enterprise-grade high concurrency, 24/7 technical support, and self-service invoicing. Users who register via <a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">this link</a> will receive a free 5 CNY platform trial credit!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -71,7 +76,7 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Thanks to Crazyrouter for sponsoring this project! Crazyrouter is a high-performance AI API aggregation platform — one API key for 300+ models including Claude Code, Codex, Gemini CLI, and more. All models at 55% of official pricing with auto-failover, smart routing, and unlimited concurrency. Crazyrouter offers an exclusive deal for CC Switch users: register via <a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">this link</a> to get <strong>$2 free credit</strong> instantly, plus enter promo code `CCSWITCH` on your first top-up for an extra <strong>30% bonus credit</strong>! </td>
|
||||
</tr>
|
||||
|
||||
@@ -80,6 +85,21 @@ Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original pric
|
||||
<td>Thanks to SSSAiCode for sponsoring this project! SSSAiCode is a stable and reliable API relay service, dedicated to providing stable, reliable, and affordable Claude and Codex model services, <strong>offering high cost-effective official Claude service at just ¥0.5/$ equivalent</strong>, supporting monthly and pay-as-you-go billing plans with same-day fast invoicing. SSSAiCode offers a special deal for CC Switch users: register via <a href="https://www.sssaicode.com/register?ref=DCP0SM">this link</a> to enjoy $10 extra credit on every top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/register?aff=aOYQ"><img src="assets/partners/logos/mikubanner.svg" alt="Micu" width="150"></a></td>
|
||||
<td>Thanks to Micu API for sponsoring this project! Micu API is a global LLM relay service provider dedicated to delivering the best cost-performance ratio with high stability. Backed by a registered enterprise for core assurance, eliminating any risk of service discontinuation, with fast official invoicing support! We champion "zero cost to try": top up from as low as ¥1 with no minimum, and get fee-free refunds anytime! Micu API offers an exclusive deal for CC Switch users: register via <a href="https://www.openclaudecode.cn/register?aff=aOYQ">this link</a> and enter promo code "ccswitch" when topping up to enjoy a <strong>10% discount</strong>!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>Thanks to XCodeAPI for sponsoring this project! XCodeAPI offers a special benefit for CC Switch users: register via <a href="https://x-code.cc/register?aff=IbPp">this link</a> and get an extra 10% credit bonus on your first order! (Contact the site admin to claim)</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>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -106,7 +126,7 @@ Modern AI-powered coding relies on CLI tools like Claude Code, Codex, Gemini CLI
|
||||
|
||||
## Features
|
||||
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.11.1-en.md)
|
||||
[Full Changelog](CHANGELOG.md) | [Release Notes](docs/release-notes/v3.12.3-en.md)
|
||||
|
||||
### Provider Management
|
||||
|
||||
@@ -164,9 +184,9 @@ CC Switch provides a "Shared Config Snippet" feature to pass common data (beyond
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS shows "unidentified developer" warning — how do I fix it?</strong></summary>
|
||||
<summary><strong>macOS installation</strong></summary>
|
||||
|
||||
The author doesn't have an Apple Developer account yet (registration in progress). Close the warning, then go to **System Settings → Privacy & Security → Open Anyway**. After that, the app will open normally.
|
||||
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly — no extra steps needed. We recommend using the `.dmg` installer.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -191,6 +211,7 @@ Add an official provider from the preset list. After switching to it, run the Lo
|
||||
- **Local settings**: `~/.cc-switch/settings.json` (device-level UI preferences)
|
||||
- **Backups**: `~/.cc-switch/backups/` (auto-rotated, keeps 10 most recent)
|
||||
- **Skills**: `~/.cc-switch/skills/` (symlinked to corresponding apps by default)
|
||||
- **Skill Backups**: `~/.cc-switch/skill-backups/` (created automatically before uninstall, keeps 20 most recent)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -223,7 +244,7 @@ For detailed guides on every feature, check out the **[User Manual](docs/user-ma
|
||||
### System Requirements
|
||||
|
||||
- **Windows**: Windows 10 and above
|
||||
- **macOS**: macOS 10.15 (Catalina) and above
|
||||
- **macOS**: macOS 12 (Monterey) and above
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ and other mainstream distributions
|
||||
|
||||
### Windows Users
|
||||
@@ -247,9 +268,9 @@ brew upgrade --cask cc-switch
|
||||
|
||||
**Method 2: Manual Download**
|
||||
|
||||
Download `CC-Switch-v{version}-macOS.zip` from the [Releases](../../releases) page and extract to use.
|
||||
Download `CC-Switch-v{version}-macOS.dmg` (recommended) or `.zip` from the [Releases](../../releases) page.
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it first, then go to "System Settings" → "Privacy & Security" → click "Open Anyway", and you'll be able to open it normally afterwards.
|
||||
> **Note**: CC Switch for macOS is code-signed and notarized by Apple. You can install and open it directly.
|
||||
|
||||
### Arch Linux Users
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode、OpenClaw のオールインワン管理ツール
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
[](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)
|
||||
|
||||
MiniMax-M2.5 は、実際の生産性向上のために設計された最先端の大規模言語モデルです。多様で複雑な実環境のデジタルワークスペースでトレーニングされた M2.5 は、M2.1 のコーディング能力をベースに一般的なオフィス業務へと拡張し、Word・Excel・PowerPoint ファイルの生成と操作、多様なソフトウェア環境間のコンテキスト切り替え、異なるエージェントや人間チーム間での協働を流暢にこなします。SWE-Bench Verified で 80.2%、Multi-SWE-Bench で 51.3%、BrowseComp で 76.3% を達成し、計画的な行動と出力の最適化トレーニングにより、前世代よりもトークン効率に優れています。
|
||||
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 ネイティブなワークフローと組織構築の新基準を打ち立てます。
|
||||
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Coding Plan の限定 12% オフを入手!
|
||||
[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!
|
||||
|
||||
---
|
||||
|
||||
@@ -34,6 +34,11 @@ MiniMax-M2.5 は、実際の生産性向上のために設計された最先端
|
||||
<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href="https://www.packyapi.com/register?aff=cc-switch">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_en.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href="https://cloud.siliconflow.cn/i/drGuwc9k">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥20 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href="https://aigocode.com/invite/CC-SWITCH">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>
|
||||
@@ -56,8 +61,8 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>UCloud CompShare のご支援に感謝します!CompShare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Compshare" width="150"></a></td>
|
||||
<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・従量課金のコストパフォーマンスに優れた Coding Plan パッケージを提供し、公式価格の 60〜80% オフで利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -71,7 +76,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">こちらのリンク</a>から登録すると <strong>$2 の無料クレジット</strong> を即時進呈。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>
|
||||
</tr>
|
||||
|
||||
@@ -80,6 +85,21 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。<strong>高コストパフォーマンスの公式 Claude サービスを 0.5¥/$ 換算で提供</strong>、月額制・Paygo など多様な課金方式に対応し、当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href="https://www.sssaicode.com/register?ref=DCP0SM">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/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.openclaudecode.cn/register?aff=aOYQ">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>XCodeAPI のご支援に感謝します!CC Switch ユーザー向けの特別特典:<a href="https://x-code.cc/register?aff=IbPp">こちらのリンク</a>から登録すると、初回注文で 10% の追加クレジットボーナスがもらえます!(サイト管理者に連絡して受け取りください)</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>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href="https://ctok.ai">こちら</a>から登録してください!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -106,7 +126,7 @@ Claude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% /
|
||||
|
||||
## 特長
|
||||
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.11.1-ja.md)
|
||||
[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.12.3-ja.md)
|
||||
|
||||
### プロバイダ管理
|
||||
|
||||
@@ -191,6 +211,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
|
||||
- **ローカル設定**: `~/.cc-switch/settings.json`(デバイスレベルの UI 設定)
|
||||
- **バックアップ**: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)
|
||||
- **Skills**: `~/.cc-switch/skills/`(デフォルトでシンボリックリンクにより対応アプリに接続)
|
||||
- **Skill バックアップ**: `~/.cc-switch/skill-backups/`(アンインストール前に自動作成、最新 20 件を保持)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -223,7 +244,7 @@ CC Switch は「最小限の介入」という設計原則に従っています
|
||||
### システム要件
|
||||
|
||||
- **Windows**: Windows 10 以上
|
||||
- **macOS**: macOS 10.15 (Catalina) 以上
|
||||
- **macOS**: macOS 12 (Monterey) 以上
|
||||
- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション
|
||||
|
||||
### Windows ユーザー
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Claude Code、Codex、Gemini CLI、OpenCode 和 OpenClaw 的全方位管理工具
|
||||
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://github.com/farion1231/cc-switch/releases)
|
||||
[](https://tauri.app/)
|
||||
[](https://github.com/farion1231/cc-switch/releases/latest)
|
||||
@@ -22,9 +22,9 @@
|
||||
|
||||
[](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)
|
||||
|
||||
MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景均达到或刷新行业 SOTA,拥有架构师级代码能力与高效任务拆解能力,推理速度较上一代提升 37%、token 消耗更优;100 token/s 连续工作一小时仅需 1 美金,让复杂 Agent 规模化部署经济可行,已在企业多职能场景深度落地,加速全民 Agent 时代到来。
|
||||
MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。
|
||||
|
||||
[点击](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)即可领取 MiniMax Coding Plan 专属 88 折优惠!
|
||||
[点击此处](https://platform.minimaxi.com/subscribe/coding-plan?code=7kYF2VoaCn&source=link)享 MiniMax Token Plan 专属 88 折优惠!
|
||||
|
||||
---
|
||||
|
||||
@@ -34,6 +34,11 @@ MiniMax M2.5 在编程、工具调用与搜索、办公等核心生产力场景
|
||||
<td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的API中转服务商,提供 Claude Code、Codex、Gemini 等多种中转服务。PackyCode 为本软件的用户提供了特别优惠,使用<a href="https://www.packyapi.com/register?aff=cc-switch">此链接</a>注册并在充值时填写"cc-switch"优惠码,首次充值可以享受9折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://cloud.siliconflow.cn/i/drGuwc9k"><img src="assets/partners/logos/silicon_zh.jpg" alt="SiliconFlow" width="150"></a></td>
|
||||
<td>感谢硅基流动赞助了本项目!硅基流动是一个高性能 AI 基础设施与模型 API 平台,一站式提供语言、语音、图像、视频等多模态模型的快速、可靠访问。平台支持按量计费、丰富的多模态模型选择、高速推理和企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过<a href="https://cloud.siliconflow.cn/i/drGuwc9k">此链接</a>注册并完成实名认证,即可获得 ¥20 奖励金,可在平台内跨模型使用。硅基流动现已兼容 OpenClaw,用户可接入硅基流动 API Key 免费调用主流 AI 模型。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://aigocode.com/invite/CC-SWITCH"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一个集成了 Claude Code、Codex 以及 Gemini 最新模型的一站式平台,为你提供稳定、高效且高性价比的AI编程服务。本站提供灵活的订阅计划,零封号风险,国内直连,无需魔法,极速响应。AIGoCode 为 CC Switch 的用户提供了特别福利,通过<a href="https://aigocode.com/invite/CC-SWITCH">此链接</a>注册的用户首次充值可以获得额外10%奖励额度!</td>
|
||||
@@ -57,8 +62,8 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="Ucloud" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
<td width="180"><a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch"><img src="assets/partners/logos/ucloud.png" alt="优云智算" width="150"></a></td>
|
||||
<td>感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按量的高性价比 Coding Plan 套餐,基于官方2~5折优惠。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过<a href="https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch">此链接</a>注册的用户,可得免费5元平台体验金!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -72,7 +77,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="AICoding" width="150"></a></td>
|
||||
<td width="180"><a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch"><img src="assets/partners/logos/crazyrouter.jpg" alt="Crazyrouter" width="150"></a></td>
|
||||
<td>感谢 Crazyrouter 赞助了本项目!Crazyrouter 是一个高性能 AI API 聚合平台——一个 API Key 即可访问 300+ 模型,包括 Claude Code、Codex、Gemini CLI 等。全部模型低至官方定价的 55%,支持自动故障转移、智能路由和无限并发。Crazyrouter 为 CC Switch 用户提供了专属优惠:通过<a href="https://crazyrouter.com/register?aff=OZcm&ref=cc-switch">此链接</a>注册即可获得 <strong>$2 免费额度</strong>,首次充值时输入优惠码 `CCSWITCH` 还可获得额外 <strong>30% 奖励额度</strong>!</td>
|
||||
</tr>
|
||||
|
||||
@@ -81,6 +86,21 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
<td>感谢 SSSAiCode 赞助了本项目!SSSAiCode 是一家稳定可靠的API中转站,致力于提供稳定、可靠、平价的Claude、CodeX模型服务,<strong>提供高性价比折合0.5¥/$的官方Claude服务</strong>,支持包月、Paygo多种计费方式、支持当日快速开票,SSSAiCode为本软件的用户提供特别优惠,使用<a href="https://www.sssaicode.com/register?ref=DCP0SM">此链接</a>注册每次充值均可享受10$的额外奖励!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.openclaudecode.cn/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.openclaudecode.cn/register?aff=aOYQ">此链接</a>注册并在充值时填写"ccswitch"优惠码可享九折优惠!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://x-code.cc/register?aff=IbPp"><img src="assets/partners/logos/xcodeapi.png" alt="XCodeAPI" width="150"></a></td>
|
||||
<td>感谢 XCodeAPI 赞助了本项目!XCodeAPI 为本软件的用户提供特别福利,使用<a href="https://x-code.cc/register?aff=IbPp">此链接</a>注册后首单加赠10%的额度!(联系站长领取)</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>感谢 CTok.ai 赞助了本项目!CTok.ai 致力于打造一站式 AI 编程工具服务平台。我们提供 Claude Code 专业套餐及技术社群服务,同时支持 Google Gemini 和 OpenAI Codex。通过精心设计的套餐方案和专业的技术社群,为开发者提供稳定的服务保障和持续的技术支持,让 AI 辅助编程真正成为开发者的生产力工具。点击<a href="https://ctok.ai">这里</a>注册!</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
@@ -107,7 +127,7 @@ Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更
|
||||
|
||||
## 功能特性
|
||||
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.11.1-zh.md)
|
||||
[完整更新日志](CHANGELOG.md) | [发布说明](docs/release-notes/v3.12.3-zh.md)
|
||||
|
||||
### 供应商管理
|
||||
|
||||
@@ -165,9 +185,9 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>macOS 提示"未知开发者"警告 — 如何解决?</strong></summary>
|
||||
<summary><strong>macOS 安装</strong></summary>
|
||||
|
||||
这是由于作者没有苹果开发者账号(正在注册中)。关闭警告后,前往**系统设置 → 隐私与安全性 → 仍要打开**。之后应用即可正常打开。
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。推荐使用 `.dmg` 安装包。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -194,6 +214,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
- **本地设置**:`~/.cc-switch/settings.json`(设备级 UI 偏好设置)
|
||||
- **备份**:`~/.cc-switch/backups/`(自动轮换,保留最近 10 个)
|
||||
- **SKILLS**:`~/.cc-switch/skills/`(默认通过软链接连接到对应应用)
|
||||
- **技能备份**:`~/.cc-switch/skill-backups/`(卸载前自动创建,保留最近 20 个)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -226,7 +247,7 @@ CC Switch 使用“通用配置片段”功能,在不同的供应商之间传
|
||||
### 系统要求
|
||||
|
||||
- **Windows**:Windows 10 及以上
|
||||
- **macOS**:macOS 10.15 (Catalina) 及以上
|
||||
- **macOS**:macOS 12 (Monterey) 及以上
|
||||
- **Linux**:Ubuntu 22.04+ / Debian 11+ / Fedora 34+ 等主流发行版
|
||||
|
||||
### Windows 用户
|
||||
@@ -250,9 +271,9 @@ brew upgrade --cask cc-switch
|
||||
|
||||
**方式二:手动下载**
|
||||
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.zip` 解压使用。
|
||||
从 [Releases](../../releases) 页面下载 `CC-Switch-v{版本号}-macOS.dmg`(推荐)或 `.zip`。
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开。
|
||||
> **注意**:CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开。
|
||||
|
||||
### Arch Linux 用户
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 902 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 895 KiB |
|
After Width: | Height: | Size: 246 KiB |
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1075.78 240.6">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #068cde;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #02a4fd;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #02a6ff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<path class="cls-1" d="M226.14,157.63c-3.62,0-7.24,0-10.95,0v-24.96c5.2,0,10.17,0,15.13,0,5.55-.01,8.52-4.01,10.18-8.16,1.34-3.37,1.36-7.51-1.34-11.1-3.16-4.2-7.23-5.72-12.25-5.63-3.87.07-7.74.01-11.66.01v-24.79c6.56-.43,12.93.45,19.3-1.13.4-.42.85-1.05,1.44-1.49,4.61-3.47,6.48-9.22,4.67-14.51-1.63-4.76-6.67-8.03-12.22-7.99-4.37.03-8.74,0-13.42,0,0-5.79.1-11.16-.04-16.54-.08-3.23-1.09-6.23-3.22-8.79-7.6-9.17-17.84-6.02-28.13-6.29-.39-.23-.63-1.14-.45-2.35.73-4.72.37-9.44-.24-14.13-.51-3.95-5.79-9.24-9.1-9.56-10.42-1-15.88,5.21-15.83,14.89.02,3.54,0,7.08,0,10.92h-24.44c-.76-1.03-.45-2.13-.42-3.19.15-5.2.71-10.35-1.11-15.5-2.15-6.08-11.68-9.27-17.05-5.79-5.27,3.41-7.22,7.95-6.99,13.99.14,3.56.53,7.22-.44,10.6h-23.98c-.22-.38-.37-.51-.37-.66-.05-4.56,0-9.12-.14-13.68-.15-5.18-4.72-10.76-9.31-11.57-8.81-1.55-15.64,4.23-15.64,13.24,0,4.11,0,8.21,0,12.61-4.92,0-9.32.08-13.72-.02-10.41-.22-18.81,7.79-17.84,18.57.39,4.32.06,8.7.06,13.34-5.17,0-9.9-.05-14.63.01-5.36.07-11.08,5.57-11.83,10.1-1.39,8.44,6.23,15.25,14.55,14.78,3.86-.22,7.74-.04,11.75-.04v24.92c-4.45,0-8.67-.07-12.89.02-3.2.07-6.5.62-8.75,2.93-3.6,3.69-6.1,8.11-4.12,13.5,2.13,5.8,6.42,8.43,12.98,8.43,4.21,0,8.42,0,12.83,0v24.95c-3.48,0-6.79-.12-10.08.03-3.74.18-7.43.36-10.78,2.69-4.11,2.87-6.48,8.21-5.32,12.83,1.1,4.36,7.17,9.83,11.59,9.41,4.82-.46,9.72-.1,14.69-.1,0,5.18.51,9.88-.1,14.44-1.36,10,8.64,18.06,17.22,17.5,4.62-.3,9.28-.05,14.15-.05,1.26,9.11-3.28,19.95,9.5,25.9,10.27,1.43,15.74-3.33,15.75-15.14,0-3.45,0-6.9,0-10.55h24.94c0,3.39,0,6.6,0,9.8,0,3.81.26,7.41,2.73,10.76,3.09,4.2,8.64,6.68,14,4.87,3.62-1.22,8.31-5.43,8.3-10.7,0-4.85,0-9.7,0-14.7h24.92c0,3.98-.14,7.7.03,11.41.22,4.83,1.4,9.35,5.68,12.32,5.16,3.59,12.81,2.96,17.28-2.41,3.93-7.43,2.05-14.57,2.39-21.58,5.71,0,11.1.03,16.49-.02,1.98-.02,4.05-.06,5.8-1.09,6.78-3.99,9.8-9.94,9.36-17.81-.23-4.18-.04-8.39-.04-12.56,8.59-1.68,18.23,2.96,24.73-6.3.08-.31.31-1.1.5-1.9.97-4.19,1.82-8.06-1.59-12.01-3.49-4.05-7.66-5.05-12.52-5.04ZM168.3,160.54c0,3.41-1.48,4.58-4.69,4.49-4.41-.12-8.82-.09-13.23-.05-3.15.03-4.43-1.39-4.41-4.56.06-16.85.02-33.69-.03-50.54,0-.99.44-2.13-.73-3.15-4.49,13.97-8.94,27.8-13.44,41.79h-21.8c-4.39-13.78-8.8-27.62-13.55-42.54-.15,1.94-.29,2.89-.29,3.84-.01,16.68-.08,33.36.04,50.04.03,3.7-1.18,5.38-5.05,5.16-5.51-.31-11.08.38-16.22-.44-1.24-1.59-1.21-2.95-1.21-4.26.07-27.3.18-54.6.22-81.9,0-2.16.89-3.46,2.97-3.48,9.9-.06,19.8,0,29.7.05.31,0,.63.19,1.39.44,4.22,14.29,8.51,28.79,12.8,43.3.29.05.58.1.87.15,4.53-14.59,9.07-29.17,13.66-43.95,10.35,0,20.48-.03,30.62.03,1.76.01,2.38,1.37,2.42,2.92.08,2.57.1,5.14.1,7.71-.06,24.98-.16,49.95-.15,74.93Z"/>
|
||||
<rect class="cls-4" x="48.86" y="48.46" width="143.67" height="143.67" rx="10.57" ry="10.57"/>
|
||||
<path class="cls-3" d="M165.55,75.28c-10.14-.06-20.27-.03-30.62-.03-4.59,14.78-9.12,29.36-13.66,43.95-.29-.05-.58-.1-.87-.15-4.29-14.51-8.58-29.01-12.8-43.3-.77-.25-1.08-.44-1.39-.44-9.9-.04-19.8-.1-29.7-.05-2.08.01-2.96,1.32-2.97,3.48-.04,27.3-.15,54.6-.22,81.9,0,1.31-.03,2.67,1.21,4.26,5.13.82,10.7.13,16.22.44,3.87.22,5.07-1.46,5.05-5.16-.12-16.68-.06-33.36-.04-50.04,0-.95.14-1.9.29-3.84,4.75,14.91,9.16,28.76,13.55,42.54h21.8c4.5-13.99,8.94-27.82,13.44-41.79,1.18,1.01.73,2.16.73,3.15.04,16.85.09,33.69.03,50.54-.01,3.17,1.26,4.59,4.41,4.56,4.41-.04,8.82-.07,13.23.05,3.21.09,4.69-1.08,4.69-4.49-.01-24.98.09-49.95.15-74.93,0-2.57-.02-5.14-.1-7.71-.05-1.55-.67-2.91-2.42-2.92Z"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M372.64,135.48c-.13-.49-.54-.78-.71-.89-7.15-4.87-14.15-9.95-21.35-14.75-4.85-3.24-7.95-5.93-8.98-6.85-3.54-3.13-6.16-6.05-7.88-8.11,18.27.05,31.65-.03,32.44-.05.12,0,.6-.03.98-.42.22-.22.31-.45.35-.59.02-4.96.04-9.91.06-14.87,0-.78-.62-1.42-1.39-1.42-8.12.04-16.23.08-24.35.12,4.03-4.67,7.45-8.18,9.86-10.55,2.43-2.39,4.46-5.14,6.77-7.64,1.93-2.09,4.11-4.37,3.5-6.38-.2-.66-.63-1.08-.87-1.29-3.46-2.9-6.93-5.8-10.39-8.7-.41-.25-1.2-.63-2.04-.42-.82.21-1.3.88-1.47,1.12-3.4,4.75-5.17,7.07-5.2,7.12,0,0-1.32,2.51-13.36,16.27-.12.14-.48.54-.48,1.08,0,.5.31.88.49,1.07,2.96,2.73,5.93,5.46,8.89,8.2h-10.94v-37.5c0-.78-.62-1.42-1.39-1.42h-15.19c-.77,0-1.39.64-1.39,1.42v37.5h-13.87l10.91-9.45c.55-.48.65-1.31.23-1.91-1.08-1.54-2.47-3.35-4.11-5.37-1.64-2.02-3.6-4.28-5.8-6.7-2.13-2.43-4.13-4.59-5.93-6.43-1.8-1.83-3.5-3.43-5.06-4.75-.53-.45-1.31-.43-1.82.04l-10.51,9.67c-.29.27-.46.65-.46,1.05s.17.78.46,1.05c.96.88,2.1,2.06,3.39,3.49,1.33,1.47,2.71,3.04,4.15,4.7,1.44,1.66,2.87,3.36,4.26,5.04,1.41,1.71,2.71,3.27,3.89,4.68,1.17,1.39,2.11,2.54,2.83,3.44.86,1.09,1.04,1.35,1.04,1.35.02.03.03.06.05.09h-23.14c-.77,0-1.39.64-1.39,1.42v14.45c0,.78.62,1.42,1.39,1.42,10.73.14,21.47.29,32.2.43-3.44,3.44-6.64,6.34-9.41,8.73-4.74,4.08-8.53,6.9-9.53,7.64-5.15,3.8-7.72,5.7-9.46,6.47,0,0-1.99,2.06-9.47,6.03-.37.2-.63.55-.72.96-.09.41.01.84.27,1.18,3.31,4.84,6.62,9.68,9.93,14.51,2.17-1.39,5.05-3.3,8.34-5.71,1.13-.83,4.24-3.11,8.34-6.5,6.58-5.43,8.21-7.48,15.62-13.59,1.43-1.18,2.61-2.13,3.36-2.73v32.48c0,.78.62,1.42,1.39,1.42h15.19c.77,0,1.39-.64,1.39-1.42v-32.91c2.78,2.65,6.21,5.83,10.21,9.33,10.52,9.2,9.2,6.82,12.78,10.6,0,0,7.52,6.23,12.33,9.29.65.41,1.5.21,1.91-.45l8.68-14c.21-.34.27-.75.17-1.13Z"/>
|
||||
<g>
|
||||
<path class="cls-2" d="M481.65,98.3h-43.96c-.78,0-1.42.63-1.42,1.42v51.72c0,.78.63,1.42,1.42,1.42h43.96c.78,0,1.42-.63,1.42-1.42v-51.72c0-.78-.63-1.42-1.42-1.42ZM467.34,137.4h-15.33v-4.1h15.33v4.1ZM467.34,118.08h-15.33v-4.1h15.33v4.1Z"/>
|
||||
<path class="cls-2" d="M485.91,80.91h-8.67v-6.03h6.54c.78,0,1.42-.63,1.42-1.42v-13.3c0-.78-.63-1.42-1.42-1.42h-6.54v-9.15c0-.78-.63-1.42-1.42-1.42h-12.56c-.78,0-1.42.63-1.42,1.42v9.15h-4.46v-9.15c0-.78-.63-1.42-1.42-1.42h-12.45c-.78,0-1.42.63-1.42,1.42v9.15h-5.54c-.56,0-1.04.32-1.27.79v-5.86c0-.78-.63-1.42-1.42-1.42h-48.55c-.78,0-1.42.63-1.42,1.42v12.96c0,.78.63,1.42,1.42,1.42h11.48v5.24h-9.91c-.78,0-1.42.63-1.42,1.42v76.27c0,.78.63,1.42,1.42,1.42h46.2c.78,0,1.42-.63,1.42-1.42v-54.75c.26.29.63.46,1.05.46h50.35c.78,0,1.42-.63,1.42-1.42v-12.96c0-.78-.63-1.42-1.42-1.42ZM421.7,136.6h-23.18v-3.98h23.18v3.98ZM421.7,117.28h-19.16c1.54-2.24,2.74-4.23,3.57-5.91.83-1.69,1.57-3.33,2.19-4.91,1.19-3.22,1.77-7.95,1.77-14.47v-3.02h.08v13.36c0,3.98.93,7.04,2.78,9.08,1.84,2.04,4.77,3.29,8.61,3.69l.17.03v2.16ZM442.11,80.91h-6.54c-.42,0-.79.18-1.05.46v-6.66c0-.78-.63-1.42-1.42-1.42h-9.57v-5.24h10.36c.56,0,1.04-.32,1.27-.79v6.2c0,.78.63,1.42,1.42,1.42h5.54v6.03ZM461.85,80.91h-4.46v-6.03h4.46v6.03Z"/>
|
||||
</g>
|
||||
<path class="cls-2" d="M608.47,127.84c-4.56-1.17-9.11-2.33-13.67-3.5-.16-20.74-.33-41.47-.49-62.21,0-.79-.63-1.43-1.42-1.43h-34.31v-10.58c0-.79-.63-1.43-1.42-1.43h-14.6c-.78,0-1.42.64-1.42,1.43v10.58h-32.96c-.78,0-1.42.64-1.42,1.43v66c0,.79.63,1.43,1.42,1.43h32.96v6.12c0,3.15.28,5.89.83,8.12.57,2.35,1.57,4.34,2.95,5.92,1.39,1.59,3.28,2.81,5.6,3.61,2.2.76,4.97,1.27,8.25,1.51,1.43.07,3.35.15,5.76.23,2.44.08,4.95.11,7.46.11s5-.02,7.33-.06c2.4-.04,4.24-.14,5.62-.29,3.19-.31,5.93-.72,8.16-1.23,3.05-.7,5.13-2.13,5.99-2.65,1.51-.92,3.64-2.22,5.55-4.66,1.81-2.3,2.48-4.4,3.28-6.89.81-2.52,1.79-5.71,1.06-9.61-.15-.82-.35-1.48-.5-1.94ZM541.15,112.97h-17.16v-9.73h17.16v9.73ZM541.15,87.12h-17.16v-9.84h17.16v9.84ZM558.59,77.28h18.51v9.84h-18.51v-9.84ZM558.59,103.24h18.51v9.73h-18.51v-9.73ZM590.3,133.5c-.06.19-.43,1.31-.83,1.94-1.12,1.76-3.83,1.83-12.61,1.89-7.06.05-.21-.03-7.33-.06-5.89-.02-7.51.05-8.56-1.22-1.02-1.24-1.31-3.54-1.44-4.56-.1-.8-.12-1.48-.11-1.95,10.48.04,20.96.08,31.44.12.02.9-.05,2.27-.56,3.83Z"/>
|
||||
<path class="cls-2" d="M721.16,93.62h-39.92v-.2c6.24-3.1,12.4-6.63,18.31-10.49,6.14-4.01,12.31-8.35,18.34-12.9.35-.27.56-.69.56-1.13v-14.44c0-.78-.63-1.42-1.42-1.42h-87.02c-.78,0-1.42.63-1.42,1.42v14.32c0,.78.63,1.42,1.42,1.42h54.17c-.81.75-2.1,1.91-3.75,3.22-3.49,2.77-5.95,4.13-9.75,6.58-1.85,1.19-4.54,2.98-7.75,5.33-.03,2.76-.06,5.52-.1,8.29h-41.41c-.78,0-1.41.63-1.41,1.42v15c0,.78.63,1.42,1.41,1.42h41.41v21.77c0,1.22-.04,2.25-.11,3.05-.05.57-.06,1.11-.42,1.34-.35.22-.82.04-1.08-.04-1.08-.36-2.28-.11-3.42-.17-2.27-.12-2.51.11-5,.04-2.39-.07-4.79.16-7.17-.08-.27-.03-.93-.1-1.29.29-.44.48-.17,1.38-.04,1.75,1.43,4.35,2.86,8.69,4.29,13.04.11.5.34,1.22.92,1.83,1.16,1.24,2.98,1.26,4.12,1.25,9.2-.06,11.21-.21,11.21-.21,4.83-.36,5.78-.39,7.58-.96,1.76-.56,3.69-1.19,5.38-2.95,1.68-1.74,2.36-3.6,2.76-5.45.44-2.03.66-4.52.66-7.4v-27.11h39.92c.78,0,1.41-.63,1.41-1.42v-15c0-.78-.63-1.42-1.41-1.42Z"/>
|
||||
<path class="cls-2" d="M839.47,131.72h-40.52v-59.68h33.89c.78,0,1.42-.63,1.42-1.42v-15.57c0-.78-.63-1.42-1.42-1.42h-86.74c-.78,0-1.42.63-1.42,1.42v15.57c0,.78.63,1.42,1.42,1.42h33.55v59.68h-40.41c-.78,0-1.42.63-1.42,1.42v15.35c0,.78.63,1.42,1.42,1.42h100.22c.78,0,1.42-.63,1.42-1.42v-15.35c0-.78-.63-1.42-1.42-1.42Z"/>
|
||||
<path class="cls-2" d="M955.36,61.13h-39.83c.46-1.11.9-2.22,1.3-3.32.65-1.74,1.31-3.52,2-5.34.14-.37.12-.79-.06-1.14-.18-.35-.5-.62-.88-.72l-14.29-3.98c-.73-.2-1.49.2-1.73.93-2.48,7.62-5.66,15.09-9.45,22.22-3,5.64-6.11,10.87-9.28,15.62v-12.58c1.19-3.03,2.37-6.23,3.52-9.52,1.16-3.3,2.38-6.9,3.73-10.99.12-.36.09-.76-.09-1.1-.18-.34-.48-.59-.85-.7l-13.84-4.21c-.36-.11-.76-.07-1.09.11-.33.18-.58.49-.68.86-1.13,4.04-2.62,8.43-4.42,13.05-1.81,4.65-3.82,9.34-5.97,13.95-2.16,4.62-4.45,9.15-6.82,13.44-2.37,4.3-4.71,8.14-6.96,11.42-.42.61-.3,1.43.27,1.9l11.21,9.21c.31.26.72.37,1.12.31.4-.06.75-.29.97-.63l1.97-3.03v46.25c0,.78.63,1.42,1.42,1.42h15.09c.78,0,1.42-.63,1.42-1.42v-57.07l8.02,6.03c.62.47,1.5.35,1.98-.27,2.9-3.8,5.62-7.74,8.08-11.71,2.03-3.29,3.95-6.68,5.73-10.12v73.83c0,.78.63,1.42,1.42,1.42h14.98c.78,0,1.42-.63,1.42-1.42v-20.29h27.52c.78,0,1.42-.63,1.42-1.42v-15.12c0-.78-.63-1.42-1.42-1.42h-27.52v-10.01h25.11c.78,0,1.42-.63,1.42-1.42v-14.89c0-.78-.63-1.42-1.42-1.42h-25.11v-9.21h30.6c.78,0,1.42-.63,1.42-1.42v-14.66c0-.78-.63-1.42-1.42-1.42Z"/>
|
||||
<path class="cls-2" d="M1074.36,137.97h-41.39v-4.55h31.04c.78,0,1.42-.63,1.42-1.42v-13.19c0-.78-.63-1.42-1.42-1.42h-2.25l8.41-9.22c.49-.53.5-1.35.02-1.89-2.83-3.22-6.98-7.17-12.38-11.75l-3.96-3.29h9.69c.78,0,1.42-.63,1.42-1.42v-10.52h8.59c.78,0,1.42-.63,1.42-1.42v-19.1c0-.78-.63-1.42-1.42-1.42h-39.96l-2.28-8.83c-.17-.67-.81-1.12-1.49-1.06l-16.17,1.36c-.42.04-.8.25-1.04.59-.24.34-.32.77-.21,1.18.61,2.31,1.17,4.58,1.68,6.75h-39.86c-.78,0-1.42.63-1.42,1.42v19.1c0,.78.63,1.42,1.42,1.42h8.47v10.52c0,.78.63,1.42,1.42,1.42h11.51c-1.07.86-2.12,1.69-3.13,2.46-1.91,1.47-3.64,2.66-5.15,3.54-1.12.66-2.24,1.28-3.31,1.84-.94.49-1.91.8-2.9.93-.38.05-.71.25-.94.55-.23.3-.33.68-.28,1.06l1.63,11.71c.1.69.68,1.21,1.38,1.22,5.55.08,11.18.06,16.74-.06,5.06-.1,10.15-.22,15.25-.36v3.26h-31.39c-.78,0-1.42.63-1.42,1.42v13.19c0,.78.63,1.42,1.42,1.42h31.39v4.55h-41.86c-.78,0-1.42.63-1.42,1.42v12.62c0,.78.63,1.42,1.42,1.42h101.32c.78,0,1.42-.63,1.42-1.42v-12.62c0-.78-.63-1.42-1.42-1.42ZM1055.75,115.62c.57.62,1.11,1.21,1.64,1.77h-24.42v-3.81c3.26-.13,6.47-.27,9.64-.4,3.37-.14,6.82-.32,10.27-.53,1.04,1.03,2.01,2.03,2.87,2.97ZM990.4,76.13v-3.3h66.73v3.3h-66.73ZM1009.78,99.75c1.14-.79,2.29-1.62,3.43-2.48,2.38-1.78,4.82-3.8,7.26-6.03h15.11l-2.47,2.47c-.29.29-.44.7-.41,1.11s.24.79.58,1.03c.92.68,1.91,1.41,2.95,2.2.21.16.43.33.66.51-5.44.39-10.57.68-15.27.87-4.02.16-7.98.26-11.83.31Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="cls-2" points="700.41 193.09 700.29 193.09 695.84 175.52 688.42 175.52 688.42 199.6 692.65 199.6 692.65 179.03 692.76 178.9 698.35 199.6 702.12 199.6 708.05 178.9 708.05 179.03 708.05 199.6 712.28 199.6 712.28 175.52 705.2 175.52 700.41 193.09"/>
|
||||
<path class="cls-2" d="M727.36,178.51c3.04.09,4.65,1.61,4.83,4.56h5.64c-.36-5.47-3.85-8.24-10.47-8.33-6.62.26-10.07,4.47-10.33,12.63.18,7.98,3.62,12.06,10.33,12.23,6.71-.09,10.2-2.78,10.47-8.07h-5.64c-.09,2.95-1.7,4.47-4.83,4.56-2.95-.17-4.52-3.08-4.7-8.72.18-5.73,1.75-8.68,4.7-8.85Z"/>
|
||||
<path class="cls-2" d="M756.74,188.14c.08,2.86-.24,4.82-.98,5.86-.73,1.22-2.03,1.82-3.9,1.82s-3.21-.61-4.02-1.82c-.73-1.04-1.06-2.99-.97-5.86v-13.15h-4.87v15.1c.16,5.99,3.45,9.07,9.87,9.24,6.25-.17,9.5-3.25,9.75-9.24v-15.1h-4.87v13.15Z"/>
|
||||
<polygon class="cls-2" points="782.14 188.79 792.21 188.79 792.21 184.76 782.14 184.76 782.14 179.16 792.98 179.16 792.98 175.13 776.98 175.13 776.98 199.2 793.37 199.2 793.37 195.17 782.14 195.17 782.14 188.79"/>
|
||||
<rect class="cls-2" x="797.64" y="174.74" width="4.33" height="24.08"/>
|
||||
<path class="cls-2" d="M822.27,188.31c-.09-1.04-.4-2.04-.94-2.99-1.34-2.6-3.8-3.9-7.38-3.9-5.19.26-7.92,3.21-8.19,8.85-.09,5.81,2.64,8.68,8.19,8.59,4.83,0,7.47-1.73,7.92-5.21h-4.7c-.45,1.48-1.52,2.17-3.22,2.08-2.06.09-3.04-1.3-2.95-4.17h11.41c0-1.13-.05-2.21-.13-3.25ZM810.99,188.18c.09-2.34,1.07-3.51,2.95-3.51,2.06,0,3.09,1.17,3.09,3.51h-6.04Z"/>
|
||||
<path class="cls-2" d="M831.26,189.46c.28-3.34,1.07-5.01,2.37-5.01,1.67,0,2.6,1.08,2.79,3.25h5.3c-.19-4.24-2.84-6.45-7.96-6.63-4.93.36-7.63,3.43-8.1,9.2.37,5.78,3.07,8.75,8.1,8.93,5.02,0,7.68-2.21,7.96-6.63h-5.3c-.09,2.26-.98,3.38-2.65,3.38-1.49,0-2.33-1.8-2.51-5.41v-1.08Z"/>
|
||||
<path class="cls-2" d="M927.16,189.69c.28-3.34,1.07-5.01,2.37-5.01,1.67,0,2.6,1.08,2.79,3.25h5.3c-.19-4.24-2.84-6.45-7.96-6.63-4.93.36-7.63,3.43-8.1,9.2.37,5.78,3.07,8.75,8.1,8.93,5.02,0,7.68-2.21,7.96-6.63h-5.3c-.09,2.26-.98,3.38-2.65,3.38-1.49,0-2.33-1.8-2.51-5.41v-1.08Z"/>
|
||||
<path class="cls-2" d="M854.41,195.73c-1.36.26-1.97-.65-1.8-2.73v-7.81h3.37v-3.38h-3.37v-5.08c-1.56,0-3.12,0-4.69,0,0,1.69,0,3.39,0,5.08h-3.13v3.38h3.13c0,3.23-.02,6.45-.03,9.68.03.49.17,2.15,1.47,3.24.82.7,1.73.84,2.75,1,.82.13,2.18.24,3.88-.17,0-1.11,0-2.23-.01-3.34-.48.09-1,.13-1.56.13Z"/>
|
||||
<path class="cls-2" d="M999.9,195.61c-1.36.26-1.97-.65-1.8-2.73v-7.81h3.37v-3.38h-3.37v-5.08c-1.56,0-3.12,0-4.69,0,0,1.69,0,3.39,0,5.08h-3.13v3.38h3.13l-.03,9.68c.03.49.17,2.15,1.47,3.24.82.7,1.73.84,2.75,1,.82.13,2.18.24,3.88-.17v-3.34c-.5.09-1.02.13-1.58.13Z"/>
|
||||
<path class="cls-2" d="M863.85,184.64h-.13v-3.25h-4.7c0,.54.04,1.31.13,2.3v15.17h5.1v-9.21c.18-1.08.4-1.94.67-2.57.45-.54,1.3-.95,2.55-1.22h2.28v-4.6c-2.95-.18-4.92.95-5.91,3.39Z"/>
|
||||
<path class="cls-2" d="M881.19,181.41c-5.4.26-8.23,3.21-8.49,8.85.25,5.55,3.08,8.42,8.49,8.59,5.4-.17,8.23-3.04,8.49-8.59-.25-5.64-3.08-8.59-8.49-8.85ZM881.19,195.73c-2.28,0-3.42-1.82-3.42-5.47s1.14-5.6,3.42-5.6c2.45,0,3.63,1.87,3.55,5.6,0,3.64-1.18,5.47-3.55,5.47Z"/>
|
||||
<path class="cls-2" d="M908.77,185.85c-.08-.78-.14-1.32-.38-1.9-.5-1.26-1.5-1.96-1.82-2.16-1.45-.93-2.93-.77-3.33-.71-.79-.01-2.53.08-4.12,1.26-.46.34-.82.71-1.1,1.06,0-.67,0-1.34-.01-2h-5.1v17.48h5.1v-10.43c.27-2.53,1.3-3.88,3.09-4.07,2.06,0,3.09,1.36,3.09,4.07v10.43c1.56,0,3.12,0,4.68.01,0-3.79-.02-7.57-.03-11.36.01-.42,0-.99-.07-1.67Z"/>
|
||||
<rect class="cls-2" x="913.06" y="174.68" width="4.81" height="4.29"/>
|
||||
<rect class="cls-2" x="913.18" y="181.97" width="4.58" height="16.79"/>
|
||||
<path class="cls-2" d="M950.52,188.05c-2.08-.52-3.12-1.13-3.12-1.82,0-1.04.62-1.56,1.87-1.56,1.33.09,2.04.65,2.12,1.69h4.37c-.25-3.3-2.41-4.95-6.49-4.95-4.41.35-6.7,2-6.86,4.95-.17,2.86,1.79,4.69,5.86,5.47,2.08.44,3.16,1.13,3.24,2.08,0,1.22-.75,1.82-2.24,1.82-1.58-.09-2.45-.74-2.62-1.95h-4.49c.17,3.3,2.54,4.99,7.11,5.08,4.57-.35,6.99-2.08,7.24-5.21-.67-3.47-2.66-5.34-5.99-5.6Z"/>
|
||||
<path class="cls-2" d="M980.42,184.99c-.32-.09-.51-.13-.59-.13-2.84-.43-4.18-1.56-4.02-3.38.08-1.91,1.26-2.95,3.55-3.12,2.29,0,3.47,1.22,3.55,3.64h4.5c-.24-4.69-2.72-7.16-7.45-7.42-5.84.35-8.87,2.99-9.11,7.94,0,3.21,2.4,5.42,7.22,6.64.16.09.43.17.83.26,2.76.61,4.14,1.74,4.14,3.38-.08,2-1.54,3.04-4.38,3.12-2.45-.17-3.67-1.65-3.67-4.43h-4.73c-.08,5.29,2.72,7.94,8.4,7.94,6.15-.17,9.26-2.78,9.34-7.81.08-3.3-2.45-5.51-7.57-6.64Z"/>
|
||||
<path class="cls-2" d="M1020.78,181.81h-3.98c0,3.43.01,6.87.02,10.3,0,.19-.07,2.13-1.58,3.1-.96.62-2.02.54-2.38.52-.37-.03-1.06-.05-1.65-.47-.81-.57-1.24-1.68-1.29-3.31v-10.15h-4.23c-.01,3.67-.03,7.34-.04,11.01-.01.42.01,1.04.21,1.75.62,2.23,2.61,4.11,4.98,4.62,2.07.45,3.82-.28,4.27-.48.4-.17.72-.36.95-.5,0,.34,0,.68.01,1.02,1.58-.01,3.15-.03,4.73-.04,0-1.2-.01-2.39-.02-3.59v-13.8Z"/>
|
||||
<path class="cls-2" d="M1042.61,174.52h-4.95v9.24c-1.1-1.47-2.58-2.26-4.44-2.34-4.74.26-7.27,3.21-7.61,8.85.34,5.55,2.71,8.42,7.1,8.59,2.37,0,4.01-.87,4.95-2.6,0,.26.04.65.13,1.17v1.17h4.95c-.09-1.04-.13-2.17-.13-3.38v-20.69ZM1034.24,195.73c-2.45,0-3.64-1.82-3.55-5.47-.09-3.73,1.1-5.6,3.55-5.6,2.11.17,3.25,2.04,3.42,5.6-.17,3.47-1.31,5.29-3.42,5.47Z"/>
|
||||
<rect class="cls-2" x="1046.8" y="174.52" width="4.96" height="4.29"/>
|
||||
<rect class="cls-2" x="1046.92" y="181.81" width="4.71" height="16.79"/>
|
||||
<path class="cls-2" d="M1063.98,181.41c-5.35.26-8.14,3.21-8.39,8.85.25,5.55,3.05,8.42,8.39,8.59,5.34-.17,8.14-3.04,8.39-8.59-.25-5.64-3.05-8.59-8.39-8.85ZM1063.98,195.73c-2.25,0-3.38-1.82-3.38-5.47s1.13-5.6,3.38-5.6c2.42,0,3.59,1.87,3.51,5.6,0,3.64-1.17,5.47-3.51,5.47Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -19,3 +19,4 @@
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check Returns, OpenAI Responses API Arrives, and OpenClaw / WebDAV Get a Major Upgrade
|
||||
|
||||
**[中文版 →](v3.12.0-zh.md) | [日本語版 →](v3.12.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.0 is a feature release focused on provider compatibility, OpenClaw editing, Common Config usability, and sync/data reliability. It restores the **Model Health Check (Stream Check)** UI with improved stability, adds **OpenAI Responses API** format conversion, expands provider presets for **Ucloud**, **Micu**, **X-Code API**, **Novita**, and **Bailian For Coding**, and upgrades **WebDAV sync** with dual-layer versioning.
|
||||
|
||||
**Release Date**: 2026-03-09
|
||||
|
||||
**Update Scale**: 56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Stream Check returns**: Restored the model health check UI, added first-run confirmation, and fixed `openai_chat` provider support
|
||||
- **OpenAI Responses API**: Added `api_format = "openai_responses"` with bidirectional conversion and shared conversion cleanup — simply select the Responses API format when adding a provider and enable proxy takeover, and you can use GPT-series models in Claude Code!
|
||||
- **OpenClaw overhaul**: Introduced JSON5 round-trip config editing, a config health banner, better agent model selection, and a User-Agent toggle
|
||||
- **Preset expansion**: Added Ucloud, Micu, X-Code API, Novita, and Bailian For Coding updates, plus SiliconFlow partner badge and model-role badges
|
||||
- **Sync and maintenance improvements**: Added WebDAV protocol v2 + db-v6 versioning, daily rollups, incremental auto-vacuum, and sync-aware backup
|
||||
- **Common Config usability improvements**: After updating a Common Config Snippet, it is now automatically applied when switching providers — no more manual checkbox needed
|
||||
|
||||
---
|
||||
|
||||
## Main Features
|
||||
|
||||
### Model Health Check (Stream Check)
|
||||
|
||||
Restored the Stream Check panel for live provider validation, improving the reliability of provider management.
|
||||
|
||||
- Restored Stream Check UI panel with single and batch provider availability testing
|
||||
- Added first-run confirmation dialog to prevent unsupported providers from showing misleading errors
|
||||
- Fixed detection compatibility for `openai_chat` API format providers
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
Added native support for providers using the OpenAI Responses API with a new `openai_responses` API format.
|
||||
|
||||
- New `api_format = "openai_responses"` provider format option
|
||||
- Bidirectional Anthropic Messages <-> OpenAI Responses API format conversion
|
||||
- Consolidated shared conversion logic to reduce code duplication
|
||||
|
||||
### Bedrock Request Optimizer
|
||||
|
||||
Added a PRE-SEND phase request optimizer for AWS Bedrock providers to improve compatibility and performance.
|
||||
|
||||
- PRE-SEND thinking + cache injection optimizer (#1301, thanks @keithyt06)
|
||||
|
||||
### OpenClaw Config Enhancements
|
||||
|
||||
Comprehensive upgrade to the OpenClaw configuration editing experience with richer management capabilities.
|
||||
|
||||
- JSON5 round-trip write-back: preserves comments and formatting when editing configs
|
||||
- EnvPanel JSON editing mode and `tools.profile` selection support
|
||||
- New config validation warnings and config health status checks
|
||||
- Improved agent model dropdown with recommended model fill from provider presets
|
||||
- User-Agent toggle: optionally append OpenClaw identifier to requests (defaults to off)
|
||||
- Legacy timeout configuration auto-migration
|
||||
|
||||
### Provider Presets
|
||||
|
||||
New and expanded provider presets covering more providers and use cases.
|
||||
|
||||
- **Ucloud**: Added `endpointCandidates` and OpenClaw defaults, refreshed `templateValues` / `suggestedDefaults`
|
||||
- **Micu**: Added preset defaults and OpenClaw recommended models
|
||||
- **X-Code API**: Added Claude presets and `endpointCandidates`
|
||||
- **Novita**: New provider preset (#1192, thanks @Alex-wuhu)
|
||||
- **Bailian For Coding**: New provider preset (#1263, thanks @suki135246)
|
||||
- **SiliconFlow**: Added partner badge
|
||||
- **Model Role Badges**: Provider presets now support model-role badge display
|
||||
|
||||
### WebDAV Sync Enhancements
|
||||
|
||||
WebDAV sync introduces dual-layer versioning for improved sync reliability and data safety.
|
||||
|
||||
- New WebDAV protocol v2 + db-v6 dual-layer versioning
|
||||
- Confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes
|
||||
- Sync-aware backup: uses a sync-specific backup variant that skips local-only table data
|
||||
|
||||
### Usage & Data
|
||||
|
||||
Enhanced usage statistics and data maintenance capabilities for finer-grained data management, significantly reducing database growth rate.
|
||||
|
||||
- Daily rollups: aggregate usage data by day to reduce storage overhead
|
||||
- Auto-vacuum: incremental database cleanup to maintain database health
|
||||
- UsageFooter extra statistics fields (#1137, thanks @bugparty)
|
||||
|
||||
### Other New Features
|
||||
|
||||
- **Session Deletion**: Per-provider session cleanup with path safety validation
|
||||
- **Claude Auth Field Selector**: Restored authentication field selector
|
||||
- **Failover Toggle on Main Page**: Moved the failover toggle to display independently on the main page with a first-use confirmation dialog
|
||||
- **Common Config Auto-Extract**: On first run, automatically extracts common config snippets from live config files
|
||||
- **New Provider Page Improvements**: Improved new provider page experience (#1155, thanks @wugeer)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Improvements
|
||||
|
||||
### Common Config Runtime Overlay
|
||||
|
||||
Common Config Snippets are now applied as a runtime overlay instead of being materialized into stored provider configs.
|
||||
|
||||
**Before**: Common Config content was merged directly into each provider's `settings_config` on save or switch. This caused shared configuration to be duplicated across every provider entry, requiring manual sync when changes were needed.
|
||||
|
||||
**After**: Common Config is only injected as a runtime overlay when switching providers and writing to the live file — provider entries themselves no longer contain shared configuration. This means modifying Common Config takes effect immediately without updating each provider individually.
|
||||
|
||||
### Common Config Auto-Extract
|
||||
|
||||
On first run, if no Common Config Snippet exists in the database, one is automatically extracted from the current live config. This ensures users upgrading from older versions do not lose their existing shared configuration settings.
|
||||
|
||||
### Periodic Maintenance Timer Consolidation
|
||||
|
||||
Consolidated daily rollups and auto-vacuum into a unified periodic maintenance timer, eliminating resource contention and complexity from multiple independent timers.
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Proxy & Streaming
|
||||
|
||||
- Fixed OpenAI ChatCompletion -> Anthropic Messages streaming conversion
|
||||
- Added Codex `/responses/compact` route support (#1194, thanks @Tsukumi233)
|
||||
- Improved TOML config merge logic to prevent key-value loss
|
||||
- Improved proxy forwarder failure logs with additional diagnostic information
|
||||
|
||||
### Provider & Preset Fixes
|
||||
|
||||
- Renamed X-Code to X-Code API for consistent branding
|
||||
- Fixed SSSAiCode `/v1` path issue
|
||||
- Removed incorrect `www` prefix from AICoding URLs
|
||||
- Fixed new provider page line-break deletion issue (#1155, thanks @wugeer)
|
||||
|
||||
### Platform Fixes
|
||||
|
||||
- Fixed cache hit token statistics not being reported (#1244, thanks @a1398394385)
|
||||
- Fixed minimize-to-tray causing auto exit after some time (#1245, thanks @YewFence)
|
||||
|
||||
### i18n Fixes
|
||||
|
||||
- Added 69 missing translation keys and removed remaining hardcoded Chinese strings
|
||||
- Fixed model test panel i18n issues
|
||||
- Normalized JSON5 slash escaping to prevent i18n string parsing errors
|
||||
|
||||
### UI Fixes
|
||||
|
||||
- Fixed Skills count display (#1295, thanks @fzzv)
|
||||
- Removed HTTP status code display from endpoint speed test to reduce visual noise
|
||||
- Fixed outline button styling (#1222, thanks @Sube-py)
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
- Skip unnecessary OpenClaw config writes when config is unchanged, reducing disk I/O
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
- Restructured the user manual for i18n and added complete EN/JA coverage
|
||||
- Added OpenClaw usage documentation and completed settings documentation
|
||||
- Added UCloud sponsor information
|
||||
- Reorganized the docs directory and synced README feature sections across EN/ZH/JA
|
||||
|
||||
---
|
||||
|
||||
## Notes & Considerations
|
||||
|
||||
- **Common Config now uses runtime overlay**: Common Config Snippets are no longer materialized into each provider's stored config. They are dynamically applied at switch time. Modifying Common Config takes effect immediately without updating each provider.
|
||||
- **Stream Check requires first-use confirmation**: A confirmation dialog appears when using the model health check for the first time. Testing proceeds only after confirmation.
|
||||
- **OpenClaw User-Agent toggle defaults to off**: The User-Agent identifier must be manually enabled in the OpenClaw configuration.
|
||||
|
||||
---
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Thanks to all contributors for their contributions to this release!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check が復活し、OpenAI Responses API に対応、OpenClaw と WebDAV も大幅強化
|
||||
|
||||
**[中文版 →](v3.12.0-zh.md) | [English →](v3.12.0-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.0 は、プロバイダー互換性、OpenClaw の設定編集、共通設定の使い勝手、同期とデータ保守性を強化する機能リリースです。安定性を強化した **Model Health Check (Stream Check)** UI を復元し、**OpenAI Responses API** 形式変換を追加、**Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** などのプリセットを拡張し、**WebDAV 同期** に二層バージョニングを導入しました。
|
||||
|
||||
**リリース日**: 2026-03-09
|
||||
|
||||
**更新規模**: 56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **Stream Check 復活**: モデルヘルスチェック UI を復元し、初回確認ダイアログを追加、`openai_chat` プロバイダー対応も修正
|
||||
- **OpenAI Responses API**: `api_format = "openai_responses"` を追加し、双方向変換と共有変換ロジックの整理を実施 — プロバイダー追加時に Responses API フォーマットを選択してプロキシテイクオーバーを有効にするだけで、Claude Code で GPT シリーズモデルが使えます!
|
||||
- **OpenClaw パネル強化**: JSON5 round-trip 編集、設定ヘルスバナー、改良された Agent Model 選択、User-Agent トグルを導入
|
||||
- **プリセット拡張**: Ucloud、Micu、X-Code API、Novita、Bailian For Coding を追加・更新し、SiliconFlow partner badge とモデルロールバッジも追加
|
||||
- **同期と保守の改善**: WebDAV protocol v2 + db-v6、daily rollups、incremental auto-vacuum、sync-aware backup を追加
|
||||
- **共通設定の使い勝手向上**: 共通設定スニペットを更新すると、プロバイダー切り替え時に自動的に反映されるようになりました。手動でチェックを入れ直す必要はありません
|
||||
|
||||
---
|
||||
|
||||
## 主な機能
|
||||
|
||||
### モデルヘルスチェック (Stream Check)
|
||||
|
||||
Stream Check パネルを復元し、プロバイダーの可用性をリアルタイムで検証できるようにしました。
|
||||
|
||||
- Stream Check UI パネルを復元し、単一またはバッチでのプロバイダー可用性検出をサポート
|
||||
- 初回使用確認ダイアログを追加、ヘルスチェック非対応プロバイダーの誤検出によるユーザー混乱を防止
|
||||
- `openai_chat` API フォーマットプロバイダーの検出互換性を修正
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
新しい `openai_responses` API フォーマットを追加し、OpenAI Responses API を使用するプロバイダーのネイティブサポートを提供します。
|
||||
|
||||
- `api_format = "openai_responses"` プロバイダーフォーマットオプションを追加
|
||||
- Anthropic Messages <-> OpenAI Responses API の双方向フォーマット変換をサポート
|
||||
- 共有変換ロジックを整理し、重複コードを削減
|
||||
|
||||
### Bedrock リクエストオプティマイザー
|
||||
|
||||
AWS Bedrock プロバイダー向けに PRE-SEND フェーズのリクエスト最適化を追加し、互換性とパフォーマンスを向上させました。
|
||||
|
||||
- PRE-SEND thinking + cache injection オプティマイザー(#1301、@keithyt06 に感謝)
|
||||
|
||||
### OpenClaw 設定強化
|
||||
|
||||
OpenClaw の設定編集体験を全面的にアップグレードし、より豊富な設定管理をサポートします。
|
||||
|
||||
- JSON5 round-trip 書き戻し: 編集時にコメントとフォーマットを保持
|
||||
- EnvPanel の JSON 編集モードと `tools.profile` 選択をサポート
|
||||
- 設定検証バナーと設定ヘルスステータスチェックを追加
|
||||
- Agent モデルのドロップダウン改善、プロバイダープリセットから推奨モデルを自動入力
|
||||
- User-Agent トグル: リクエストに OpenClaw 識別子を付加する機能(デフォルトオフ)
|
||||
- Legacy timeout 設定の自動マイグレーション
|
||||
|
||||
### プロバイダープリセット
|
||||
|
||||
新規および既存のプロバイダープリセットを拡張し、より多くのプロバイダーとユースケースをカバーします。
|
||||
|
||||
- **Ucloud**: `endpointCandidates` および OpenClaw デフォルト値を追加、`templateValues` / `suggestedDefaults` を更新
|
||||
- **Micu**: プリセットデフォルト値および OpenClaw 推奨モデルを追加
|
||||
- **X-Code API**: Claude プリセットおよび `endpointCandidates` を追加
|
||||
- **Novita**: プロバイダープリセットを追加(#1192、@Alex-wuhu に感謝)
|
||||
- **Bailian For Coding**: プロバイダープリセットを追加(#1263、@suki135246 に感謝)
|
||||
- **SiliconFlow**: partner badge 識別を追加
|
||||
- **モデルロールバッジ**: プロバイダープリセットでモデルロール badge 表示をサポート
|
||||
|
||||
### WebDAV 同期強化
|
||||
|
||||
WebDAV 同期に二層バージョン管理を導入し、同期の信頼性とデータ安全性を向上させました。
|
||||
|
||||
- WebDAV protocol v2 + db-v6 二層バージョン管理を追加
|
||||
- WebDAV auto-sync の切り替え時に確認ダイアログを表示し、誤操作を防止
|
||||
- sync-aware backup: 同期時にローカル専用テーブルを除外した sync バリアントバックアップを使用
|
||||
|
||||
### 使用量とデータ
|
||||
|
||||
使用量統計とデータ保守機能を強化し、より精密なデータ管理を実現、データベースの増加速度を大幅に抑制します。
|
||||
|
||||
- Daily rollups: 日次で使用量データを集計し、ストレージ使用量を削減
|
||||
- Auto-vacuum: インクリメンタルなデータベースクリーンアップ、データベースの健全性を維持
|
||||
- UsageFooter に追加統計フィールドを追加(#1137、@bugparty に感謝)
|
||||
|
||||
### その他の新機能
|
||||
|
||||
- **セッション削除**: プロバイダー単位のクリーンアップとパス安全性検証付きのセッション削除
|
||||
- **Claude auth field selector 復元**: 認証フィールドセレクターを復元
|
||||
- **Failover トグルをメインページへ移動**: failover toggle を設定パネルからメインページに独立表示し、初回確認ダイアログを追加
|
||||
- **共通設定の自動抽出**: 初回起動時に live config から共通設定スニペットを自動抽出
|
||||
- **新規プロバイダーページの改善**: 新規プロバイダーページの体験を最適化(#1155、@wugeer に感謝)
|
||||
|
||||
---
|
||||
|
||||
## アーキテクチャ改善
|
||||
|
||||
### Common Config ランタイムオーバーレイ
|
||||
|
||||
共通設定スニペット(Common Config Snippet)をランタイムオーバーレイ方式に変更し、保存済みプロバイダー設定への物理マージを廃止しました。
|
||||
|
||||
**変更前**: Common Config の内容は保存時または切り替え時に各プロバイダーの `settings_config` に直接マージされていました。これにより共通設定が各プロバイダーエントリーにコピーされ、変更時には一つずつ同期する必要がありました。
|
||||
|
||||
**変更後**: Common Config はプロバイダー切り替え時に live ファイルへ書き込む際のみ runtime overlay として注入され、プロバイダーエントリー自体には共通設定を含みません。つまり Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。
|
||||
|
||||
### Common Config 初回自動抽出
|
||||
|
||||
初回起動時にデータベースに Common Config Snippet がまだ存在しない場合、現在の live config から自動抽出します。これにより旧バージョンからアップグレードしたユーザーの既存の共通設定が失われないことを保証します。
|
||||
|
||||
### 定期メンテナンスタイマー統合
|
||||
|
||||
daily rollups と auto-vacuum を統一の定期メンテナンスタイマーに統合し、複数の独立タイマーによるリソース競合と複雑さを回避しました。
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### プロキシとストリーミング
|
||||
|
||||
- OpenAI ChatCompletion -> Anthropic Messages のストリーミング変換問題を修正
|
||||
- Codex `/responses/compact` ルーティングをサポート(#1194、@Tsukumi233 に感謝)
|
||||
- TOML 設定マージロジックを改善し、キー値の欠落を回避
|
||||
- proxy forwarder の失敗ログを改善し、診断情報を追加
|
||||
|
||||
### プロバイダーとプリセットの修正
|
||||
|
||||
- X-Code を X-Code API にリネームし、ブランド名称を統一
|
||||
- SSSAiCode の `/v1` パス問題を修正
|
||||
- AICoding URL の誤った `www` プレフィックスを削除
|
||||
- 新規プロバイダーページの改行削除問題を修正(#1155、@wugeer に感謝)
|
||||
|
||||
### プラットフォーム修正
|
||||
|
||||
- cache hit token の統計欠落を修正(#1244、@a1398394385 に感謝)
|
||||
- 最小化後しばらくすると自動終了する問題を修正(#1245、@YewFence に感謝)
|
||||
|
||||
### i18n 修正
|
||||
|
||||
- 69 個の欠落翻訳キーを補完し、残りのハードコード中国語を除去
|
||||
- model test panel の i18n 問題を修正
|
||||
- JSON5 slash escaping を正規化し、国際化文字列の解析異常を回避
|
||||
|
||||
### UI 修正
|
||||
|
||||
- Skills カウント表示の問題を修正(#1295、@fzzv に感謝)
|
||||
- endpoint speed test から HTTP ステータスコード表示を削除し、視覚的ノイズを軽減
|
||||
- outline button のスタイル問題を修正(#1222、@Sube-py に感謝)
|
||||
|
||||
---
|
||||
|
||||
## パフォーマンス
|
||||
|
||||
- OpenClaw 設定が未変更の場合に不要な書き込みをスキップし、ディスク I/O を削減
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
- ユーザーマニュアルを i18n 対応で再構成し、EN/JA の内容を拡充
|
||||
- OpenClaw の説明を追加し、設定ドキュメントを補完
|
||||
- UCloud スポンサー情報を追加
|
||||
- docs ディレクトリを再編成し、EN/ZH/JA の README 機能説明を同期
|
||||
|
||||
---
|
||||
|
||||
## 注意事項
|
||||
|
||||
- **Common Config はランタイムオーバーレイに変更**: 共通設定スニペットは各プロバイダー設定への物理マージではなく、切り替え時に動的にオーバーレイされます。Common Config の変更は即座に反映され、各プロバイダーを個別に更新する必要はありません。
|
||||
- **Stream Check は初回使用時に確認が必要**: 初回使用時にモデルヘルスチェックの確認ダイアログが表示され、確認後に使用可能になります。
|
||||
- **OpenClaw の User-Agent トグルはデフォルトオフ**: OpenClaw 設定で User-Agent 識別子の付加機能を手動で有効にする必要があります。
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------------- | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,238 @@
|
||||
# CC Switch v3.12.0
|
||||
|
||||
> Stream Check 回归,OpenAI Responses API 上线,OpenClaw 与 WebDAV 迎来一次大升级
|
||||
|
||||
**[English →](v3.12.0-en.md) | [日本語版 →](v3.12.0-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.0 是一个功能版本,重点提升供应商兼容性、OpenClaw 配置编辑体验、通用配置功能使用体验,以及同步与数据维护能力。本次恢复了增强稳定性后的 **模型健康检查(Stream Check)** UI,新增 **OpenAI Responses API** 格式转换,扩展了 **Ucloud**、**Micu**、**X-Code API**、**Novita**、**Bailian For Coding** 等供应商预设,并为 **WebDAV 同步** 引入双层版本控制。
|
||||
|
||||
**发布日期**:2026-03-09
|
||||
|
||||
**更新规模**:56 commits | 221 files changed | +20,582 / -8,026 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **Stream Check 回归**:恢复模型健康检查 UI,新增首次使用确认,并修复 `openai_chat` 供应商检测
|
||||
- **OpenAI Responses API**:新增 `api_format = "openai_responses"`,支持双向格式转换并整理共享转换逻辑,只需要在添加供应商的时候选择 Response 接口格式并开启代理接管,您就可以在 Claude Code 中使用 gpt 系列模型了!
|
||||
- **OpenClaw 面板升级**:引入 JSON5 round-trip 配置编辑、配置健康提示、改进后的 Agent Model 选择和 User-Agent 开关
|
||||
- **预设扩展**:补充 Ucloud、Micu、X-Code API、Novita、Bailian For Coding 预设,并新增 SiliconFlow partner badge 与模型角色标识
|
||||
- **同步与维护增强**:新增 WebDAV protocol v2 + db-v6 双层版本、daily rollups、增量 auto-vacuum 和 sync-aware backup
|
||||
- **通用配置功能使用体验优化**:现在通用配置片段更新之后,会在切换供应商时自动同步到新的供应商,不需要再手动勾选。
|
||||
|
||||
---
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 模型健康检查 Stream Check
|
||||
|
||||
恢复 Stream Check 面板,用于实时验证供应商可用性,增强供应商管理的可靠性。
|
||||
|
||||
- 恢复 Stream Check UI 面板,支持单个或批量检测供应商可用性
|
||||
- 新增首次使用确认对话框,避免不支持健康检查的供应商报错误导用户
|
||||
- 修复 `openai_chat` API 格式供应商的检测兼容性
|
||||
|
||||
### OpenAI Responses API
|
||||
|
||||
新增 `openai_responses` API 格式,为使用 OpenAI Responses API 的供应商提供原生支持。
|
||||
|
||||
- 新增 `api_format = "openai_responses"` 供应商格式选项
|
||||
- 支持 Anthropic Messages <-> OpenAI Responses API 双向格式转换
|
||||
- 整理共享转换逻辑,减少重复代码
|
||||
|
||||
### Bedrock 请求优化器
|
||||
|
||||
为 AWS Bedrock 供应商新增 PRE-SEND 阶段请求优化器,提升兼容性和性能。
|
||||
|
||||
- PRE-SEND thinking + cache injection 优化器(#1301,感谢 @keithyt06)
|
||||
|
||||
### OpenClaw 配置增强
|
||||
|
||||
OpenClaw 配置编辑体验全面升级,支持更丰富的配置管理。
|
||||
|
||||
- JSON5 round-trip 写回:编辑配置时保留注释和格式
|
||||
- EnvPanel 支持 JSON 编辑模式和 `tools.profile` 选择
|
||||
- 新增配置校验提示和配置健康状态检查
|
||||
- Agent 模型下拉框改进,支持从供应商预设填充推荐模型
|
||||
- User-Agent 开关:可选在请求中附加 User-Agent 标识(默认关闭)
|
||||
- Legacy timeout 配置自动迁移
|
||||
|
||||
### 供应商预设 Preset
|
||||
|
||||
新增和扩展多组供应商预设,覆盖更多供应商和使用场景。
|
||||
|
||||
- **Ucloud**:新增 `endpointCandidates` 以及 OpenClaw 默认值,刷新 `templateValues` / `suggestedDefaults`
|
||||
- **Micu**:新增预设默认值及 OpenClaw 推荐模型
|
||||
- **X-Code API**:新增 Claude 预设及 `endpointCandidates`
|
||||
- **Novita**:新增供应商预设(#1192,感谢 @Alex-wuhu)
|
||||
- **Bailian For Coding**:新增供应商预设(#1263,感谢 @suki135246)
|
||||
- **SiliconFlow**:新增 partner badge 标识
|
||||
- **模型角色标识**:供应商预设支持模型角色 badge 显示
|
||||
|
||||
### WebDAV 同步增强
|
||||
|
||||
WebDAV 同步引入双层版本控制,提升同步可靠性和数据安全性。
|
||||
|
||||
- 新增 WebDAV protocol v2 + db-v6 双层版本控制
|
||||
- 切换 WebDAV auto-sync 时弹出确认对话框,防止误操作
|
||||
- sync-aware backup:WebDAV 同步时使用 sync 变体备份,跳过仅本地使用的表数据
|
||||
|
||||
### 用量与数据
|
||||
|
||||
用量统计和数据维护能力增强,数据管理更精细,极大降低数据库增长速度。
|
||||
|
||||
- Daily rollups:按天汇总用量数据,减少存储占用
|
||||
- Auto-vacuum:增量式数据库清理,保持数据库健康
|
||||
- UsageFooter 新增额外统计字段(#1137,感谢 @bugparty)
|
||||
|
||||
### 其他新功能
|
||||
|
||||
- **会话删除**:按供应商清理会话记录,带路径安全校验
|
||||
- **Claude auth field selector 恢复**:恢复认证字段选择器
|
||||
- **Failover 开关独立显示**:将 failover toggle 从设置面板移到主页独立展示,并新增首次确认对话框
|
||||
- **通用配置自动抽取**:首次运行时自动从 live config 中抽取通用配置片段
|
||||
- **新供应商页面改进**:优化新建供应商页面体验(#1155,感谢 @wugeer)
|
||||
|
||||
---
|
||||
|
||||
## 架构改进
|
||||
|
||||
### Common Config 运行时叠加
|
||||
|
||||
通用配置片段(Common Config Snippet)改为运行时叠加方式应用,不再物化写入每个供应商配置。
|
||||
|
||||
**变更前**:Common Config 内容在保存或切换时直接合并写入每个供应商的 `settings_config`。这导致公共配置被复制到每个供应商条目中,修改时需要逐一同步。
|
||||
|
||||
**变更后**:Common Config 仅在切换供应商写入 live 文件时以 runtime overlay 方式注入,供应商条目本身不包含公共配置。这意味着修改 Common Config 后立即生效,无需逐一更新每个供应商。
|
||||
|
||||
### 通用配置首次自动抽取
|
||||
|
||||
首次运行时,如果数据库中尚无 Common Config Snippet,会自动从当前 live config 中抽取通用配置。这确保了从旧版本升级的用户不会丢失已有的通用配置设置。
|
||||
|
||||
### 定期维护定时器整合
|
||||
|
||||
将 daily rollups 和 auto-vacuum 整合到统一的定期维护定时器中,避免多个独立定时器带来的资源竞争和复杂度。
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 代理与流式转换
|
||||
|
||||
- 修复 OpenAI ChatCompletion -> Anthropic Messages 流式转换问题
|
||||
- 新增 Codex `/responses/compact` 路由支持(#1194,感谢 @Tsukumi233)
|
||||
- 改进 TOML 配置合并逻辑,避免键值丢失
|
||||
- 改进 proxy forwarder 失败日志,增加更多诊断信息
|
||||
|
||||
### 供应商预设修复
|
||||
|
||||
- X-Code 更名为 X-Code API,统一品牌命名
|
||||
- 修复 SSSAiCode `/v1` 路径问题
|
||||
- 移除 AICoding URL 错误的 `www` 前缀
|
||||
- 优化新建供应商页面换行删除问题(#1155,感谢 @wugeer)
|
||||
|
||||
### 平台修复
|
||||
|
||||
- 修复 cache hit token 统计缺失(#1244,感谢 @a1398394385)
|
||||
- 修复最小化到托盘后一段时间自动退出的问题(#1245,感谢 @YewFence)
|
||||
|
||||
### i18n 修复
|
||||
|
||||
- 补齐 69 个缺失翻译 key,清理剩余硬编码中文
|
||||
- 修复 model test panel 的 i18n 问题
|
||||
- 规范 JSON5 slash escaping,避免国际化字符串解析异常
|
||||
|
||||
### UI 修复
|
||||
|
||||
- 修复 Skills 计数显示问题(#1295,感谢 @fzzv)
|
||||
- 移除 endpoint speed test 的 HTTP 状态码显示,减少视觉噪音
|
||||
- 修复 outline button 样式问题(#1222,感谢 @Sube-py)
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
- OpenClaw 配置未变化时跳过无意义写入,减少磁盘 I/O
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- 重构用户手册以支持国际化,补齐 EN/JA 完整内容
|
||||
- 新增 OpenClaw 使用说明,补完设置章节
|
||||
- 新增 UCloud 赞助商信息
|
||||
- 重组 docs 目录结构,同步 EN/ZH/JA README 的功能说明
|
||||
|
||||
---
|
||||
|
||||
## 说明与注意事项
|
||||
|
||||
- **Common Config 改为运行时叠加**:通用配置片段不再物化写入每个供应商配置,而是在切换时动态叠加。修改 Common Config 后立即生效,无需逐一更新供应商。
|
||||
- **Stream Check 首次使用需确认**:首次使用模型健康检查时会弹出确认对话框,确认后方可使用。
|
||||
- **OpenClaw User-Agent 开关默认关闭**:需要在 OpenClaw 配置中手动开启 User-Agent 标识附加功能。
|
||||
|
||||
---
|
||||
|
||||
## 特别感谢
|
||||
|
||||
感谢以下贡献者为本版本做出的贡献!
|
||||
|
||||
@keithyt06 @bugparty @Alex-wuhu @suki135246 @Tsukumi233 @wugeer @fzzv @Sube-py @a1398394385 @YewFence
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------------- | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.0-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.0-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| -------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.0-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.12.0-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,146 @@
|
||||
# CC Switch v3.12.1
|
||||
|
||||
> Stability Fixes, StepFun Presets, OpenClaw authHeader, and New Sponsor Partners
|
||||
|
||||
**[中文版 →](v3.12.1-zh.md) | [日本語版 →](v3.12.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.1 is a patch release focused on stability improvements and bug fixes. It resolves a Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, and several i18n interpolation issues. It also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades the default Gemini model to **3.1-pro**, and welcomes four new sponsor partners.
|
||||
|
||||
**Release Date**: 2026-03-12
|
||||
|
||||
**Update Scale**: 19 commits | 56 files changed | +1,429 / -396 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Common Config modal fix**: Resolved an infinite reopen loop in the Common Config modal and added draft editing support
|
||||
- **WebDAV sync reliability**: Fixed a foreign key constraint failure when restoring `provider_health` during WebDAV sync
|
||||
- **StepFun presets**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model
|
||||
- **OpenClaw enhancements**: Added input type selection for model Advanced Options and `authHeader` field for vendor-specific auth header support
|
||||
- **Gemini model upgrade**: Upgraded default Gemini model to 3.1-pro in provider presets
|
||||
- **New sponsors**: Welcomed Micu API, XCodeAPI, SiliconFlow, and CTok as sponsor partners
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### StepFun Provider Presets
|
||||
|
||||
Added provider presets for StepFun (阶跃星辰), a leading Chinese AI model provider.
|
||||
|
||||
- New preset entries for StepFun across supported applications
|
||||
- Includes the step-3.5-flash model (#1369, thanks @hengm3467)
|
||||
|
||||
### OpenClaw Enhancements
|
||||
|
||||
Enhanced the OpenClaw configuration with more granular control and better vendor compatibility.
|
||||
|
||||
- Added input type selection dropdown for model Advanced Options (#1368, thanks @liuxxxu)
|
||||
- Added optional `authHeader` boolean to `OpenClawProviderConfig` for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type
|
||||
|
||||
### Sponsor Partners
|
||||
|
||||
- **Micu API**: Added Micu API as sponsor partner with affiliate links
|
||||
- **XCodeAPI**: Added XCodeAPI as sponsor partner
|
||||
- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links
|
||||
- **CTok**: Added CTok as sponsor partner
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)
|
||||
- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page
|
||||
- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Common Config & UI
|
||||
|
||||
- Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits
|
||||
- Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)
|
||||
- Fixed session search index not syncing with query data, causing stale list display after session deletion
|
||||
|
||||
### Sync & Data
|
||||
|
||||
- Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync
|
||||
|
||||
### Provider & Preset
|
||||
|
||||
- Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)
|
||||
- Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)
|
||||
- Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`
|
||||
|
||||
### i18n & Localization
|
||||
|
||||
- Fixed stream check toast i18n interpolation keys not matching translation placeholders
|
||||
- Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)
|
||||
- Renamed OpenCode API format label from "OpenAI" to "OpenAI Responses" for accuracy
|
||||
|
||||
---
|
||||
|
||||
## Special Thanks
|
||||
|
||||
Thanks to all contributors for their contributions to this release!
|
||||
|
||||
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.1-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.1-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.1-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.1-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,146 @@
|
||||
# CC Switch v3.12.1
|
||||
|
||||
> 安定性修正、StepFun プリセット、OpenClaw authHeader 対応、新スポンサーパートナー
|
||||
|
||||
**[中文版 →](v3.12.1-zh.md) | [English →](v3.12.1-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.1 は、安定性の改善とバグ修正に焦点を当てたパッチリリースです。共通設定モーダルの無限再オープンループ、WebDAV 同期時の外部キー制約エラー、複数の i18n 補間問題を修正しました。また、**StepFun(阶跃星辰)** プロバイダープリセットの追加、OpenClaw の**入力タイプ選択**と **authHeader** サポート、デフォルト Gemini モデルの **3.1-pro** へのアップグレード、4 つの新スポンサーパートナーの追加が含まれます。
|
||||
|
||||
**リリース日**: 2026-03-12
|
||||
|
||||
**更新規模**: 19 commits | 56 files changed | +1,429 / -396 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **共通設定モーダル修正**: 共通設定モーダルの無限再オープンループを解決し、下書き編集サポートを追加
|
||||
- **WebDAV 同期の信頼性向上**: WebDAV 同期で `provider_health` 復元時の外部キー制約エラーを修正
|
||||
- **StepFun プリセット**: StepFun(阶跃星辰)プロバイダープリセットを追加、step-3.5-flash モデルを含む
|
||||
- **OpenClaw 強化**: モデル詳細設定に入力タイプ選択を追加、ベンダー固有の認証ヘッダーサポート用 `authHeader` フィールドを追加
|
||||
- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 3.1-pro にアップグレード
|
||||
- **新スポンサー**: Micu API、XCodeAPI、SiliconFlow、CTok をスポンサーパートナーとして追加
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### StepFun プロバイダープリセット
|
||||
|
||||
中国の主要 AI モデルプロバイダーである StepFun(阶跃星辰)のプロバイダープリセットを追加しました。
|
||||
|
||||
- サポート対象アプリケーション全体に StepFun プリセットエントリーを追加
|
||||
- step-3.5-flash モデルを含む(#1369、@hengm3467 に感謝)
|
||||
|
||||
### OpenClaw 強化
|
||||
|
||||
OpenClaw 設定をより細かく制御でき、ベンダー互換性を向上させました。
|
||||
|
||||
- モデル詳細設定に入力タイプ(input type)選択ドロップダウンを追加(#1368、@liuxxxu に感謝)
|
||||
- `OpenClawProviderConfig` にオプションの `authHeader` ブール値を追加し、ベンダー固有の認証ヘッダー(例: Longcat)をサポート。フォーム状態を共有型の再利用にリファクタリング
|
||||
|
||||
### スポンサーパートナー
|
||||
|
||||
- **Micu API**: Micu API をスポンサーパートナーとして追加、アフィリエイトリンク付き
|
||||
- **XCodeAPI**: XCodeAPI をスポンサーパートナーとして追加
|
||||
- **SiliconFlow**: SiliconFlow(硅基流动)をスポンサーパートナーとして追加、アフィリエイトリンク付き
|
||||
- **CTok**: CTok をスポンサーパートナーとして追加
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
- **UCloud → Compshare**: UCloud プロバイダーを Compshare(优云智算)にリネームし、3 言語(EN/ZH/JA)の完全な i18n サポートを追加
|
||||
- **Compshare リンク**: Compshare スポンサー登録リンクを coding-plan ページに更新
|
||||
- **Gemini モデルアップグレード**: プロバイダープリセットのデフォルト Gemini モデルを 2.5-pro から 3.1-pro にアップグレード
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### 共通設定と UI
|
||||
|
||||
- 共通設定モーダルの無限再オープンループを修正し、編集中のデータ損失を防ぐための下書き編集サポートを追加
|
||||
- Windows でツールバーコンパクトモードが左側のオーバーフローにより機能しない問題を修正(#1375、@zuoliangyu に感謝)
|
||||
- セッション削除後にクエリデータと検索インデックスが同期されず、リストが更新されない問題を修正
|
||||
|
||||
### 同期とデータ
|
||||
|
||||
- WebDAV 同期で `provider_health` テーブルを復元する際の外部キー制約エラーを修正
|
||||
|
||||
### プロバイダーとプリセット
|
||||
|
||||
- Longcat プロバイダープリセットに欠落していた `authHeader: true` を追加(#1377、@wavever に感謝)
|
||||
- OpenClaw のツール権限プロファイルをアップストリームスキーマに合わせて修正(#1355、@bigsongeth に感謝)
|
||||
- X-Code API の URL を `www.x-code.cn` から `x-code.cc` に修正
|
||||
|
||||
### i18n とローカリゼーション
|
||||
|
||||
- Stream Check トーストの i18n 補間キーが翻訳プレースホルダーと一致しない問題を修正
|
||||
- プロキシ起動トーストでアドレスとポート値が補間されない問題を修正(#1399、@Mason-mengze に感謝)
|
||||
- OpenCode の API フォーマットラベルを「OpenAI」から「OpenAI Responses」にリネームし、正確性を向上
|
||||
|
||||
---
|
||||
|
||||
## 謝辞
|
||||
|
||||
以下のコントリビューターの皆様、このリリースへの貢献に感謝します!
|
||||
|
||||
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.1-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.1-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.1-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.1-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,146 @@
|
||||
# CC Switch v3.12.1
|
||||
|
||||
> 稳定性修复、StepFun 预设、OpenClaw authHeader 支持,以及新赞助商伙伴
|
||||
|
||||
**[English →](v3.12.1-en.md) | [日本語版 →](v3.12.1-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.1 是一个以稳定性改进和 Bug 修复为主的补丁版本。修复了通用配置弹窗无限重复打开的循环问题、WebDAV 同步时的外键约束失败以及多个 i18n 插值问题。同时新增了 **StepFun(阶跃星辰)** 供应商预设、OpenClaw **输入类型选择** 和 **authHeader** 支持,将默认 Gemini 模型升级到 **3.1-pro**,并欢迎四位新赞助商伙伴加入。
|
||||
|
||||
**发布日期**:2026-03-12
|
||||
|
||||
**更新规模**:19 commits | 56 files changed | +1,429 / -396 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **通用配置弹窗修复**:解决了通用配置弹窗无限重复打开的循环问题,并新增草稿编辑支持
|
||||
- **WebDAV 同步可靠性**:修复了 WebDAV 同步恢复 `provider_health` 时的外键约束失败
|
||||
- **StepFun 预设**:新增 StepFun(阶跃星辰)供应商预设,包含 step-3.5-flash 模型
|
||||
- **OpenClaw 增强**:新增模型高级选项的输入类型选择和 `authHeader` 字段,支持供应商特定的认证头
|
||||
- **Gemini 模型升级**:供应商预设中的默认 Gemini 模型升级到 3.1-pro
|
||||
- **新赞助商**:欢迎 Micu API、XCodeAPI、SiliconFlow、CTok 加入赞助伙伴
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### StepFun 供应商预设
|
||||
|
||||
新增 StepFun(阶跃星辰)供应商预设,阶跃星辰是领先的中国 AI 模型提供商。
|
||||
|
||||
- 在各支持应用中新增 StepFun 预设条目
|
||||
- 包含 step-3.5-flash 模型(#1369,感谢 @hengm3467)
|
||||
|
||||
### OpenClaw 增强
|
||||
|
||||
增强 OpenClaw 配置能力,提供更细粒度的控制和更好的供应商兼容性。
|
||||
|
||||
- 新增模型高级选项的输入类型(input type)选择下拉框(#1368,感谢 @liuxxxu)
|
||||
- 在 `OpenClawProviderConfig` 中新增可选的 `authHeader` 布尔字段,支持供应商特定的认证头(如 Longcat),并重构表单状态以复用共享类型
|
||||
|
||||
### 赞助商伙伴
|
||||
|
||||
- **Micu API**:新增 Micu API 赞助商及推广链接
|
||||
- **XCodeAPI**:新增 XCodeAPI 赞助商
|
||||
- **SiliconFlow**:新增 SiliconFlow(硅基流动)赞助商及推广链接
|
||||
- **CTok**:新增 CTok 赞助商
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
- **UCloud → Compshare**:将 UCloud 供应商更名为 Compshare(优云智算),支持三种语言(中/英/日)的完整国际化
|
||||
- **Compshare 链接**:更新 Compshare 赞助商注册链接指向 coding-plan 页面
|
||||
- **Gemini 模型升级**:供应商预设中的默认 Gemini 模型从 2.5-pro 升级到 3.1-pro
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 通用配置与 UI
|
||||
|
||||
- 修复通用配置弹窗无限重复打开的循环问题,并新增草稿编辑支持以防止编辑过程中数据丢失
|
||||
- 修复 Windows 下因左侧溢出导致工具栏紧凑模式不触发的问题(#1375,感谢 @zuoliangyu)
|
||||
- 修复会话删除后搜索索引未与查询数据同步,导致列表显示过期的问题
|
||||
|
||||
### 同步与数据
|
||||
|
||||
- 修复 WebDAV 同步恢复 `provider_health` 表时的外键约束失败
|
||||
|
||||
### 供应商与预设
|
||||
|
||||
- 为 Longcat 供应商预设补充缺失的 `authHeader: true`(#1377,感谢 @wavever)
|
||||
- 对齐 OpenClaw 工具权限配置与上游 schema(#1355,感谢 @bigsongeth)
|
||||
- 修正 X-Code API URL,从 `www.x-code.cn` 改为 `x-code.cc`
|
||||
|
||||
### i18n 与本地化
|
||||
|
||||
- 修复 Stream Check Toast 的 i18n 插值 key 与翻译占位符不匹配
|
||||
- 修复代理启动 Toast 未正确插值地址和端口的问题(#1399,感谢 @Mason-mengze)
|
||||
- 将 OpenCode API 格式标签从 "OpenAI" 改为 "OpenAI Responses",更准确地反映实际格式
|
||||
|
||||
---
|
||||
|
||||
## 特别感谢
|
||||
|
||||
感谢以下贡献者为本版本做出的贡献!
|
||||
|
||||
@hengm3467 @liuxxxu @bigsongeth @zuoliangyu @wavever @Mason-mengze
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------------ | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.1-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.1-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.1-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.12.1-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,138 @@
|
||||
# CC Switch v3.12.2
|
||||
|
||||
> Common Config Protection During Proxy Takeover, Snippet Lifecycle Stability, Section-Aware Codex TOML Editing
|
||||
|
||||
**[中文版 →](v3.12.2-zh.md) | [日本語版 →](v3.12.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.2 is a reliability-focused patch release that addresses Common Config loss during proxy takeover and improves Codex TOML editing accuracy. Proxy takeover hot-switches and provider sync now update the restore backup instead of overwriting live config files; the startup sequence has been reordered so snippets are extracted from clean live files before takeover state is restored; and Codex `base_url` editing has been refactored into a section-aware model that no longer appends to the end of the file.
|
||||
|
||||
**Release Date**: 2026-03-12
|
||||
|
||||
**Update Scale**: 5 commits | 22 files changed | +1,716 / -288 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Empty state guidance**: Provider list empty state now shows detailed import instructions with a conditional Common Config snippet hint for Claude/Codex/Gemini
|
||||
|
||||
- **Proxy takeover restore flow rework**: Hot-switches and provider sync now refresh the restore backup instead of overwriting live config files, preserving the full user configuration on rollback
|
||||
- **Snippet lifecycle stability**: Introduced a `cleared` flag to prevent auto-extraction from resurrecting cleared snippets, and reordered startup to extract from clean state
|
||||
- **Section-aware Codex TOML editing**: `base_url` and `model` field reads/writes now target the correct `[model_providers.<name>]` section
|
||||
- **Codex MCP config protection**: Existing `mcp_servers` blocks in restore snapshots survive provider hot-switches via per-server-id merge instead of wholesale replacement, with provider/common-config definitions winning on conflict
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### Empty State Guidance
|
||||
|
||||
Improved the first-run experience with helpful guidance when the provider list is empty.
|
||||
|
||||
- Empty state page shows step-by-step import instructions
|
||||
- Conditionally displays a Common Config snippet hint for Claude/Codex/Gemini providers (not shown for OpenCode/OpenClaw)
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### Proxy Takeover Restore Flow
|
||||
|
||||
The proxy takeover hot-switch and provider sync logic has been reworked to protect Common Config throughout the takeover lifecycle.
|
||||
|
||||
- Provider sync now updates the restore backup instead of writing directly to live config files when takeover is active
|
||||
- Effective provider settings are rebuilt with Common Config applied before saving restore snapshots, so rollback restores the real user configuration
|
||||
- Legacy providers with inferred common config usage are automatically marked with `commonConfigEnabled=true`
|
||||
|
||||
### Codex TOML Editing Engine
|
||||
|
||||
Codex `config.toml` update logic has been refactored onto shared section-aware TOML helpers.
|
||||
|
||||
- New Rust module `codex_config.rs` with `update_codex_toml_field` and `remove_codex_toml_base_url_if`
|
||||
- New frontend utilities `getTomlSectionRange` / `getCodexProviderSectionName` for section-aware operations
|
||||
- Inline TOML editing logic scattered across `proxy.rs` now delegates to the new module
|
||||
|
||||
### Common Config Initialization Lifecycle
|
||||
|
||||
The startup sequence has been reordered for more robust snippet extraction and migration.
|
||||
|
||||
- Startup now auto-extracts Common Config snippets from clean live files before restoring proxy takeover state
|
||||
- Introduced a snippet `cleared` flag to track whether a user intentionally cleared a snippet
|
||||
- Persisted a one-time legacy migration flag to avoid repeated `commonConfigEnabled` backfills
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Common Config Loss
|
||||
|
||||
- Fixed multiple scenarios where Common Config could be dropped during proxy takeover: sync overwriting live files, hot-switches producing incomplete restore snapshots, and provider switches losing config changes
|
||||
|
||||
### Codex Restore Snapshot Preservation
|
||||
|
||||
- Fixed Codex takeover restore backups discarding existing `mcp_servers` blocks during provider hot-switches; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while backup-only servers are retained
|
||||
|
||||
### Cleared Snippet Resurrection
|
||||
|
||||
- Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared
|
||||
|
||||
### Codex `base_url` Misplacement
|
||||
|
||||
- Fixed Codex `base_url` extraction and editing not targeting the correct `[model_providers.<name>]` section, causing it to append to the file tail or confuse `mcp_servers.*.base_url` entries for provider endpoints
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.2-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.2-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.2-macOS.zip` | **Recommended** - Extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.2-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> **Note**: Since the author doesn't have an Apple Developer account, you may see an "unidentified developer" warning on first launch. Please close it, then go to "System Settings" -> "Privacy & Security" -> click "Open Anyway", and it will open normally afterwards.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,138 @@
|
||||
# CC Switch v3.12.2
|
||||
|
||||
> プロキシテイクオーバー中の共通設定保護、Snippet ライフサイクルの安定化、Codex TOML セクション対応編集
|
||||
|
||||
**[中文版 →](v3.12.2-zh.md) | [English →](v3.12.2-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.2 は、信頼性を重視したパッチリリースです。プロキシテイクオーバーモードでの共通設定(Common Config)の消失問題を解決し、Codex TOML 設定の編集精度を改善しました。テイクオーバーのホットスイッチとプロバイダー同期は、ライブ設定ファイルを上書きする代わりにリストアバックアップを更新するようになりました。起動シーケンスを再整理し、テイクオーバー状態を復元する前にクリーンなライブファイルから Snippet を抽出するようにしました。また Codex の `base_url` 編集をセクション対応モデルにリファクタリングし、ファイル末尾への誤追加を防止しました。
|
||||
|
||||
**リリース日**: 2026-03-12
|
||||
|
||||
**更新規模**: 5 commits | 22 files changed | +1,716 / -288 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **空状態ガイダンスの改善**: プロバイダーリストが空の場合に詳細なインポート手順を表示し、Claude/Codex/Gemini には共通設定 Snippet のヒントを条件付きで表示
|
||||
|
||||
- **プロキシテイクオーバーリストアフロー刷新**: ホットスイッチとプロバイダー同期がライブ設定ファイルの上書きではなくリストアバックアップの更新を行うようになり、ロールバック時に完全なユーザー設定を保持
|
||||
- **Snippet ライフサイクルの安定化**: `cleared` フラグを導入し、クリア済み Snippet の自動再抽出を防止。起動順序を調整してクリーンな状態から抽出
|
||||
- **Codex TOML セクション対応編集**: `base_url` と `model` フィールドの読み書きが正しい `[model_providers.<name>]` セクションを対象にするように改善
|
||||
- **Codex MCP 設定の保護**: プロバイダーホットスイッチ時にリストアスナップショット内の既存 `mcp_servers` ブロックが保持されるように修正。テーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 定義が競合時に優先
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### 空状態ガイダンスの改善
|
||||
|
||||
プロバイダーリストが空の場合の初回利用体験を改善しました。
|
||||
|
||||
- 空状態ページにプロバイダーインポートの操作ガイドを表示
|
||||
- Claude/Codex/Gemini アプリケーションに共通設定 Snippet のヒントを条件付きで表示(OpenCode/OpenClaw には非表示)
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### プロキシテイクオーバーリストアフロー
|
||||
|
||||
テイクオーバーのホットスイッチとプロバイダー同期ロジックをリファクタリングし、テイクオーバーライフサイクル全体で共通設定を保護します。
|
||||
|
||||
- テイクオーバーがアクティブな場合、プロバイダー同期がライブ設定ファイルへの直接書き込みではなくリストアバックアップを更新
|
||||
- リストアスナップショットの保存前に共通設定を適用した実効プロバイダー設定を再構築し、ロールバックで実際のユーザー設定を復元
|
||||
- 共通設定の使用が推測されるレガシープロバイダーに `commonConfigEnabled=true` を自動マーク
|
||||
|
||||
### Codex TOML 編集エンジン
|
||||
|
||||
Codex `config.toml` の更新ロジックを共有のセクション対応 TOML ヘルパーにリファクタリングしました。
|
||||
|
||||
- Rust 側に新モジュール `codex_config.rs` を追加(`update_codex_toml_field` と `remove_codex_toml_base_url_if`)
|
||||
- フロントエンドにセクション対応ユーティリティ `getTomlSectionRange` / `getCodexProviderSectionName` を追加
|
||||
- `proxy.rs` に散在していたインライン TOML 編集ロジックを新モジュールに委譲
|
||||
|
||||
### 共通設定初期化ライフサイクル
|
||||
|
||||
Snippet の抽出とマイグレーションをより堅牢にするため、起動シーケンスを再整理しました。
|
||||
|
||||
- 起動時にプロキシテイクオーバー状態を復元する前に、クリーンなライブファイルから共通設定 Snippet を自動抽出
|
||||
- Snippet の `cleared` フラグを導入し、ユーザーが意図的にクリアしたかどうかを追跡
|
||||
- 一回限りのレガシーマイグレーションフラグを永続化し、`commonConfigEnabled` のバックフィルの繰り返しを防止
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### 共通設定の消失
|
||||
|
||||
- プロキシテイクオーバー中に共通設定が消失する複数のシナリオを修正:同期によるライブファイルの上書き、ホットスイッチによる不完全なリストアスナップショット、プロバイダー切り替え時の設定変更の消失
|
||||
|
||||
### Codex リストアスナップショットの保護
|
||||
|
||||
- プロバイダーホットスイッチ時に Codex テイクオーバーリストアバックアップが既存の `mcp_servers` ブロックを破棄する問題を修正。MCP バックアップ保持をテーブル全体の置換からサーバー ID ごとのマージに変更し、プロバイダー/共通設定の MCP 更新が競合時に優先され、バックアップのみのサーバーも保持
|
||||
|
||||
### クリア済み Snippet の復活
|
||||
|
||||
- 起動時の自動抽出が、ユーザーが意図的にクリアした共通設定 Snippet を再作成する問題を修正
|
||||
|
||||
### Codex `base_url` の配置エラー
|
||||
|
||||
- Codex `base_url` の抽出と編集が正しい `[model_providers.<name>]` セクションを対象にせず、ファイル末尾に追加されたり `mcp_servers.*.base_url` をプロバイダーエンドポイントと誤認する問題を修正
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.2-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.2-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.2-macOS.zip` | **推奨** - 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.2-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> **注意**: 作者が Apple Developer アカウントを持っていないため、初回起動時に「開発元を確認できません」という警告が表示される場合があります。一度閉じてから、「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックすると、その後は正常に開けます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,138 @@
|
||||
# CC Switch v3.12.2
|
||||
|
||||
> 代理接管期间通用配置保护、Snippet 生命周期稳定性、Codex TOML Section 感知编辑
|
||||
|
||||
**[English →](v3.12.2-en.md) | [日本語版 →](v3.12.2-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.2 是一个以可靠性为核心的补丁版本,重点解决代理(Proxy)接管模式下通用配置(Common Config)丢失的问题,并改进了 Codex TOML 配置的编辑准确性。代理接管的热切换和供应商同步现在会更新恢复备份而非直接覆盖 live 文件;启动流程重新排序,确保先从干净的 live 文件提取 Snippet 再恢复接管状态;Codex 的 `base_url` 编辑重构为 Section 感知模式,不再错误追加到文件末尾。
|
||||
|
||||
**发布日期**:2026-03-12
|
||||
|
||||
**更新规模**:5 commits | 22 files changed | +1,716 / -288 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **首次使用引导优化**:供应商列表空状态显示详细的导入说明,Claude/Codex/Gemini 还会提示通用配置 Snippet 功能
|
||||
|
||||
- **代理接管恢复流程重构**:热切换和供应商同步现在刷新恢复备份,而非覆盖 live 配置文件,回滚时保留完整的用户配置
|
||||
- **Snippet 生命周期稳定**:引入 `cleared` 标志防止已清除的 Snippet 被自动重新提取,启动顺序调整确保从干净状态提取
|
||||
- **Codex TOML Section 感知编辑**:`base_url` 和 `model` 字段的读写现在定位到正确的 `[model_providers.<name>]` Section
|
||||
- **Codex MCP 配置保护**:热切换供应商时保留恢复快照中已有的 `mcp_servers` 配置块,按 server id 合并而非整表替换,供应商/通用配置的 MCP 定义优先
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### 空状态引导优化
|
||||
|
||||
改善首次使用体验,当供应商列表为空时显示详细的导入说明。
|
||||
|
||||
- 空状态页面展示导入供应商的操作指引
|
||||
- 对 Claude/Codex/Gemini 应用有条件地显示通用配置 Snippet 提示(OpenCode/OpenClaw 不显示)
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### 代理接管恢复流程
|
||||
|
||||
代理接管的热切换和供应商同步逻辑经过重构,确保通用配置在整个接管生命周期中得到保护。
|
||||
|
||||
- 接管活跃时,供应商同步更新恢复备份而非直接写入 live 配置文件
|
||||
- 保存恢复快照前先应用通用配置,使回滚能还原真实的用户配置
|
||||
- 遗留供应商中推断使用了通用配置的条目自动标记 `commonConfigEnabled=true`
|
||||
|
||||
### Codex TOML 编辑引擎
|
||||
|
||||
将 Codex `config.toml` 的更新逻辑重构到共享的 Section 感知 TOML 辅助函数上。
|
||||
|
||||
- Rust 端新增 `codex_config.rs` 模块,包含 `update_codex_toml_field` 和 `remove_codex_toml_base_url_if`
|
||||
- 前端新增 `getTomlSectionRange` / `getCodexProviderSectionName` 等 Section 感知工具函数
|
||||
- `proxy.rs` 中散落的 TOML 内联编辑逻辑统一委托给新模块
|
||||
|
||||
### 通用配置初始化生命周期
|
||||
|
||||
启动流程重新排序,通用配置 Snippet 的提取和迁移逻辑更加健壮。
|
||||
|
||||
- 启动时先从干净的 live 文件自动提取通用配置 Snippet,再恢复代理接管状态
|
||||
- 引入 Snippet `cleared` 标志,追踪用户是否主动清除了某个 Snippet
|
||||
- 持久化一次性遗留迁移标志,避免重复执行旧版 `commonConfigEnabled` 回填
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### 通用配置丢失
|
||||
|
||||
- 修复代理接管期间通用配置可能被丢弃的多种场景:同步覆盖 live 文件、热切换产生不完整的恢复快照、供应商切换丢失配置变更
|
||||
|
||||
### Codex 恢复快照保护
|
||||
|
||||
- 修复 Codex 接管恢复备份在供应商热切换时丢弃已有 `mcp_servers` 配置块的问题;将 MCP 备份保留策略从整表替换改为按 server id 合并,供应商/通用配置的 MCP 定义在冲突时优先,备份中独有的服务器仍被保留
|
||||
|
||||
### 已清除 Snippet 复活
|
||||
|
||||
- 修复启动时自动提取机制重新创建用户已主动清除的通用配置 Snippet 的问题
|
||||
|
||||
### Codex `base_url` 位置错误
|
||||
|
||||
- 修复 Codex `base_url` 提取和编辑未定位到正确的 `[model_providers.<name>]` Section,导致追加到文件末尾或误将 `mcp_servers.*.base_url` 识别为供应商端点的问题
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 10.15 (Catalina) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------------ | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.2-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.2-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.2-macOS.zip` | **推荐** - 解压后拖入 Applications 即可,Universal Binary |
|
||||
| `CC-Switch-v3.12.2-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,313 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot Reverse Proxy, macOS Code Signing & Notarization, Reasoning Effort Mapping, OpenCode SQLite Backend
|
||||
|
||||
**[中文版 →](v3.12.3-zh.md) | [日本語版 →](v3.12.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
CC Switch v3.12.3 is a major feature release that adds GitHub Copilot reverse proxy support with a dedicated Auth Center, introduces macOS code signing and Apple notarization for a seamless install experience, maps reasoning effort levels across providers, migrates OpenCode to a SQLite backend, enables Tool Search via the native `ENABLE_TOOL_SEARCH` environment variable toggle, and delivers a full skill backup/restore lifecycle. Additional improvements include proxy gzip compression, o-series model compatibility, Skills import rework, Ghostty terminal fix, Skills cache strategy optimization, Claude 4.6 context window update, and multiple bug fixes.
|
||||
|
||||
**Release Date**: 2026-03-24
|
||||
|
||||
**Update Scale**: 36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- **GitHub Copilot reverse proxy**: Full Copilot proxy support with OAuth device flow authentication, token refresh, and request fingerprint emulation ([⚠️ Risk Notice](#️-risk-notice))
|
||||
- **Copilot Auth Center**: Dedicated authentication management UI for GitHub Copilot OAuth flow with token status display and one-click refresh
|
||||
- **macOS code signing & notarization**: macOS builds are now code-signed and notarized by Apple, eliminating the "unidentified developer" warning entirely
|
||||
- **Reasoning Effort mapping**: Proxy-layer auto-mapping — explicit `output_config.effort` takes priority, falling back to `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high) for o-series and GPT-5+ models
|
||||
- **OpenCode SQLite backend**: Added SQLite session storage for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts
|
||||
- **Codex 1M context window toggle**: One-click checkbox to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit`
|
||||
- **Disable Auto-Upgrade toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading
|
||||
- **Tool Search env var toggle**: Tool Search enabled via Claude 2.1.76+ native `ENABLE_TOOL_SEARCH` environment variable in the Common Config editor — no binary patching required
|
||||
- **Skill backup/restore lifecycle**: Skills are automatically backed up before uninstall; backup list with restore and delete management added
|
||||
- **Proxy gzip compression**: Non-streaming proxy requests now auto-negotiate gzip compression, reducing bandwidth usage
|
||||
- **o-series model compatibility**: Chat Completions proxy correctly uses `max_completion_tokens` for o1/o3/o4-mini models; Responses API kept on the correct `max_output_tokens` field
|
||||
- **Skills import rework**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation
|
||||
- **Ghostty terminal support**: Fixed Claude session restore in Ghostty terminal
|
||||
|
||||
---
|
||||
|
||||
## New Features
|
||||
|
||||
### GitHub Copilot Reverse Proxy
|
||||
|
||||
Added full reverse proxy support for GitHub Copilot, enabling Copilot-authenticated requests to be forwarded through CC Switch.
|
||||
|
||||
- Implements OAuth device flow authentication for GitHub Copilot
|
||||
- Automatic token refresh and session management
|
||||
- Request fingerprint emulation for seamless compatibility
|
||||
- Integrated into the existing proxy infrastructure alongside Claude, Codex, and Gemini handlers
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
A dedicated authentication management UI for GitHub Copilot.
|
||||
|
||||
- OAuth device flow with code display and browser-based authorization
|
||||
- Token status display showing expiration and validity
|
||||
- One-click token refresh without re-authentication
|
||||
- Integrated into the settings panel for easy access
|
||||
|
||||
### Reasoning Effort Mapping
|
||||
|
||||
Proxy-layer auto-mapping of reasoning effort for OpenAI o-series and GPT-5+ models.
|
||||
|
||||
- Two-tier resolution: explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high)
|
||||
- Covers both Chat Completions and Responses API paths with 17 unit tests
|
||||
|
||||
### OpenCode SQLite Backend
|
||||
|
||||
Added SQLite session storage support for OpenCode alongside the existing JSON backend.
|
||||
|
||||
- Dual-backend scan with SQLite priority on ID conflicts
|
||||
- Atomic session deletion and path validation
|
||||
- JSON backend remains functional for backwards compatibility
|
||||
|
||||
### Codex 1M Context Window Toggle
|
||||
|
||||
Added a one-click toggle for Codex 1M context window in the config editor.
|
||||
|
||||
- Checkbox sets `model_context_window = 1000000` in `config.toml`
|
||||
- Auto-populates `model_auto_compact_token_limit = 900000` when enabled
|
||||
- Unchecking removes both fields cleanly
|
||||
|
||||
### Disable Auto-Upgrade Toggle
|
||||
|
||||
Added a checkbox in the Claude Common Config editor to disable Claude Code auto-upgrades.
|
||||
|
||||
- Sets `DISABLE_AUTOUPDATER=1` in the environment configuration when enabled
|
||||
- Displayed alongside Teammates mode, Tool Search, and High Effort toggles
|
||||
|
||||
### Tool Search Environment Variable Toggle
|
||||
|
||||
Tool Search is now enabled via the native `ENABLE_TOOL_SEARCH` environment variable introduced in Claude 2.1.76+.
|
||||
|
||||
- Toggle available in the Common Config editor under environment variables
|
||||
- Sets `ENABLE_TOOL_SEARCH=1` in the Claude environment configuration
|
||||
- No binary patching required — uses Claude's built-in support
|
||||
|
||||
### macOS Code Signing & Notarization
|
||||
|
||||
macOS builds are now code-signed and notarized by Apple.
|
||||
|
||||
- Application signed with a valid Apple Developer certificate
|
||||
- Notarized through Apple's notarization service for Gatekeeper approval
|
||||
- DMG installer also signed and notarized
|
||||
- Eliminates the "unidentified developer" warning on first launch
|
||||
|
||||
### Skill Auto-Backup on Uninstall
|
||||
|
||||
Skill files are now automatically backed up before uninstall to prevent accidental data loss.
|
||||
|
||||
- Backups stored in `~/.cc-switch/skill-backups/` with all skill files and a `meta.json` containing original metadata
|
||||
- Old backups are automatically pruned to keep at most 20
|
||||
- Backup path is returned to the frontend and shown in the success toast
|
||||
|
||||
### Skill Backup Restore & Delete
|
||||
|
||||
Added management commands for skill backups created during uninstall.
|
||||
|
||||
- List all available skill backups with metadata
|
||||
- Restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure
|
||||
- Delete removes the backup directory after a confirmation dialog
|
||||
- ConfirmDialog gains a configurable zIndex prop to support nested dialog stacking
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### Skills Cache Strategy Optimization
|
||||
|
||||
Optimized the Skills cache invalidation strategy for better performance.
|
||||
|
||||
- Reduced unnecessary cache refreshes during skill operations
|
||||
- Improved cache coherence between skill install/uninstall and list queries
|
||||
|
||||
### Claude 4.6 Context Window Update
|
||||
|
||||
Updated Claude 4.6 model preset with the latest context window size.
|
||||
|
||||
- Reflects the expanded context window for Claude 4.6 models
|
||||
- Updated in provider presets for accurate model information display
|
||||
|
||||
### MiniMax M2.7 Upgrade
|
||||
|
||||
- Updated MiniMax provider preset to M2.7 model variant
|
||||
|
||||
### Xiaomi MiMo Upgrade
|
||||
|
||||
- Updated Xiaomi MiMo provider preset to the latest model version
|
||||
|
||||
### AddProviderDialog Simplification
|
||||
|
||||
- Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)
|
||||
|
||||
### Provider Form Advanced Options Collapse
|
||||
|
||||
- Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty
|
||||
- Auto-expands when any value is set or when a preset fills them in; does not auto-collapse when manually cleared
|
||||
|
||||
### Proxy Gzip Compression
|
||||
|
||||
Non-streaming proxy requests now support gzip compression for reduced bandwidth usage.
|
||||
|
||||
- Non-streaming requests let reqwest auto-negotiate gzip and transparently decompress responses
|
||||
- Streaming requests conservatively keep `Accept-Encoding: identity` to avoid decompression errors on interrupted SSE streams
|
||||
|
||||
### o1/o3 Model Compatibility
|
||||
|
||||
Proxy forwarding now handles OpenAI o-series model token parameters correctly.
|
||||
|
||||
- Chat Completions path uses `max_completion_tokens` instead of `max_tokens` for o1/o3/o4-mini models (#1451, thanks @Hemilt0n)
|
||||
- Responses API path kept on the correct `max_output_tokens` field instead of incorrectly injecting `max_completion_tokens`
|
||||
|
||||
### OpenCode Model Variants
|
||||
|
||||
- Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)
|
||||
|
||||
### Skills Import Flow
|
||||
|
||||
The Skills import flow has been reworked for correctness and cleanup.
|
||||
|
||||
- Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation when the same skill directory exists under multiple app paths
|
||||
- Added reconciliation to `sync_to_app` to remove disabled/orphaned symlinks
|
||||
- MCP `sync_all_enabled` now removes disabled servers from live config
|
||||
- Schema migration preserves a snapshot of legacy app mappings to avoid lossy reconstruction
|
||||
|
||||
---
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### WebDAV Password Clearing
|
||||
|
||||
- Fixed an issue where the WebDAV password was silently cleared when saving unrelated settings
|
||||
|
||||
### Tool Message Parsing
|
||||
|
||||
- Fixed incorrect parsing of tool-use messages in certain proxy response formats
|
||||
|
||||
### Dark Mode Styling
|
||||
|
||||
- Fixed dark mode rendering inconsistencies in UI components
|
||||
|
||||
### Copilot Request Fingerprint
|
||||
|
||||
- Fixed request fingerprint generation for Copilot proxy to match expected format
|
||||
|
||||
### Provider Form Double Submit
|
||||
|
||||
- Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352, thanks @Hexi1997)
|
||||
|
||||
### Ghostty Session Restore
|
||||
|
||||
- Fixed Claude session restore in Ghostty terminal (#1506, thanks @canyonsehun)
|
||||
|
||||
### Skill ZIP Import Extension
|
||||
|
||||
- Added `.skill` file extension support in ZIP import dialog (#1240, #1455, thanks @yovinchen)
|
||||
|
||||
### Skill ZIP Install Target App
|
||||
|
||||
- ZIP skill installs now use the currently active app instead of always defaulting to Claude
|
||||
|
||||
### OpenClaw Active Card Highlight
|
||||
|
||||
- Fixed active OpenClaw provider card not being highlighted (#1419, thanks @funnytime75)
|
||||
|
||||
### Responsive Layout with TOC
|
||||
|
||||
- Improved responsive design when TOC title exists (#1491, thanks @West-Pavilion)
|
||||
|
||||
### Import Skills Dialog White Screen
|
||||
|
||||
- Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog
|
||||
|
||||
### Panel Bottom Blank Area
|
||||
|
||||
- Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values on different platforms
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Pricing Model ID Normalization
|
||||
|
||||
- Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@`→`-` replacement) in EN/ZH/JA user manuals (#1591, thanks @makoMakoGo)
|
||||
|
||||
### macOS Signed Build Messaging
|
||||
|
||||
- Removed all `xattr` workaround instructions and "unidentified developer" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with "signed and notarized by Apple" messaging
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Risk Notice
|
||||
|
||||
**GitHub Copilot Reverse Proxy Disclaimer**
|
||||
|
||||
The Copilot reverse proxy feature introduced in this release accesses GitHub Copilot services through reverse-engineered, unofficial APIs. Please be aware of the following risks before enabling this feature:
|
||||
|
||||
1. **Terms of Service**: This feature may violate [GitHub's Acceptable Use Policies](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies) and [Terms for Additional Products and Features](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features), which prohibit excessive automated bulk activity, unauthorized service reproduction, and placing undue burden on servers through automated means.
|
||||
2. **Account Risk**: There are documented cases of GitHub issuing warning emails to users of similar tools, citing "scripted interactions or otherwise deliberately unusual or strenuous" usage patterns. Continued use after a warning may result in temporary or permanent suspension of Copilot access.
|
||||
3. **No Guarantee**: GitHub may update its detection mechanisms at any time, and usage patterns that work today may be flagged in the future.
|
||||
|
||||
Users enable this feature **at their own risk**. CC Switch is not responsible for any account restrictions, warnings, or service suspensions resulting from the use of this feature.
|
||||
|
||||
---
|
||||
|
||||
## Download & Installation
|
||||
|
||||
Visit [Releases](https://github.com/farion1231/cc-switch/releases/latest) to download the appropriate version.
|
||||
|
||||
### System Requirements
|
||||
|
||||
| System | Minimum Version | Architecture |
|
||||
| ------- | ------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 or later | x64 |
|
||||
| macOS | macOS 12 (Monterey) or later | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | See table below | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| File | Description |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **Recommended** - MSI installer with auto-update |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | Portable version, extract and run, no registry write |
|
||||
|
||||
### macOS
|
||||
|
||||
| File | Description |
|
||||
| ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **Recommended** - DMG installer, drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | ZIP archive, extract and drag to Applications, Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | For Homebrew installation and auto-update |
|
||||
|
||||
> macOS builds are code-signed and notarized by Apple for a seamless install experience.
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
Update:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| Distribution | Recommended Format | Installation Method |
|
||||
| --------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
|
||||
| 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` | Add execute permission and run directly, or use AUR |
|
||||
| Other distributions / Unsure | `.AppImage` | `chmod +x CC-Switch-*.AppImage && ./CC-Switch-*.AppImage` |
|
||||
@@ -0,0 +1,313 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot リバースプロキシ、macOS コード署名と公証、Reasoning Effort マッピング、Tool Search 環境変数トグル、Skill バックアップ/リストア、OpenCode SQLite バックエンド
|
||||
|
||||
**[中文版 →](v3.12.3-zh.md) | [English →](v3.12.3-en.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概要
|
||||
|
||||
CC Switch v3.12.3 は、GitHub Copilot リバースプロキシと Copilot Auth Center を追加し、Copilot トークンを使用した Claude/OpenAI API へのアクセスを実現しました。macOS ビルドに Apple コード署名と公証を導入し、「開発元を確認できません」の警告を解消しました。Reasoning Effort マッピングにより、Claude の thinking budget を OpenAI 互換の reasoning_effort パラメータに自動変換します。Tool Search は従来のバイナリパッチ方式から Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数トグルに移行し、共通設定エディタから切り替え可能になりました。OpenCode バックエンドを JSON から SQLite に移行し、Skill バックアップ/リストアライフサイクル、プロキシ gzip 圧縮、o シリーズモデル互換性の改善も含まれます。
|
||||
|
||||
**リリース日**: 2026-03-24
|
||||
|
||||
**更新規模**: 36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## ハイライト
|
||||
|
||||
- **GitHub Copilot リバースプロキシ**: Copilot トークンを使用して Claude/OpenAI API にアクセスするリバースプロキシを追加。Copilot Auth Center でトークンの取得と管理が可能([⚠️ リスクに関する注意事項](#️-リスクに関する注意事項))
|
||||
- **macOS コード署名と公証**: macOS ビルドが Apple のコード署名と公証に対応し、初回起動時の警告なしでインストール可能に。DMG インストーラーを新たに提供
|
||||
- **Reasoning Effort マッピング**: プロキシ層での自動マッピング — 明示的な `output_config.effort` を優先し、`budget_tokens` 閾値(<4000→low, 4000–16000→medium, ≥16000→high)にフォールバック。o シリーズおよび GPT-5+ モデルに対応
|
||||
- **Tool Search 環境変数トグル**: バイナリパッチ方式を廃止し、Claude 2.1.76+ ネイティブの `ENABLE_TOOL_SEARCH` 環境変数による切り替えに移行。共通設定エディタから設定可能
|
||||
- **Skill バックアップ/リストアライフサイクル**: アンインストール前に Skill ファイルを自動バックアップ。バックアップリスト、リストア、削除の管理機能を追加
|
||||
- **OpenCode SQLite バックエンド**: OpenCode に SQLite セッションストレージを追加(既存の JSON バックエンドと併存)。ID 競合時は SQLite を優先するデュアルバックエンドスキャン
|
||||
- **Codex 1M コンテキストウィンドウトグル**: 設定エディタでワンクリックで `model_context_window = 1000000` を設定可能。`model_auto_compact_token_limit` も自動設定
|
||||
- **自動アップグレード無効化トグル**: Claude 共通設定エディタに `DISABLE_AUTOUPDATER` 環境変数のチェックボックスを追加し、Claude Code の自動アップグレードを防止
|
||||
- **プロキシ Gzip 圧縮**: 非ストリーミングプロキシリクエストが gzip 圧縮を自動ネゴシエーションし、帯域幅消費を削減
|
||||
- **o シリーズモデル互換性**: Chat Completions プロキシが o1/o3/o4-mini モデルに `max_completion_tokens` を正しく使用。Responses API は正しい `max_output_tokens` フィールドを維持
|
||||
- **Skills インポートの刷新**: ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、複数アプリの誤った有効化を防止
|
||||
- **Ghostty ターミナルサポート**: Ghostty ターミナルでの Claude セッション復元を修正
|
||||
|
||||
---
|
||||
|
||||
## 新機能
|
||||
|
||||
### GitHub Copilot リバースプロキシ
|
||||
|
||||
GitHub Copilot トークンを使用して Claude API および OpenAI API にアクセスするリバースプロキシ機能を追加しました。
|
||||
|
||||
- Copilot のアクセストークンを利用し、Claude Code や Codex などのクライアントからプロキシ経由で API リクエストを転送
|
||||
- Copilot 固有のリクエストフィンガープリントとヘッダー処理に対応
|
||||
- プロバイダープリセットに Copilot 用テンプレートを追加
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
Copilot トークンの取得と管理を行う認証センターを追加しました。
|
||||
|
||||
- GitHub デバイスフローによるトークン取得をサポート
|
||||
- トークンの有効期限管理と自動リフレッシュ
|
||||
- フロントエンドから直接トークンステータスの確認と再認証が可能
|
||||
|
||||
### Reasoning Effort マッピング
|
||||
|
||||
OpenAI o シリーズおよび GPT-5+ モデル向けのプロキシ層自動マッピング機能を追加しました。
|
||||
|
||||
- 二段階の解決ロジック:明示的な `output_config.effort` を優先し、thinking `budget_tokens` 閾値(<4000→low, 4000–16000→medium, ≥16000→high)にフォールバック
|
||||
- Chat Completions と Responses API の両パスをカバー、17 個のユニットテスト付き
|
||||
|
||||
### Tool Search 環境変数トグル
|
||||
|
||||
Claude CLI Tool Search の有効化/無効化を環境変数で制御する設定を追加しました。
|
||||
|
||||
- Claude 2.1.76+ で導入されたネイティブの `ENABLE_TOOL_SEARCH` 環境変数を使用
|
||||
- 共通設定(Common Config)エディタから直接トグル可能
|
||||
- 従来のバイナリパッチ方式は不要になり、CLI アップデート時の再適用も不要
|
||||
|
||||
### Skill アンインストール時の自動バックアップ
|
||||
|
||||
アンインストール前に Skill ファイルを自動バックアップし、意図しないデータ損失を防止します。
|
||||
|
||||
- バックアップは `~/.cc-switch/skill-backups/` に保存され、すべての skill ファイルと元のメタデータを含む `meta.json` が含まれます
|
||||
- 古いバックアップは自動的にプルーニングされ、最大 20 個を保持
|
||||
- バックアップパスはフロントエンドに返され、成功トーストに表示
|
||||
|
||||
### Skill バックアップのリストアと削除
|
||||
|
||||
アンインストール時に作成された Skill バックアップの管理コマンドを追加しました。
|
||||
|
||||
- すべての利用可能な skill バックアップをメタデータ付きで一覧表示
|
||||
- リストアはファイルを SSOT にコピーし、DB レコードを保存し、現在のアプリに同期。失敗時は自動ロールバック
|
||||
- 削除は確認ダイアログの後にバックアップディレクトリを削除
|
||||
- ConfirmDialog にネストされたダイアログスタッキングをサポートする設定可能な zIndex プロパティを追加
|
||||
|
||||
### OpenCode SQLite バックエンド
|
||||
|
||||
OpenCode に SQLite セッションストレージサポートを追加しました(既存の JSON バックエンドと併存)。
|
||||
|
||||
- デュアルバックエンドスキャン、ID 競合時は SQLite を優先
|
||||
- アトミックなセッション削除とパス検証
|
||||
- JSON バックエンドは後方互換性のため引き続き機能
|
||||
|
||||
### Codex 1M コンテキストウィンドウトグル
|
||||
|
||||
設定エディタに Codex 1M コンテキストウィンドウのワンクリックトグルを追加しました。
|
||||
|
||||
- チェックボックスで `config.toml` に `model_context_window = 1000000` を設定
|
||||
- 有効化時に `model_auto_compact_token_limit = 900000` を自動設定
|
||||
- 無効化時は両フィールドをクリーンに削除
|
||||
|
||||
### 自動アップグレード無効化トグル
|
||||
|
||||
Claude 共通設定エディタに自動アップグレードを無効化するチェックボックスを追加しました。
|
||||
|
||||
- 有効化時に `DISABLE_AUTOUPDATER=1` 環境変数を設定し、Claude Code の自動アップグレードを防止
|
||||
- Teammates モード、Tool Search、高強度思考トグルと同じ行に表示
|
||||
|
||||
### macOS コード署名と公証
|
||||
|
||||
macOS ビルドに Apple のコード署名と公証を導入しました。
|
||||
|
||||
- Apple Developer ID による署名と Apple 公証サービスによる公証を実施
|
||||
- 初回起動時の「開発元を確認できません」警告が不要に
|
||||
- DMG インストーラーを新たに提供し、ドラッグ&ドロップでのインストールに対応
|
||||
- CI/CD パイプラインに署名・公証ステップを統合
|
||||
|
||||
---
|
||||
|
||||
## 変更
|
||||
|
||||
### Skills キャッシュ戦略の最適化
|
||||
|
||||
Skills のキャッシュ戦略を最適化し、パフォーマンスと信頼性を向上しました。
|
||||
|
||||
- キャッシュの有効期限管理とインバリデーション戦略を改善
|
||||
- 不要なキャッシュ再構築を削減し、起動時間を短縮
|
||||
|
||||
### Claude 4.6 コンテキストウィンドウ更新
|
||||
|
||||
Claude 4.6 モデルのコンテキストウィンドウサイズを更新しました。
|
||||
|
||||
- Claude 4.6 の最新コンテキストウィンドウサイズをプリセットに反映
|
||||
|
||||
### MiniMax M2.7 アップグレード
|
||||
|
||||
MiniMax モデルプリセットを M2.7 にアップグレードしました。
|
||||
|
||||
- MiniMax プロバイダープリセットのモデル ID とパラメータを M2.7 に更新
|
||||
|
||||
### Xiaomi MiMo アップグレード
|
||||
|
||||
Xiaomi MiMo モデルプリセットをアップグレードしました。
|
||||
|
||||
- MiMo プロバイダープリセットのモデル ID とパラメータを最新版に更新
|
||||
|
||||
### AddProviderDialog の簡素化
|
||||
|
||||
- 冗長な OAuth タブを削除し、ダイアログを 3 タブから 2 タブ(アプリ固有 + ユニバーサル)に簡素化
|
||||
|
||||
### プロバイダーフォームの高度なオプション折りたたみ
|
||||
|
||||
- Claude プロバイダーフォームのモデルマッピング、API フォーマットなどの高度なフィールドが未入力時にデフォルトで折りたたまれるように変更
|
||||
- プリセットが値を入力すると自動展開。手動クリア時は自動折りたたみしない
|
||||
|
||||
### プロキシ Gzip 圧縮
|
||||
|
||||
非ストリーミングプロキシリクエストが gzip 圧縮をサポートし、帯域幅消費を削減しました。
|
||||
|
||||
- 非ストリーミングリクエストは reqwest が gzip を自動ネゴシエーションし、レスポンスを透過的に解凍
|
||||
- ストリーミングリクエストは中断された SSE ストリームの解凍エラーを避けるため、保守的に `Accept-Encoding: identity` を維持
|
||||
|
||||
### o1/o3 モデル互換性
|
||||
|
||||
プロキシ転送が OpenAI o シリーズモデルのトークンパラメータを正しく処理するようになりました。
|
||||
|
||||
- Chat Completions パスが o1/o3/o4-mini モデルに `max_tokens` の代わりに `max_completion_tokens` を使用 (#1451、@Hemilt0n に感謝)
|
||||
- Responses API パスが正しい `max_output_tokens` フィールドを維持し、`max_completion_tokens` の誤った注入を防止
|
||||
|
||||
### OpenCode モデルバリアント
|
||||
|
||||
- OpenCode のモデルバリアントを options 内部ではなくプリセットのトップレベルに配置し、発見しやすさを向上 (#1317)
|
||||
|
||||
### Skills インポートフロー
|
||||
|
||||
Skills インポートフローが正確性とクリーンアップのためにリワークされました。
|
||||
|
||||
- ファイルシステムベースの暗黙的なアプリ推論を明示的な `ImportSkillSelection` に置き換え、同じ skill ディレクトリが複数アプリパスに存在する場合の複数アプリ誤有効化を防止
|
||||
- `sync_to_app` に調整ロジックを追加し、無効化/孤立したシンボリックリンクを削除
|
||||
- MCP `sync_all_enabled` がライブ設定から無効化されたサーバーを削除するように改善
|
||||
- スキーママイグレーションがレガシーアプリマッピングのスナップショットを保持し、損失のある再構築を回避
|
||||
|
||||
---
|
||||
|
||||
## バグ修正
|
||||
|
||||
### WebDAV パスワードの消失
|
||||
|
||||
- 無関係な設定保存時に WebDAV パスワードがサイレントにクリアされる問題を修正
|
||||
|
||||
### ツールメッセージのパース
|
||||
|
||||
- プロキシのツールメッセージパース処理の不具合を修正し、特定のツール呼び出しパターンでのエラーを解消
|
||||
|
||||
### ダークモードの表示
|
||||
|
||||
- ダークモードでの一部 UI コンポーネントの表示不具合を修正
|
||||
|
||||
### Copilot リクエストフィンガープリント
|
||||
|
||||
- Copilot リバースプロキシのリクエストフィンガープリント生成の不具合を修正し、認証エラーを解消
|
||||
|
||||
### プロバイダーフォームの二重送信
|
||||
|
||||
- プロバイダー追加/編集フォームでの高速連続クリックによる重複送信を防止 (#1352、@Hexi1997 に感謝)
|
||||
|
||||
### Ghostty ターミナルセッション復元
|
||||
|
||||
- Ghostty ターミナルでの Claude セッション復元の失敗を修正 (#1506、@canyonsehun に感謝)
|
||||
|
||||
### Skill ZIP インポート拡張子
|
||||
|
||||
- ZIP インポートダイアログが `.skill` ファイル拡張子をサポートするように修正 (#1240, #1455、@yovinchen に感謝)
|
||||
|
||||
### Skill ZIP インストール対象アプリ
|
||||
|
||||
- ZIP 方式でインストールされた skill が常に Claude をデフォルトにするのではなく、現在アクティブなアプリを使用するように修正
|
||||
|
||||
### OpenClaw アクティブカードのハイライト
|
||||
|
||||
- OpenClaw の現在アクティブなプロバイダーカードがハイライト表示されない問題を修正 (#1419、@funnytime75 に感謝)
|
||||
|
||||
### TOC 付きレスポンシブレイアウト
|
||||
|
||||
- TOC タイトルが存在する場合のレスポンシブデザインを改善 (#1491、@West-Pavilion に感謝)
|
||||
|
||||
### Skills インポートダイアログの白い画面
|
||||
|
||||
- ImportSkillsDialog に不足していた TooltipProvider を追加し、ダイアログを開く際のランタイムクラッシュを防止
|
||||
|
||||
### パネル下部の空白エリア
|
||||
|
||||
- すべてのコンテンツパネルのハードコードされた `h-[calc(100vh-8rem)]` を `flex-1 min-h-0` に置き換え、異なるプラットフォーム間のオフセット値の不一致による下部のギャップを解消
|
||||
|
||||
---
|
||||
|
||||
## ドキュメント
|
||||
|
||||
### 料金モデル ID の正規化
|
||||
|
||||
- 中英日三言語のユーザーマニュアルにモデル ID 正規化ルール(プレフィックス除去、サフィックストリミング、`@`→`-` 置換)の説明セクションを追加 (#1591、@makoMakoGo に感謝)
|
||||
|
||||
### macOS 署名済みメッセージの更新
|
||||
|
||||
- README、README_ZH、インストールガイド(EN/ZH/JA)、FAQ ページ(EN/ZH/JA)からすべての `xattr` 回避策と「開発元を確認できません」警告を削除し、「Apple のコード署名と公証済み」メッセージに置換
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ リスクに関する注意事項
|
||||
|
||||
**GitHub Copilot リバースプロキシに関する免責事項**
|
||||
|
||||
本リリースで追加された Copilot リバースプロキシ機能は、リバースエンジニアリングによる非公式 API を通じて GitHub Copilot サービスにアクセスします。この機能を有効にする前に、以下のリスクをご確認ください:
|
||||
|
||||
1. **利用規約違反の可能性**:この機能は [GitHub 利用規約](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)および[追加製品の利用条件](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features)に違反する可能性があります。これらの規約では、過度な自動一括操作、サービスの無断複製、自動化手段によるサーバーへの過度な負荷が禁止されています。
|
||||
2. **アカウントリスク**:類似ツールの利用者が GitHub から「スクリプト化されたインタラクション、または意図的に異常もしくは過度な使用」を指摘する警告メールを受け取った事例が報告されています。警告後も使用を継続した場合、Copilot へのアクセスが一時的または永久的に停止される可能性があります。
|
||||
3. **将来の利用保証なし**:GitHub は検出メカニズムをいつでも更新する可能性があり、現在利用可能な使用パターンが将来的にフラグ付けされる可能性があります。
|
||||
|
||||
この機能を有効にすることで、ユーザーは**すべてのリスクを自己責任で負う**ものとします。CC Switch は、この機能の使用に起因するアカウント制限、警告、またはサービス停止について一切の責任を負いません。
|
||||
|
||||
---
|
||||
|
||||
## ダウンロードとインストール
|
||||
|
||||
[Releases](https://github.com/farion1231/cc-switch/releases/latest) から適切なバージョンをダウンロードしてください。
|
||||
|
||||
### システム要件
|
||||
|
||||
| システム | 最小バージョン | アーキテクチャ |
|
||||
| -------- | -------------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 以降 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 以降 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 下表参照 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ------------------------------------------ | ---------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **推奨** - MSI インストーラー、自動更新対応 |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | ポータブル版、解凍して実行、レジストリ書き込みなし |
|
||||
|
||||
### macOS
|
||||
|
||||
| ファイル | 説明 |
|
||||
| ---------------------------------- | ----------------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **推奨** - DMG インストーラー、ドラッグ&ドロップでインストール |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | 解凍して Applications にドラッグ、Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | Homebrew インストールと自動更新用 |
|
||||
|
||||
> macOS 版は Apple のコード署名と公証済みで、そのままインストールしてご利用いただけます。
|
||||
|
||||
### Homebrew (macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| ディストリビューション | 推奨形式 | インストール方法 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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,296 @@
|
||||
# CC Switch v3.12.3
|
||||
|
||||
> GitHub Copilot 反向代理、macOS 代码签名与公证、Reasoning Effort 映射、Tool Search 环境变量开关、Skill 备份/恢复生命周期
|
||||
|
||||
**[English →](v3.12.3-en.md) | [日本語版 →](v3.12.3-ja.md)**
|
||||
|
||||
---
|
||||
|
||||
## 概览
|
||||
|
||||
CC Switch v3.12.3 新增了 **GitHub Copilot 反向代理** 支持和 **Copilot Auth Center** 认证管理,引入了 **Reasoning Effort 映射** 实现跨供应商推理强度控制,通过 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量实现了 **Tool Search 开关**,新增了 **OpenCode SQLite 后端** 支持,并完成了 **macOS 代码签名与 Apple 公证**。同时引入了完整的 Skill 备份/恢复生命周期,改进了代理对 OpenAI o 系列模型的兼容性和 gzip 压缩支持,优化了 Skills 缓存策略,更新了 Claude 4.6 上下文窗口、MiniMax M2.7 和小米 MiMo 模型预设,并修复了 WebDAV 密码、工具消息解析、暗色模式和 Copilot 请求指纹等方面的问题。
|
||||
|
||||
**发布日期**:2026-03-24
|
||||
|
||||
**更新规模**:36 commits | 107 files changed | +9,124 / -802 lines
|
||||
|
||||
---
|
||||
|
||||
## 重点内容
|
||||
|
||||
- **GitHub Copilot 反向代理**:新增 Copilot 反向代理支持,通过 Copilot Auth Center 管理 GitHub Token 认证,实现 Copilot 模型在 Claude Code 中的无缝使用([⚠️ 风险提示](#️-风险提示))
|
||||
- **macOS 代码签名与公证**:macOS 版本已通过 Apple 代码签名和公证,新增 DMG 安装格式,无需再手动绕过"未知开发者"警告
|
||||
- **Reasoning Effort 映射**:代理层自动映射 — 显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 4000–16000→medium, ≥16000→high),支持 o 系列和 GPT-5+ 模型
|
||||
- **Tool Search 环境变量开关**:利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量,在通用配置编辑器中一键启用 Tool Search
|
||||
- **Skill 备份/恢复生命周期**:卸载前自动备份 Skill 文件;新增备份列表、恢复和删除管理
|
||||
- **OpenCode SQLite 后端**:为 OpenCode 新增 SQLite 会话存储(与现有 JSON 后端并存),ID 冲突时 SQLite 优先的双后端扫描
|
||||
- **Codex 1M 上下文窗口开关**:配置编辑器中一键设置 `model_context_window = 1000000`,自动填充 `model_auto_compact_token_limit`
|
||||
- **禁用自动升级开关**:通用配置编辑器中新增 `DISABLE_AUTOUPDATER` 环境变量复选框,防止 Claude Code 自动升级
|
||||
- **代理 Gzip 压缩**:非流式代理请求自动协商 gzip 压缩,减少带宽消耗
|
||||
- **o 系列模型兼容性**:Chat Completions 代理正确使用 `max_completion_tokens` 处理 o1/o3/o4-mini 模型
|
||||
- **Skills 导入重构**:将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止多应用错误激活
|
||||
|
||||
---
|
||||
|
||||
## 新功能
|
||||
|
||||
### GitHub Copilot 反向代理
|
||||
|
||||
新增完整的 GitHub Copilot 集成,作为 Claude Code 供应商使用。
|
||||
|
||||
- 通过 OAuth Device Code 流程进行 GitHub 认证
|
||||
- 支持多账号管理和自动 Token 刷新
|
||||
- Anthropic ↔ OpenAI 格式自动转换
|
||||
- 实时获取可用模型列表和用量统计 (#930,感谢 @Mason-mengze)
|
||||
|
||||
### Copilot Auth Center
|
||||
|
||||
在设置中新增认证中心面板,全局管理 GitHub 账号。
|
||||
|
||||
- 支持按供应商绑定账号(通过 `meta.authBinding`)
|
||||
- 统一的 Token 管理和刷新机制
|
||||
|
||||
### Tool Search 开关
|
||||
|
||||
利用 Claude 2.1.76+ 原生 `ENABLE_TOOL_SEARCH` 环境变量控制 Tool Search 功能。
|
||||
|
||||
- 在供应商通用配置编辑器中以复选框形式暴露
|
||||
- 替代了之前的二进制补丁方案,更简洁可靠 (#930,感谢 @Mason-mengze)
|
||||
|
||||
### Reasoning Effort 映射
|
||||
|
||||
新增代理层自动推理强度映射,支持 OpenAI o 系列和 GPT-5+ 模型。
|
||||
|
||||
- 两级解析:显式 `output_config.effort` 优先,回退到 `budget_tokens` 阈值(<4000→low, 4000–16000→medium, ≥16000→high)
|
||||
- 覆盖 Chat Completions 和 Responses API 两条路径,含 17 个单元测试
|
||||
|
||||
### OpenCode SQLite 后端
|
||||
|
||||
为 OpenCode 新增 SQLite 会话存储支持(与现有 JSON 后端并存)。
|
||||
|
||||
- 双后端扫描,ID 冲突时 SQLite 优先
|
||||
- 原子会话删除和路径校验
|
||||
- JSON 后端保持向后兼容
|
||||
|
||||
### Codex 1M 上下文窗口开关
|
||||
|
||||
在配置编辑器中新增 Codex 1M 上下文窗口一键开关。
|
||||
|
||||
- 复选框设置 `config.toml` 中的 `model_context_window = 1000000`
|
||||
- 启用时自动填充 `model_auto_compact_token_limit = 900000`
|
||||
- 关闭时干净移除两个字段
|
||||
|
||||
### 禁用自动升级开关
|
||||
|
||||
在 Claude 通用配置编辑器中新增禁用自动升级的复选框。
|
||||
|
||||
- 勾选后设置 `DISABLE_AUTOUPDATER=1` 环境变量,阻止 Claude Code 自动升级
|
||||
- 与 Teammates 模式、Tool Search、高强度思考等开关同一排显示
|
||||
|
||||
### Skill 卸载自动备份
|
||||
|
||||
卸载 Skill 前自动备份文件,防止数据意外丢失。
|
||||
|
||||
- 备份存储在 `~/.cc-switch/skill-backups/`,包含所有 skill 文件和记录原始元数据的 `meta.json`
|
||||
- 旧备份自动清理,最多保留 20 个
|
||||
- 备份路径返回前端并在成功提示中显示
|
||||
|
||||
### Skill 备份恢复与删除
|
||||
|
||||
新增卸载时创建的 Skill 备份的管理功能。
|
||||
|
||||
- 列出所有可用的 skill 备份及元数据
|
||||
- 恢复操作将文件拷回 SSOT,保存数据库记录,并同步到当前应用,失败时自动回滚
|
||||
- 删除操作在确认对话框后移除备份目录
|
||||
|
||||
### macOS 代码签名与 Apple 公证
|
||||
|
||||
CI 流程新增完整的 macOS 代码签名和 Apple 公证支持。
|
||||
|
||||
- 导入 Apple Developer ID 证书,签名 Universal Binary
|
||||
- 提交 Apple 公证并将票据装订到 `.app` 和 `.dmg`
|
||||
- 硬性验证步骤(`codesign --verify` + `spctl -a` + `stapler validate`)把关发布
|
||||
|
||||
---
|
||||
|
||||
## 变更
|
||||
|
||||
### Skills 缓存策略优化
|
||||
|
||||
- 将 `invalidateQueries` 替换为直接 `setQueryData` 更新,用于 skill 安装/卸载/导入操作
|
||||
- 新增 `staleTime: Infinity` 和 `keepPreviousData`,消除加载闪烁 (#1573,感谢 @TangZhiZzz)
|
||||
|
||||
### 代理 Gzip 压缩
|
||||
|
||||
- 非流式请求允许 reqwest 自动协商 gzip 并透明解压响应
|
||||
- 流式请求保守地保持 `Accept-Encoding: identity`,避免中断的 SSE 流解压出错
|
||||
|
||||
### o1/o3 模型兼容性
|
||||
|
||||
- Chat Completions 路径对 o1/o3/o4-mini 模型使用 `max_completion_tokens` 替代 `max_tokens` (#1451,感谢 @Hemilt0n)
|
||||
- Responses API 路径保持使用正确的 `max_output_tokens` 字段
|
||||
|
||||
### OpenCode 模型变体
|
||||
|
||||
- 将 OpenCode 的模型变体放在预设顶层而非嵌套在 options 内部,提升可发现性 (#1317)
|
||||
|
||||
### Skills 导入流程
|
||||
|
||||
- 将基于文件系统的隐式应用推断替换为显式的 `ImportSkillSelection`,防止同一 skill 目录存在于多个应用路径下时错误激活多个应用
|
||||
- 为 `sync_to_app` 增加协调逻辑,移除已禁用/孤立的符号链接
|
||||
- MCP `sync_all_enabled` 现在会从 live 配置中移除已禁用的服务器
|
||||
|
||||
### Claude 4.6 上下文窗口
|
||||
|
||||
- Claude Opus 4.6 和 Sonnet 4.6 上下文窗口从 200K 更新至 1M(GA 发布)
|
||||
|
||||
### MiniMax 模型升级
|
||||
|
||||
- MiniMax 预设从 M2.5 升级至 M2.7,更新三语合作伙伴描述
|
||||
|
||||
### 小米 MiMo 模型升级
|
||||
|
||||
- MiMo 预设从 mimo-v2-flash 升级至 mimo-v2-pro
|
||||
|
||||
### 添加供应商对话框简化
|
||||
|
||||
- 移除冗余的 OAuth 标签页,对话框从 3 个标签页减少到 2 个(应用专属 + 通用)
|
||||
|
||||
### 供应商表单高级选项折叠
|
||||
|
||||
- Claude 供应商表单中的模型映射、API 格式等高级字段在未填写时默认折叠
|
||||
- 预设填充值后自动展开,手动清空不会自动折叠
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复
|
||||
|
||||
### WebDAV 密码被静默清除
|
||||
|
||||
- 修复 ProviderList 或 UsageScriptModal 保存设置时 WebDAV 密码被静默清除的问题
|
||||
- 前端 payload 中剥离 `webdavSync`,后端 `merge_settings_for_save()` 增加回填逻辑保护现有密码
|
||||
|
||||
### 工具消息解析
|
||||
|
||||
- 修复 Claude(tool_result content blocks)、Codex(function_call/function_call_output payloads)和 Gemini(array content + toolCalls extraction)的 tool_use/tool_result 消息分类 (#1401,感谢 @BlueOcean223)
|
||||
|
||||
### 暗色模式选择器
|
||||
|
||||
- 将 Tailwind `darkMode` 从 `["selector", "class"]` 改为 `["selector", ".dark"]`,确保暗色模式正确激活 (#1596,感谢 @qinxiandiqi)
|
||||
|
||||
### Copilot 请求指纹
|
||||
|
||||
- 统一所有 Copilot API 调用点的请求指纹头,防止 User-Agent 泄漏和 Stream Check 不匹配
|
||||
|
||||
### 供应商表单防重复提交
|
||||
|
||||
- 修复快速连续点击按钮时供应商添加/编辑表单重复提交的问题 (#1352,感谢 @Hexi1997)
|
||||
|
||||
### Ghostty 终端会话恢复
|
||||
|
||||
- 修复在 Ghostty 终端中恢复 Claude 会话失败的问题 (#1506,感谢 @canyonsehun)
|
||||
|
||||
### Skill ZIP 导入扩展名
|
||||
|
||||
- ZIP 导入对话框现在支持 `.skill` 文件扩展名 (#1240, #1455,感谢 @yovinchen)
|
||||
|
||||
### Skill ZIP 安装目标应用
|
||||
|
||||
- ZIP 方式安装的 skill 现在使用当前活跃应用,而非始终默认为 Claude
|
||||
|
||||
### OpenClaw 活跃供应商高亮
|
||||
|
||||
- 修复 OpenClaw 当前激活的供应商卡片未高亮显示的问题 (#1419,感谢 @funnytime75)
|
||||
|
||||
### 响应式布局与 TOC
|
||||
|
||||
- 改善存在 TOC 标题时的响应式布局 (#1491,感谢 @West-Pavilion)
|
||||
|
||||
### Skills 导入对话框白屏
|
||||
|
||||
- 在 ImportSkillsDialog 中补充缺失的 TooltipProvider,修复打开对话框时的运行时崩溃
|
||||
|
||||
### 面板底部空白区域
|
||||
|
||||
- 将所有内容面板的硬编码 `h-[calc(100vh-8rem)]` 替换为 `flex-1 min-h-0`,消除因不同平台偏移量不匹配导致的底部空白
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
### 定价模型 ID 归一化
|
||||
|
||||
- 在中英日三语用户手册中新增模型 ID 归一化规则说明(前缀剥离、后缀修剪、`@`→`-` 替换)(#1591,感谢 @makoMakoGo)
|
||||
|
||||
### macOS 签名与公证说明
|
||||
|
||||
- 移除 README、安装指南和 FAQ 中所有 `xattr` 变通方案和"未知开发者"警告
|
||||
- 替换为"已通过 Apple 代码签名和公证"的说明
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 风险提示
|
||||
|
||||
**GitHub Copilot 反向代理免责声明**
|
||||
|
||||
本版本新增的 Copilot 反向代理功能通过逆向工程的非官方 API 访问 GitHub Copilot 服务。启用此功能前,请注意以下风险:
|
||||
|
||||
1. **违反服务条款**:此功能可能违反 [GitHub 可接受使用政策](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)和[附加产品条款](https://docs.github.com/en/site-policy/github-terms/github-terms-for-additional-products-and-features),其中禁止过度自动化批量活动、未经授权的服务复制以及通过自动化手段对服务器施加不当负担。
|
||||
2. **账号风险**:已有类似工具的用户收到 GitHub 官方警告邮件,指出其存在"脚本化交互或其他刻意的异常或高强度使用"行为。收到警告后继续使用可能导致 Copilot 访问权限被暂停甚至永久封禁。
|
||||
3. **无法保证长期可用**:GitHub 可能随时更新其检测机制,当前可用的使用方式未来可能被标记。
|
||||
|
||||
用户启用此功能即表示**自行承担所有风险**。CC Switch 不对因使用此功能而导致的任何账号限制、警告或服务暂停承担责任。
|
||||
|
||||
---
|
||||
|
||||
## 下载与安装
|
||||
|
||||
访问 [Releases](https://github.com/farion1231/cc-switch/releases/latest) 下载对应版本。
|
||||
|
||||
### 系统要求
|
||||
|
||||
| 系统 | 最低版本 | 架构 |
|
||||
| ------- | ----------------------------- | ----------------------------------- |
|
||||
| Windows | Windows 10 及以上 | x64 |
|
||||
| macOS | macOS 12 (Monterey) 及以上 | Intel (x64) / Apple Silicon (arm64) |
|
||||
| Linux | 见下表 | x64 |
|
||||
|
||||
### Windows
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ------------------------------------------ | ----------------------------------- |
|
||||
| `CC-Switch-v3.12.3-Windows.msi` | **推荐** - MSI 安装包,支持自动更新 |
|
||||
| `CC-Switch-v3.12.3-Windows-Portable.zip` | 便携版,解压即用,不写入注册表 |
|
||||
|
||||
### macOS
|
||||
|
||||
| 文件 | 说明 |
|
||||
| ---------------------------------- | --------------------------------------------------------- |
|
||||
| `CC-Switch-v3.12.3-macOS.dmg` | **推荐** - DMG 安装包,拖入 Applications 即可 |
|
||||
| `CC-Switch-v3.12.3-macOS.zip` | 解压后拖入 Applications,Universal Binary |
|
||||
| `CC-Switch-v3.12.3-macOS.tar.gz` | 用于 Homebrew 安装和自动更新 |
|
||||
|
||||
> macOS 版本已通过 Apple 代码签名和公证,可直接安装使用。
|
||||
|
||||
### Homebrew(macOS)
|
||||
|
||||
```bash
|
||||
brew tap farion1231/ccswitch
|
||||
brew install --cask cc-switch
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```bash
|
||||
brew upgrade --cask cc-switch
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
| 发行版 | 推荐格式 | 安装方式 |
|
||||
| --------------------------------------- | ----------- | ---------------------------------------------------------------------- |
|
||||
| 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` |
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
## Version / 版本 / バージョン
|
||||
|
||||
- Documentation version: v3.11.1
|
||||
- Last updated: 2026-03-02
|
||||
- Compatible with CC Switch v3.11.1+
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Compatible with CC Switch v3.12.0+
|
||||
|
||||
## Links
|
||||
|
||||
|
||||
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
|
||||
2. Extract to get `CC Switch.app`
|
||||
3. Drag it to the Applications folder
|
||||
|
||||
### First Launch Warning
|
||||
### Signed and Notarized
|
||||
|
||||
Since the developer does not have an Apple Developer account, a "developer cannot be verified" warning may appear on first launch:
|
||||
|
||||
**Recommended solution**:
|
||||
Open Terminal and run the following command:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**Alternative solution (via System Settings)**:
|
||||
1. Close the warning dialog
|
||||
2. Open "System Settings" > "Privacy & Security"
|
||||
3. Find the CC Switch prompt and click "Open Anyway"
|
||||
4. Reopen the app to use it normally
|
||||
CC Switch for macOS is signed and notarized by Apple. You can install and open it directly — no extra steps needed.
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Bailian | Alibaba Cloud Bailian (Qwen) |
|
||||
| Kimi | Moonshot Kimi model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| ModelScope | ModelScope community |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Bailian | Alibaba Cloud Bailian |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| ModelScope | ModelScope community |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@ Presets are pre-configured provider templates that only require an API Key to us
|
||||
| Qwen Coder | Qwen coding model |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 model |
|
||||
| Kimi For Coding | Kimi coding-specific model |
|
||||
| StepFun | StepFun model |
|
||||
| MiniMax | MiniMax model |
|
||||
| MiniMax en | MiniMax (English version) |
|
||||
| KAT-Coder | KAT-Coder model |
|
||||
|
||||
@@ -186,6 +186,22 @@ Set prices for each model (per million tokens):
|
||||
| Cache Read Price | Price per million cache hit tokens |
|
||||
| Cache Creation Price | Price per million cache creation tokens |
|
||||
|
||||
### Model ID Normalization Rules
|
||||
|
||||
Before matching pricing, CC Switch normalizes the requested model ID:
|
||||
|
||||
- Remove everything before the last `/`
|
||||
- Remove everything after `:`
|
||||
- Replace `@` with `-`
|
||||
|
||||
When adding pricing entries, enter the normalized Model ID rather than the full raw model name from the request.
|
||||
|
||||
| Raw model name | Model ID to enter | Note |
|
||||
|-------|-------------------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | Removes the provider prefix |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | Removes the prefix and the `:` suffix |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | Replaces `@` with `-` |
|
||||
|
||||
### Operations
|
||||
|
||||
- **Add**: Click the "Add" button to add model pricing
|
||||
@@ -238,10 +254,14 @@ CC Switch includes preset official prices for common models (per million tokens)
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**Chinese Provider Models (CNY)**:
|
||||
**Chinese Provider Models**:
|
||||
|
||||
> Note: Currency follows each provider's official pricing page. StepFun is currently listed in USD.
|
||||
|
||||
| Model | Input | Output | Cache Read |
|
||||
|-------|-------|--------|------------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## Installation Issues
|
||||
|
||||
### macOS Shows "Unidentified Developer"
|
||||
### macOS Installation
|
||||
|
||||
**Problem**: First launch shows "Cannot open because it is from an unidentified developer"
|
||||
|
||||
**Solution 1**: Via System Settings
|
||||
1. Close the warning dialog
|
||||
2. Open "System Settings" > "Privacy & Security"
|
||||
3. Find the CC Switch prompt
|
||||
4. Click "Open Anyway"
|
||||
5. Reopen the app
|
||||
|
||||
**Solution 2**: Via Terminal command (recommended)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
The app can be opened normally after running this command.
|
||||
CC Switch for macOS is code-signed and notarized by Apple. You can download and install it directly without any additional steps. If you encounter issues, try downloading the latest version from the [Releases page](https://github.com/farion1231/cc-switch/releases).
|
||||
|
||||
### Windows: App Doesn't Launch After Installation
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ CC Switch User Manual
|
||||
|
||||
## Version Information
|
||||
|
||||
- Documentation version: v3.11.1
|
||||
- Last updated: 2026-03-02
|
||||
- Applicable to CC Switch v3.11.1+
|
||||
- Documentation version: v3.12.0
|
||||
- Last updated: 2026-03-09
|
||||
- Applicable to CC Switch v3.12.0+
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -131,21 +131,9 @@ brew upgrade --cask cc-switch
|
||||
2. 展開して `CC Switch.app` を取得
|
||||
3. 「アプリケーション」フォルダにドラッグ
|
||||
|
||||
### 初回起動時の警告
|
||||
### 署名・公証済み
|
||||
|
||||
開発者が Apple 開発者アカウントを持っていないため、初回起動時に「不明な開発者」の警告が表示される場合があります:
|
||||
|
||||
**推奨される解決方法**:
|
||||
ターミナルで以下のコマンドを実行してください:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**別の解決方法(システム設定から)**:
|
||||
1. 警告ダイアログを閉じる
|
||||
2. 「システム設定」→「プライバシーとセキュリティ」を開く
|
||||
3. CC Switch に関する表示を見つけ、「このまま開く」をクリック
|
||||
4. 再度アプリを開くと正常に使用可能
|
||||
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接インストールして開くことができます。
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 百炼 | アリクラウド百炼(通义千問) |
|
||||
| Kimi | Moonshot Kimi モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| ModelScope | 魔搭コミュニティ |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@
|
||||
| 百炼 | アリクラウド百炼 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| ModelScope | 魔搭コミュニティ |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@
|
||||
| Qwen Coder | 通义千問コーディングモデル |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |
|
||||
| Kimi For Coding | Kimi プログラミング専用モデル |
|
||||
| StepFun | StepFun モデル |
|
||||
| MiniMax | MiniMax モデル |
|
||||
| MiniMax en | MiniMax(英語版) |
|
||||
| KAT-Coder | KAT-Coder モデル |
|
||||
|
||||
@@ -186,6 +186,22 @@ Token 使用量の変化を表示:
|
||||
| キャッシュ読取価格 | 100 万キャッシュヒット Token あたりの価格 |
|
||||
| キャッシュ作成価格 | 100 万キャッシュ作成 Token あたりの価格 |
|
||||
|
||||
### モデル ID の正規化ルール
|
||||
|
||||
料金を照合する前に、CC Switch はリクエスト内のモデル ID を正規化します:
|
||||
|
||||
- 最後の `/` より前の接頭辞を削除
|
||||
- `:` 以降の接尾辞を削除
|
||||
- `@` を `-` に置換
|
||||
|
||||
料金設定では、リクエスト内の完全な元のモデル名ではなく、正規化後のモデル ID を入力してください。
|
||||
|
||||
| 元のモデル名 | 入力するモデル ID | 説明 |
|
||||
|------|------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | プロバイダー接頭辞を削除 |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 接頭辞と `:` 以降を削除 |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | `@` を `-` に置換 |
|
||||
|
||||
### 操作
|
||||
|
||||
- **追加**:「追加」ボタンで新しいモデル価格を追加
|
||||
@@ -238,10 +254,14 @@ CC Switch は一般的なモデルの公式価格(100 万 Token あたり)
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**中国メーカーのモデル(人民元)**:
|
||||
**中国メーカーのモデル**:
|
||||
|
||||
> 注: 通貨は各プロバイダーの公式料金ページに従います。StepFun は現在 USD 表記です。
|
||||
|
||||
| モデル | 入力 | 出力 | キャッシュ読取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## インストールに関する問題
|
||||
|
||||
### macOS で「不明な開発者」と表示される
|
||||
### macOS のインストール
|
||||
|
||||
**問題**:初回起動時に「開けません。身元不明の開発者のものです」と表示される
|
||||
|
||||
**解決方法 1**:システム設定から
|
||||
1. 警告ダイアログを閉じる
|
||||
2. 「システム設定」→「プライバシーとセキュリティ」を開く
|
||||
3. CC Switch に関する表示を見つける
|
||||
4. 「このまま開く」をクリック
|
||||
5. 再度アプリを開く
|
||||
|
||||
**解決方法 2**:ターミナルコマンドから(推奨)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
実行後、正常にアプリを開けるようになります。
|
||||
CC Switch の macOS 版は Apple のコード署名と公証を受けています。追加の操作なしで直接ダウンロードしてインストールできます。問題が発生した場合は、[Releases ページ](https://github.com/farion1231/cc-switch/releases) から最新版をダウンロードしてください。
|
||||
|
||||
### Windows でインストール後に起動できない
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ CC Switch ユーザーマニュアル
|
||||
|
||||
## バージョン情報
|
||||
|
||||
- ドキュメントバージョン:v3.11.1
|
||||
- 最終更新:2026-03-02
|
||||
- CC Switch v3.11.1+ 対応
|
||||
- ドキュメントバージョン:v3.12.0
|
||||
- 最終更新:2026-03-09
|
||||
- CC Switch v3.12.0+ 対応
|
||||
|
||||
## コントリビュート
|
||||
|
||||
|
||||
@@ -145,21 +145,9 @@ brew upgrade --cask cc-switch
|
||||
2. 解压得到 `CC Switch.app`
|
||||
3. 拖动到「应用程序」文件夹
|
||||
|
||||
### 首次打开提示
|
||||
### 已签名并公证
|
||||
|
||||
由于开发者没有 Apple 开发者账号,首次打开可能出现「未知开发者」警告:
|
||||
|
||||
**推荐解决方法**:
|
||||
打开终端执行以下命令:
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
**备选解决方法(通过系统设置)**:
|
||||
1. 关闭警告弹窗
|
||||
2. 打开「系统设置」→「隐私与安全性」
|
||||
3. 找到 CC Switch 相关提示,点击「仍要打开」
|
||||
4. 再次打开应用即可正常使用
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接安装打开,无需额外操作。
|
||||
|
||||
## Linux
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 百炼 | 阿里云百炼(通义千问) |
|
||||
| Kimi | Moonshot Kimi 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| ModelScope | 魔搭社区 |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -92,6 +93,7 @@
|
||||
| 百炼 | 阿里云百炼 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| ModelScope | 魔搭社区 |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
| Longcat | Longcat AI |
|
||||
@@ -124,6 +126,7 @@
|
||||
| Qwen Coder | 通义千问编码模型 |
|
||||
| Kimi k2.5 | Moonshot Kimi-k2.5 模型 |
|
||||
| Kimi For Coding | Kimi 编程专用模型 |
|
||||
| StepFun | 阶跃星辰 Step模型 |
|
||||
| MiniMax | MiniMax 模型 |
|
||||
| MiniMax en | MiniMax(英文版) |
|
||||
| KAT-Coder | KAT-Coder 模型 |
|
||||
@@ -352,4 +355,3 @@ CC Switch 支持两种方式导入供应商配置:
|
||||
- 🔴 红色:延迟 > 1000ms(较慢)
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -186,6 +186,22 @@
|
||||
| 缓存读取价格 | 每百万缓存命中 Token 的价格 |
|
||||
| 缓存创建价格 | 每百万缓存创建 Token 的价格 |
|
||||
|
||||
### 模型 ID 匹配规则
|
||||
|
||||
在匹配定价前,CC Switch 会先对请求中的模型 ID 做标准化处理:
|
||||
|
||||
- 去掉最后一个 `/` 之前的前缀
|
||||
- 去掉 `:` 之后的后缀
|
||||
- 将 `@` 替换为 `-`
|
||||
|
||||
因此,在定价配置中请填写清洗后的模型 ID,而不是请求里的完整原始模型名。
|
||||
|
||||
| 原始模型名 | 应填写的模型 ID | 说明 |
|
||||
|------|------|------|
|
||||
| `stepfun-ai/step-3.5-flash` | `step-3.5-flash` | 去掉供应商前缀 |
|
||||
| `moonshotai/kimi-k2-0905:exa` | `kimi-k2-0905` | 去掉前缀和 `:` 后缀 |
|
||||
| `gpt-5.2-codex@low` | `gpt-5.2-codex-low` | 将 `@` 替换为 `-` |
|
||||
|
||||
### 操作
|
||||
|
||||
- **添加**:点击「添加」按钮新增模型定价
|
||||
@@ -238,10 +254,14 @@ CC Switch 预设了常用模型的官方价格(每百万 Token):
|
||||
| gemini-2.5-pro | $1.25 | $10 | $0.125 |
|
||||
| gemini-2.5-flash | $0.30 | $2.50 | $0.03 |
|
||||
|
||||
**中国厂商模型(人民币)**:
|
||||
**中国厂商模型**:
|
||||
|
||||
> 注:币种遵循各供应商官方定价页面。StepFun 当前按美元列出。
|
||||
|
||||
| 模型 | 输入 | 输出 | 缓存读取 |
|
||||
|------|------|------|----------|
|
||||
| **StepFun** | | | |
|
||||
| step-3.5-flash | $0.10 | $0.30 | $0.02 |
|
||||
| **DeepSeek** | | | |
|
||||
| deepseek-v3.2 | ¥2.00 | ¥3.00 | ¥0.40 |
|
||||
| deepseek-v3.1 | ¥4.00 | ¥12.00 | ¥0.80 |
|
||||
|
||||
@@ -2,23 +2,9 @@
|
||||
|
||||
## 安装问题
|
||||
|
||||
### macOS 提示「未知开发者」
|
||||
### macOS 安装
|
||||
|
||||
**问题**:首次打开时提示「无法打开,因为它来自身份不明的开发者」
|
||||
|
||||
**解决方法一**:通过系统设置
|
||||
1. 关闭警告弹窗
|
||||
2. 打开「系统设置」→「隐私与安全性」
|
||||
3. 找到 CC Switch 相关提示
|
||||
4. 点击「仍要打开」
|
||||
5. 再次打开应用
|
||||
|
||||
**解决方法二**:通过终端命令(推荐)
|
||||
```bash
|
||||
sudo xattr -dr com.apple.quarantine /Applications/CC\ Switch.app/
|
||||
```
|
||||
|
||||
执行后即可正常打开应用。
|
||||
CC Switch macOS 版本已通过 Apple 代码签名和公证,可直接下载安装,无需额外操作。如遇问题,请尝试从 [Releases 页面](https://github.com/farion1231/cc-switch/releases) 下载最新版本。
|
||||
|
||||
### Windows 安装后无法启动
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@
|
||||
|
||||
## 版本信息
|
||||
|
||||
- 文档版本:v3.11.1
|
||||
- 最后更新:2026-02-28
|
||||
- 适用于 CC Switch v3.11.1+
|
||||
- 文档版本:v3.12.0
|
||||
- 最后更新:2026-03-09
|
||||
- 适用于 CC Switch v3.12.0+
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cc-switch",
|
||||
"version": "3.11.1",
|
||||
"version": "3.12.3",
|
||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -90,6 +90,5 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ module.exports = {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const ICONS_TO_EXTRACT = {
|
||||
// AI 服务商(必需)
|
||||
aiProviders: [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini',
|
||||
'deepseek', 'kimi', 'moonshot', 'zhipu', 'minimax',
|
||||
'deepseek', 'kimi', 'moonshot', 'stepfun', 'zhipu', 'minimax',
|
||||
'baidu', 'alibaba', 'tencent', 'meta', 'microsoft',
|
||||
'cohere', 'perplexity', 'mistral', 'huggingface'
|
||||
],
|
||||
@@ -60,6 +60,9 @@ ALL_ICONS.forEach(iconName => {
|
||||
fs.copyFileSync(sourceFile, targetFile);
|
||||
console.log(` ✓ ${iconName}.svg`);
|
||||
extracted++;
|
||||
} else if (fs.existsSync(targetFile)) {
|
||||
console.log(` ✓ ${iconName}.svg (kept local custom icon)`);
|
||||
extracted++;
|
||||
} else {
|
||||
console.log(` ✗ ${iconName}.svg (not found)`);
|
||||
notFound.push(iconName);
|
||||
@@ -110,6 +113,7 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
|
||||
@@ -10,7 +10,7 @@ const KEEP_LIST = [
|
||||
'openai', 'anthropic', 'claude', 'google', 'gemini', 'gemma', 'palm',
|
||||
'microsoft', 'azure', 'copilot', 'meta', 'llama',
|
||||
'alibaba', 'qwen', 'tencent', 'hunyuan', 'baidu', 'wenxin',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi',
|
||||
'bytedance', 'doubao', 'deepseek', 'moonshot', 'kimi', 'stepfun',
|
||||
'zhipu', 'chatglm', 'glm', 'minimax', 'mistral', 'cohere',
|
||||
'perplexity', 'huggingface', 'midjourney', 'stability',
|
||||
'xai', 'grok', 'yi', 'zeroone', 'ollama',
|
||||
|
||||
@@ -15,6 +15,7 @@ const KNOWN_METADATA = {
|
||||
deepseek: { name: 'deepseek', displayName: 'DeepSeek', category: 'ai-provider', keywords: ['deep', 'seek'], defaultColor: '#1E88E5' },
|
||||
moonshot: { name: 'moonshot', displayName: 'Moonshot', category: 'ai-provider', keywords: ['kimi', 'moonshot'], defaultColor: '#6366F1' },
|
||||
kimi: { name: 'kimi', displayName: 'Kimi', category: 'ai-provider', keywords: ['moonshot'], defaultColor: '#6366F1' },
|
||||
stepfun: { name: 'stepfun', displayName: 'StepFun', category: 'ai-provider', keywords: ['stepfun', 'step', 'jieyue', '阶跃星辰'], defaultColor: '#005AFF' },
|
||||
zhipu: { name: 'zhipu', displayName: 'Zhipu AI', category: 'ai-provider', keywords: ['chatglm', 'glm'], defaultColor: '#0F62FE' },
|
||||
minimax: { name: 'minimax', displayName: 'MiniMax', category: 'ai-provider', keywords: ['minimax'], defaultColor: '#FF6B6B' },
|
||||
baidu: { name: 'baidu', displayName: 'Baidu', category: 'ai-provider', keywords: ['ernie', 'wenxin'], defaultColor: '#2932E1' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cc-switch"
|
||||
version = "3.11.1"
|
||||
version = "3.12.3"
|
||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||
authors = ["Jason Young"]
|
||||
license = "MIT"
|
||||
@@ -13,6 +13,7 @@ rust-version = "1.85.0"
|
||||
[lib]
|
||||
name = "cc_switch_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
@@ -22,7 +23,7 @@ test-hooks = []
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde_json = { version = "1.0", features = ["preserve_order"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
log = "0.4"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -38,6 +39,8 @@ dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
flate2 = "1"
|
||||
brotli = "7"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
@@ -46,6 +49,16 @@ axum = "0.7"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["tokio", "http1", "client-legacy"] }
|
||||
hyper-rustls = { version = "0.27", features = ["http1", "tls12", "ring", "webpki-tokio"] }
|
||||
http = "1"
|
||||
http-body = "1"
|
||||
http-body-util = "0.1"
|
||||
httparse = "1"
|
||||
tokio-rustls = "0.26"
|
||||
rustls = "0.23"
|
||||
webpki-roots = "0.26"
|
||||
rustls-native-certs = "0.8"
|
||||
regex = "1.10"
|
||||
rquickjs = { version = "0.8", features = ["array-buffer", "classes"] }
|
||||
thiserror = "2.0"
|
||||
@@ -63,6 +76,7 @@ rust_decimal = "1.33"
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
json5 = "0.4"
|
||||
json-five = "0.3.1"
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -883,6 +883,7 @@ mod tests {
|
||||
dir: TempDir,
|
||||
original_home: Option<String>,
|
||||
original_userprofile: Option<String>,
|
||||
original_test_home: Option<String>,
|
||||
}
|
||||
|
||||
impl TempHome {
|
||||
@@ -890,14 +891,17 @@ mod tests {
|
||||
let dir = TempDir::new().expect("failed to create temp home");
|
||||
let original_home = env::var("HOME").ok();
|
||||
let original_userprofile = env::var("USERPROFILE").ok();
|
||||
let original_test_home = env::var("CC_SWITCH_TEST_HOME").ok();
|
||||
|
||||
env::set_var("HOME", dir.path());
|
||||
env::set_var("USERPROFILE", dir.path());
|
||||
env::set_var("CC_SWITCH_TEST_HOME", dir.path());
|
||||
|
||||
Self {
|
||||
dir,
|
||||
original_home,
|
||||
original_userprofile,
|
||||
original_test_home,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -913,6 +917,11 @@ mod tests {
|
||||
Some(value) => env::set_var("USERPROFILE", value),
|
||||
None => env::remove_var("USERPROFILE"),
|
||||
}
|
||||
|
||||
match &self.original_test_home {
|
||||
Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::error::AppError;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
/// 获取 Codex 配置目录路径
|
||||
pub fn get_codex_config_dir() -> PathBuf {
|
||||
@@ -135,3 +136,335 @@ pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
|
||||
validate_config_toml(&s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Update a field in Codex config.toml using toml_edit (syntax-preserving).
|
||||
///
|
||||
/// Supported fields:
|
||||
/// - `"base_url"`: writes to `[model_providers.<current>].base_url` if `model_provider` exists,
|
||||
/// otherwise falls back to top-level `base_url`.
|
||||
/// - `"model"`: writes to top-level `model` field.
|
||||
///
|
||||
/// Empty value removes the field.
|
||||
pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Result<String, String> {
|
||||
let mut doc = toml_str
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|e| format!("TOML parse error: {e}"))?;
|
||||
|
||||
let trimmed = value.trim();
|
||||
|
||||
match field {
|
||||
"base_url" => {
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
// Ensure [model_providers] table exists
|
||||
if doc.get("model_providers").is_none() {
|
||||
doc["model_providers"] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
|
||||
// Ensure [model_providers.<provider_key>] table exists
|
||||
if !model_providers.contains_key(&provider_key) {
|
||||
model_providers[&provider_key] = toml_edit::table();
|
||||
}
|
||||
|
||||
if let Some(provider_table) = model_providers[&provider_key].as_table_mut() {
|
||||
if trimmed.is_empty() {
|
||||
provider_table.remove("base_url");
|
||||
} else {
|
||||
provider_table["base_url"] = toml_edit::value(trimmed);
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no model_provider or structure mismatch → top-level base_url
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
} else {
|
||||
doc["base_url"] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
"model" => {
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("model");
|
||||
} else {
|
||||
doc["model"] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
_ => return Err(format!("unsupported field: {field}")),
|
||||
}
|
||||
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Remove `base_url` from the active model_provider section only if it matches `predicate`.
|
||||
/// Also removes top-level `base_url` if it matches.
|
||||
/// Used by proxy cleanup to strip local proxy URLs without touching user-configured URLs.
|
||||
pub fn remove_codex_toml_base_url_if(toml_str: &str, predicate: impl Fn(&str) -> bool) -> String {
|
||||
let mut doc = match toml_str.parse::<DocumentMut>() {
|
||||
Ok(doc) => doc,
|
||||
Err(_) => return toml_str.to_string(),
|
||||
};
|
||||
|
||||
let model_provider = doc
|
||||
.get("model_provider")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
if let Some(model_providers) = doc
|
||||
.get_mut("model_providers")
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
if let Some(provider_table) = model_providers
|
||||
.get_mut(provider_key.as_str())
|
||||
.and_then(|v| v.as_table_mut())
|
||||
{
|
||||
let should_remove = provider_table
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(&predicate)
|
||||
.unwrap_or(false);
|
||||
if should_remove {
|
||||
provider_table.remove("base_url");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: also clean up top-level base_url if it matches
|
||||
let should_remove_root = doc
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(&predicate)
|
||||
.unwrap_or(false);
|
||||
if should_remove_root {
|
||||
doc.as_table_mut().remove("base_url");
|
||||
}
|
||||
|
||||
doc.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base_url_writes_into_correct_model_provider_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
model = "gpt-5.1-codex"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://example.com/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("base_url should be in model_providers.any");
|
||||
assert_eq!(base_url, "https://example.com/v1");
|
||||
|
||||
// Should NOT have top-level base_url
|
||||
assert!(parsed.get("base_url").is_none());
|
||||
|
||||
// wire_api preserved
|
||||
let wire_api = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("wire_api"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(wire_api, Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_creates_section_when_missing() {
|
||||
let input = r#"model_provider = "custom"
|
||||
model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://custom.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("custom"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("should create section and set base_url");
|
||||
assert_eq!(base_url, "https://custom.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_url_falls_back_to_top_level_without_model_provider() {
|
||||
let input = r#"model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://fallback.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("should set top-level base_url");
|
||||
assert_eq!(base_url, "https://fallback.api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_base_url_removes_only_from_correct_section() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
wire_api = "responses"
|
||||
|
||||
[mcp_servers.context7]
|
||||
command = "npx"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
// base_url removed from model_providers.any
|
||||
let any_section = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.expect("model_providers.any should exist");
|
||||
assert!(any_section.get("base_url").is_none());
|
||||
|
||||
// wire_api preserved
|
||||
assert_eq!(
|
||||
any_section.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
|
||||
// mcp_servers untouched
|
||||
assert!(parsed.get("mcp_servers").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_field_operates_on_top_level() {
|
||||
let input = r#"model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "model", "gpt-5").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
assert_eq!(parsed.get("model").and_then(|v| v.as_str()), Some("gpt-5"));
|
||||
|
||||
// Clear model
|
||||
let result2 = update_codex_toml_field(&result, "model", "").unwrap();
|
||||
let parsed2: toml::Value = toml::from_str(&result2).unwrap();
|
||||
assert!(parsed2.get("model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_comments_and_whitespace() {
|
||||
let input = r#"# My Codex config
|
||||
model_provider = "any"
|
||||
model = "gpt-4"
|
||||
|
||||
# Provider section
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
|
||||
// Comments should be preserved
|
||||
assert!(result.contains("# My Codex config"));
|
||||
assert!(result.contains("# Provider section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_misplace_when_profiles_section_follows() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "https://old.api/v1"
|
||||
|
||||
[profiles.default]
|
||||
model = "gpt-4"
|
||||
"#;
|
||||
|
||||
let result = update_codex_toml_field(input, "base_url", "https://new.api/v1").unwrap();
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
// base_url in correct section
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(base_url, Some("https://new.api/v1"));
|
||||
|
||||
// profiles section untouched
|
||||
let profile_model = parsed
|
||||
.get("profiles")
|
||||
.and_then(|v| v.get("default"))
|
||||
.and_then(|v| v.get("model"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(profile_model, Some("gpt-4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_base_url_if_predicate() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
name = "any"
|
||||
base_url = "http://127.0.0.1:5000/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let any_section = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.unwrap();
|
||||
assert!(any_section.get("base_url").is_none());
|
||||
assert_eq!(
|
||||
any_section.get("wire_api").and_then(|v| v.as_str()),
|
||||
Some("responses")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_base_url_if_keeps_non_matching() {
|
||||
let input = r#"model_provider = "any"
|
||||
|
||||
[model_providers.any]
|
||||
base_url = "https://production.api/v1"
|
||||
"#;
|
||||
|
||||
let result =
|
||||
remove_codex_toml_base_url_if(input, |url| url.starts_with("http://127.0.0.1"));
|
||||
let parsed: toml::Value = toml::from_str(&result).unwrap();
|
||||
|
||||
let base_url = parsed
|
||||
.get("model_providers")
|
||||
.and_then(|v| v.get("any"))
|
||||
.and_then(|v| v.get("base_url"))
|
||||
.and_then(|v| v.as_str());
|
||||
assert_eq!(base_url, Some("https://production.api/v1"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::proxy::providers::copilot_auth::{GitHubAccount, GitHubDeviceCodeResponse};
|
||||
|
||||
const AUTH_PROVIDER_GITHUB_COPILOT: &str = "github_copilot";
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthAccount {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub login: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub authenticated_at: i64,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthStatus {
|
||||
pub provider: String,
|
||||
pub authenticated: bool,
|
||||
pub default_account_id: Option<String>,
|
||||
pub migration_error: Option<String>,
|
||||
pub accounts: Vec<ManagedAuthAccount>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ManagedAuthDeviceCodeResponse {
|
||||
pub provider: String,
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
fn ensure_auth_provider(auth_provider: &str) -> Result<&str, String> {
|
||||
match auth_provider {
|
||||
AUTH_PROVIDER_GITHUB_COPILOT => Ok(AUTH_PROVIDER_GITHUB_COPILOT),
|
||||
_ => Err(format!("Unsupported auth provider: {auth_provider}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_account(
|
||||
provider: &str,
|
||||
account: GitHubAccount,
|
||||
default_account_id: Option<&str>,
|
||||
) -> ManagedAuthAccount {
|
||||
ManagedAuthAccount {
|
||||
is_default: default_account_id == Some(account.id.as_str()),
|
||||
id: account.id,
|
||||
provider: provider.to_string(),
|
||||
login: account.login,
|
||||
avatar_url: account.avatar_url,
|
||||
authenticated_at: account.authenticated_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_device_code_response(
|
||||
provider: &str,
|
||||
response: GitHubDeviceCodeResponse,
|
||||
) -> ManagedAuthDeviceCodeResponse {
|
||||
ManagedAuthDeviceCodeResponse {
|
||||
provider: provider.to_string(),
|
||||
device_code: response.device_code,
|
||||
user_code: response.user_code,
|
||||
verification_uri: response.verification_uri,
|
||||
expires_in: response.expires_in,
|
||||
interval: response.interval,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_start_login(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<ManagedAuthDeviceCodeResponse, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let response = auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(map_device_code_response(auth_provider, response))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_poll_for_account(
|
||||
auth_provider: String,
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => {
|
||||
let default_account_id = auth_manager.get_status().await.default_account_id;
|
||||
Ok(account
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref())))
|
||||
}
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_list_accounts(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<ManagedAuthAccount>, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_get_status(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<ManagedAuthStatus, String> {
|
||||
let auth_provider = ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.read().await;
|
||||
let status = auth_manager.get_status().await;
|
||||
let default_account_id = status.default_account_id.clone();
|
||||
Ok(ManagedAuthStatus {
|
||||
provider: auth_provider.to_string(),
|
||||
authenticated: status.authenticated,
|
||||
default_account_id: default_account_id.clone(),
|
||||
migration_error: status.migration_error,
|
||||
accounts: status
|
||||
.accounts
|
||||
.into_iter()
|
||||
.map(|account| map_account(auth_provider, account, default_account_id.as_deref()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_remove_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_set_default_account(
|
||||
auth_provider: String,
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn auth_logout(
|
||||
auth_provider: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
ensure_auth_provider(&auth_provider)?;
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -28,6 +28,39 @@ fn invalid_json_format_error(error: serde_json::Error) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_toml_format_error(error: toml_edit::TomlError) -> String {
|
||||
let lang = settings::get_settings()
|
||||
.language
|
||||
.unwrap_or_else(|| "zh".to_string());
|
||||
|
||||
match lang.as_str() {
|
||||
"en" => format!("Invalid TOML format: {error}"),
|
||||
"ja" => format!("TOML形式が無効です: {error}"),
|
||||
_ => format!("无效的 TOML 格式: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_common_config_snippet(app_type: &str, snippet: &str) -> Result<(), String> {
|
||||
if snippet.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match app_type {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {
|
||||
snippet
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.map_err(invalid_toml_format_error)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
|
||||
match AppType::from_str(&app).map_err(|e| e.to_string())? {
|
||||
@@ -179,20 +212,22 @@ pub async fn set_claude_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
let is_cleared = snippet.trim().is_empty();
|
||||
|
||||
if !snippet.trim().is_empty() {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
let value = if is_cleared { None } else { Some(snippet) };
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet("claude", value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared("claude", is_cleared)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -213,27 +248,48 @@ pub async fn set_common_config_snippet(
|
||||
snippet: String,
|
||||
state: tauri::State<'_, crate::store::AppState>,
|
||||
) -> Result<(), String> {
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" | "omo" | "omo-slim" => {
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"codex" => {}
|
||||
_ => {}
|
||||
let is_cleared = snippet.trim().is_empty();
|
||||
let old_snippet = state
|
||||
.db
|
||||
.get_config_snippet(&app_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
validate_common_config_snippet(&app_type, &snippet)?;
|
||||
|
||||
let value = if is_cleared { None } else { Some(snippet) };
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
if let Some(legacy_snippet) = old_snippet
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::migrate_legacy_common_config_usage(
|
||||
state.inner(),
|
||||
app,
|
||||
legacy_snippet,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
let value = if snippet.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(snippet)
|
||||
};
|
||||
|
||||
state
|
||||
.db
|
||||
.set_config_snippet(&app_type, value)
|
||||
.map_err(|e| e.to_string())?;
|
||||
state
|
||||
.db
|
||||
.set_config_snippet_cleared(&app_type, is_cleared)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if matches!(app_type.as_str(), "claude" | "codex" | "gemini") {
|
||||
let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?;
|
||||
crate::services::provider::ProviderService::sync_current_provider_for_app(
|
||||
state.inner(),
|
||||
app,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
if app_type == "omo"
|
||||
&& state
|
||||
@@ -264,6 +320,27 @@ pub async fn set_common_config_snippet(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_common_config_snippet;
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_accepts_comment_only_codex_snippet() {
|
||||
validate_common_config_snippet("codex", "# comment only\n")
|
||||
.expect("comment-only codex snippet should be valid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_common_config_snippet_rejects_invalid_codex_snippet() {
|
||||
let err = validate_common_config_snippet("codex", "[broken")
|
||||
.expect_err("invalid codex snippet should be rejected");
|
||||
assert!(
|
||||
err.contains("TOML") || err.contains("toml") || err.contains("格式"),
|
||||
"expected TOML validation error, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn extract_common_config_snippet(
|
||||
appType: String,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! GitHub Copilot Tauri Commands
|
||||
//!
|
||||
//! 提供 Copilot OAuth 认证相关的 Tauri 命令,支持多账号管理。
|
||||
|
||||
use crate::proxy::providers::copilot_auth::{
|
||||
CopilotAuthManager, CopilotAuthStatus, CopilotModel, CopilotUsageResponse, GitHubAccount,
|
||||
GitHubDeviceCodeResponse,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// Copilot 认证状态
|
||||
pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
|
||||
|
||||
// ==================== 设备码流程 ====================
|
||||
|
||||
/// 启动设备码流程
|
||||
///
|
||||
/// 返回设备码和用户码,用于 OAuth 认证
|
||||
#[tauri::command]
|
||||
pub async fn copilot_start_device_flow(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<GitHubDeviceCodeResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.start_device_flow()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 轮询 OAuth Token(向后兼容)
|
||||
///
|
||||
/// 使用设备码轮询 GitHub,等待用户完成授权
|
||||
/// 返回 true 表示授权成功,false 表示等待中
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_auth(
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<bool, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(Some(_account)) => {
|
||||
log::info!("[CopilotAuth] 用户已授权");
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => Ok(false),
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询 OAuth Token(多账号版本)
|
||||
///
|
||||
/// 返回新添加的账号信息,如果授权成功
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_poll_for_account(
|
||||
device_code: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<GitHubAccount>, String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
match auth_manager.poll_for_token(&device_code).await {
|
||||
Ok(account) => Ok(account),
|
||||
Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[CopilotAuth] 轮询失败: {e}");
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 多账号管理 ====================
|
||||
|
||||
/// 列出所有已认证的账号
|
||||
#[tauri::command]
|
||||
pub async fn copilot_list_accounts(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<GitHubAccount>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.list_accounts().await)
|
||||
}
|
||||
|
||||
/// 移除指定账号
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_remove_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.remove_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置默认账号
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_set_default_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager
|
||||
.set_default_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== 状态查询 ====================
|
||||
|
||||
/// 获取认证状态(包含所有账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_auth_status(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotAuthStatus, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.get_status().await)
|
||||
}
|
||||
|
||||
/// 检查是否已认证(有任意账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_is_authenticated(state: State<'_, CopilotAuthState>) -> Result<bool, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
Ok(auth_manager.is_authenticated().await)
|
||||
}
|
||||
|
||||
/// 注销所有 Copilot 认证
|
||||
#[tauri::command]
|
||||
pub async fn copilot_logout(state: State<'_, CopilotAuthState>) -> Result<(), String> {
|
||||
let auth_manager = state.0.write().await;
|
||||
auth_manager.clear_auth().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== Token 获取 ====================
|
||||
|
||||
/// 获取有效的 Copilot Token(向后兼容:使用第一个账号)
|
||||
///
|
||||
/// 内部使用,用于代理请求
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_token(state: State<'_, CopilotAuthState>) -> Result<String, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.get_valid_token()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的有效 Copilot Token
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_token_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<String, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.get_valid_token_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ==================== 模型和使用量 ====================
|
||||
|
||||
/// 获取 Copilot 可用模型列表(向后兼容:使用第一个账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_models(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<CopilotModel>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager.fetch_models().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 可用模型列表
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_models_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<Vec<CopilotModel>, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.fetch_models_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取 Copilot 使用量信息(向后兼容:使用第一个账号)
|
||||
#[tauri::command]
|
||||
pub async fn copilot_get_usage(
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotUsageResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager.fetch_usage().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 获取指定账号的 Copilot 使用量信息
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
pub async fn copilot_get_usage_for_account(
|
||||
account_id: String,
|
||||
state: State<'_, CopilotAuthState>,
|
||||
) -> Result<CopilotUsageResponse, String> {
|
||||
let auth_manager = state.0.read().await;
|
||||
auth_manager
|
||||
.fetch_usage_for_account(&account_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -115,7 +115,7 @@ pub async fn open_zip_file_dialog<R: tauri::Runtime>(
|
||||
let dialog = app.dialog();
|
||||
let result = dialog
|
||||
.file()
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.add_filter("ZIP / Skill", &["zip", "skill"])
|
||||
.blocking_pick_file();
|
||||
|
||||
Ok(result.map(|p| p.to_string()))
|
||||
|
||||
@@ -500,7 +500,8 @@ fn extend_from_path_list(
|
||||
|
||||
/// OpenCode install.sh 路径优先级(见 https://github.com/anomalyco/opencode README):
|
||||
/// $OPENCODE_INSTALL_DIR > $XDG_BIN_DIR > $HOME/bin > $HOME/.opencode/bin
|
||||
/// 额外扫描 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
/// 额外扫描 Bun 默认全局安装路径(~/.bun/bin)
|
||||
/// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。
|
||||
fn opencode_extra_search_paths(
|
||||
home: &Path,
|
||||
opencode_install_dir: Option<std::ffi::OsString>,
|
||||
@@ -515,6 +516,7 @@ fn opencode_extra_search_paths(
|
||||
if !home.as_os_str().is_empty() {
|
||||
push_unique_path(&mut paths, home.join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".opencode").join("bin"));
|
||||
push_unique_path(&mut paths, home.join(".bun").join("bin"));
|
||||
push_unique_path(&mut paths, home.join("go").join("bin"));
|
||||
}
|
||||
|
||||
@@ -1280,6 +1282,7 @@ mod tests {
|
||||
assert_eq!(paths[1], PathBuf::from("/xdg/bin"));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.opencode/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/.bun/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/home/tester/go/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path1/bin")));
|
||||
assert!(paths.contains(&PathBuf::from("/go/path2/bin")));
|
||||
@@ -1290,7 +1293,7 @@ mod tests {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let same_dir = Some(std::ffi::OsString::from("/same/path"));
|
||||
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir.clone(), None);
|
||||
let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
@@ -1299,6 +1302,18 @@ mod tests {
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencode_extra_search_paths_deduplicates_bun_default_dir() {
|
||||
let home = PathBuf::from("/home/tester");
|
||||
let paths = opencode_extra_search_paths(&home, None, None, None);
|
||||
|
||||
let count = paths
|
||||
.iter()
|
||||
.filter(|path| **path == PathBuf::from("/home/tester/.bun/bin"))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
#[test]
|
||||
fn tool_executable_candidates_non_windows_uses_plain_binary_name() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod copilot;
|
||||
mod deeplink;
|
||||
mod env;
|
||||
mod failover;
|
||||
@@ -19,11 +21,14 @@ mod settings;
|
||||
pub mod skill;
|
||||
mod stream_check;
|
||||
mod sync_support;
|
||||
|
||||
mod usage;
|
||||
mod webdav_sync;
|
||||
mod workspace;
|
||||
|
||||
pub use auth::*;
|
||||
pub use config::*;
|
||||
pub use copilot::*;
|
||||
pub use deeplink::*;
|
||||
pub use env::*;
|
||||
pub use failover::*;
|
||||
@@ -41,6 +46,7 @@ pub use session_manager::*;
|
||||
pub use settings::*;
|
||||
pub use skill::*;
|
||||
pub use stream_check::*;
|
||||
|
||||
pub use usage::*;
|
||||
pub use webdav_sync::*;
|
||||
pub use workspace::*;
|
||||
|
||||
@@ -26,6 +26,21 @@ pub fn get_openclaw_live_provider_ids() -> Result<Vec<String>, String> {
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get a single OpenClaw provider fragment from live config.
|
||||
#[tauri::command]
|
||||
pub fn get_openclaw_live_provider(
|
||||
#[allow(non_snake_case)] providerId: String,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
openclaw_config::get_provider(&providerId).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Scan openclaw.json for known configuration hazards.
|
||||
#[tauri::command]
|
||||
pub fn scan_openclaw_config_health() -> Result<Vec<openclaw_config::OpenClawHealthWarning>, String>
|
||||
{
|
||||
openclaw_config::scan_openclaw_config_health().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agents Configuration Commands
|
||||
// ============================================================================
|
||||
@@ -41,7 +56,7 @@ pub fn get_openclaw_default_model() -> Result<Option<openclaw_config::OpenClawDe
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_default_model(
|
||||
model: openclaw_config::OpenClawDefaultModel,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_default_model(&model).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -56,7 +71,7 @@ pub fn get_openclaw_model_catalog(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_model_catalog(
|
||||
catalog: HashMap<String, openclaw_config::OpenClawModelCatalogEntry>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_model_catalog(&catalog).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -71,7 +86,7 @@ pub fn get_openclaw_agents_defaults(
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_agents_defaults(
|
||||
defaults: openclaw_config::OpenClawAgentsDefaults,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_agents_defaults(&defaults).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -87,7 +102,9 @@ pub fn get_openclaw_env() -> Result<openclaw_config::OpenClawEnvConfig, String>
|
||||
|
||||
/// Set OpenClaw env config (env section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_env(env: openclaw_config::OpenClawEnvConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_env(
|
||||
env: openclaw_config::OpenClawEnvConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_env_config(&env).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -103,6 +120,8 @@ pub fn get_openclaw_tools() -> Result<openclaw_config::OpenClawToolsConfig, Stri
|
||||
|
||||
/// Set OpenClaw tools config (tools section of openclaw.json)
|
||||
#[tauri::command]
|
||||
pub fn set_openclaw_tools(tools: openclaw_config::OpenClawToolsConfig) -> Result<(), String> {
|
||||
pub fn set_openclaw_tools(
|
||||
tools: openclaw_config::OpenClawToolsConfig,
|
||||
) -> Result<openclaw_config::OpenClawWriteOutcome, String> {
|
||||
openclaw_config::set_tools_config(&tools).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use indexmap::IndexMap;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::provider::Provider;
|
||||
use crate::services::{
|
||||
@@ -10,6 +11,11 @@ use crate::services::{
|
||||
use crate::store::AppState;
|
||||
use std::str::FromStr;
|
||||
|
||||
// 常量定义
|
||||
const TEMPLATE_TYPE_GITHUB_COPILOT: &str = "github_copilot";
|
||||
const COPILOT_UNIT_PREMIUM: &str = "requests";
|
||||
|
||||
/// 获取所有供应商
|
||||
#[tauri::command]
|
||||
pub fn get_providers(
|
||||
state: State<'_, AppState>,
|
||||
@@ -103,20 +109,22 @@ fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result
|
||||
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
||||
if state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_none()
|
||||
.should_auto_extract_config_snippet(app_type.as_str())?
|
||||
{
|
||||
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet));
|
||||
let _ = state
|
||||
.db
|
||||
.set_config_snippet_cleared(app_type.as_str(), false);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderService::migrate_legacy_common_config_usage_if_needed(state, app_type.clone())?;
|
||||
}
|
||||
|
||||
Ok(imported)
|
||||
@@ -140,10 +148,65 @@ pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result<
|
||||
#[tauri::command]
|
||||
pub async fn queryProviderUsage(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
#[allow(non_snake_case)] providerId: String, // 使用 camelCase 匹配前端
|
||||
app: String,
|
||||
) -> Result<crate::provider::UsageResult, String> {
|
||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否为 GitHub Copilot 模板类型,并解析绑定账号
|
||||
let (is_copilot_template, copilot_account_id) = {
|
||||
let providers = state
|
||||
.db
|
||||
.get_all_providers(app_type.as_str())
|
||||
.map_err(|e| format!("Failed to get providers: {e}"))?;
|
||||
|
||||
let provider = providers.get(&providerId);
|
||||
let is_copilot = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.usage_script.as_ref())
|
||||
.and_then(|s| s.template_type.as_ref())
|
||||
.map(|t| t == TEMPLATE_TYPE_GITHUB_COPILOT)
|
||||
.unwrap_or(false);
|
||||
let account_id = provider
|
||||
.and_then(|p| p.meta.as_ref())
|
||||
.and_then(|m| m.managed_account_id_for(TEMPLATE_TYPE_GITHUB_COPILOT));
|
||||
|
||||
(is_copilot, account_id)
|
||||
};
|
||||
|
||||
if is_copilot_template {
|
||||
// 使用 Copilot 专用 API
|
||||
let auth_manager = copilot_state.0.read().await;
|
||||
let usage = match copilot_account_id.as_deref() {
|
||||
Some(account_id) => auth_manager
|
||||
.fetch_usage_for_account(account_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
None => auth_manager
|
||||
.fetch_usage()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch Copilot usage: {e}"))?,
|
||||
};
|
||||
let premium = &usage.quota_snapshots.premium_interactions;
|
||||
let used = premium.entitlement - premium.remaining;
|
||||
|
||||
return Ok(crate::provider::UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![crate::provider::UsageData {
|
||||
plan_name: Some(usage.copilot_plan),
|
||||
remaining: Some(premium.remaining as f64),
|
||||
total: Some(premium.entitlement as f64),
|
||||
used: Some(used as f64),
|
||||
unit: Some(COPILOT_UNIT_PREMIUM.to_string()),
|
||||
is_valid: Some(true),
|
||||
invalid_message: None,
|
||||
extra: Some(format!("Reset: {}", usage.quota_reset_date)),
|
||||
}]),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
ProviderService::query_usage(state.inner(), app_type, &providerId)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
|
||||
@@ -57,3 +57,20 @@ pub async fn launch_session_terminal(
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session(
|
||||
providerId: String,
|
||||
sessionId: String,
|
||||
sourcePath: String,
|
||||
) -> Result<bool, String> {
|
||||
let provider_id = providerId.clone();
|
||||
let session_id = sessionId.clone();
|
||||
let source_path = sourcePath.clone();
|
||||
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
session_manager::delete_session(&provider_id, &session_id, &source_path)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Failed to delete session: {e}"))?
|
||||
}
|
||||
|
||||
@@ -6,8 +6,20 @@ fn merge_settings_for_save(
|
||||
mut incoming: crate::settings::AppSettings,
|
||||
existing: &crate::settings::AppSettings,
|
||||
) -> crate::settings::AppSettings {
|
||||
if incoming.webdav_sync.is_none() {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
match (&mut incoming.webdav_sync, &existing.webdav_sync) {
|
||||
// incoming 没有 webdav → 保留现有
|
||||
(None, _) => {
|
||||
incoming.webdav_sync = existing.webdav_sync.clone();
|
||||
}
|
||||
// incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码
|
||||
// (get_settings_for_frontend 总是清空密码,所以通过 save_settings
|
||||
// 传入的空密码意味着"保持现有"而非"用户主动清空")
|
||||
(Some(incoming_sync), Some(existing_sync))
|
||||
if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() =>
|
||||
{
|
||||
incoming_sync.password = existing_sync.password.clone();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
incoming
|
||||
}
|
||||
@@ -116,6 +128,66 @@ mod tests {
|
||||
Some("https://dav.new.example.com")
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test: frontend always receives empty password from
|
||||
/// get_settings_for_frontend(). If a component accidentally spreads
|
||||
/// the full settings object into save_settings, the empty password
|
||||
/// must NOT overwrite the existing one.
|
||||
#[test]
|
||||
fn save_settings_should_preserve_password_when_incoming_has_empty_password() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
// Simulate frontend sending settings with cleared password
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
|
||||
Some("secret"),
|
||||
"empty password from frontend must not overwrite existing password"
|
||||
);
|
||||
}
|
||||
|
||||
/// When both incoming and existing have no password, merge should
|
||||
/// work without panicking and keep the empty state.
|
||||
#[test]
|
||||
fn save_settings_should_handle_both_empty_passwords() {
|
||||
let mut existing = AppSettings::default();
|
||||
existing.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let mut incoming = AppSettings::default();
|
||||
incoming.webdav_sync = Some(WebDavSyncSettings {
|
||||
base_url: "https://dav.example.com".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
..WebDavSyncSettings::default()
|
||||
});
|
||||
|
||||
let merged = merge_settings_for_save(incoming, &existing);
|
||||
|
||||
assert_eq!(
|
||||
merged.webdav_sync.as_ref().map(|v| v.password.as_str()),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取开机自启状态
|
||||
@@ -145,6 +217,36 @@ pub async fn set_rectifier_config(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn get_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
) -> Result<crate::proxy::types::OptimizerConfig, String> {
|
||||
state.db.get_optimizer_config().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 设置优化器配置
|
||||
#[tauri::command]
|
||||
pub async fn set_optimizer_config(
|
||||
state: tauri::State<'_, crate::AppState>,
|
||||
config: crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<bool, String> {
|
||||
// Validate cache_ttl: only allow known values
|
||||
match config.cache_ttl.as_str() {
|
||||
"5m" | "1h" => {}
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Invalid cache_ttl value: '{other}'. Allowed values: '5m', '1h'"
|
||||
))
|
||||
}
|
||||
}
|
||||
state
|
||||
.db
|
||||
.set_optimizer_config(&config)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 获取日志配置
|
||||
#[tauri::command]
|
||||
pub async fn get_log_config(
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
|
||||
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
|
||||
use crate::error::format_skill_error;
|
||||
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
|
||||
use crate::services::skill::{
|
||||
DiscoverableSkill, ImportSkillSelection, Skill, SkillBackupEntry, SkillRepo, SkillService,
|
||||
SkillUninstallResult,
|
||||
};
|
||||
use crate::store::AppState;
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
@@ -33,6 +36,17 @@ pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<Instal
|
||||
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_skill_backups() -> Result<Vec<SkillBackupEntry>, String> {
|
||||
SkillService::list_backups().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_skill_backup(backup_id: String) -> Result<bool, String> {
|
||||
SkillService::delete_backup(&backup_id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 安装 Skill(新版统一安装)
|
||||
///
|
||||
/// 参数:
|
||||
@@ -56,9 +70,22 @@ pub async fn install_skill_unified(
|
||||
|
||||
/// 卸载 Skill(新版统一卸载)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
pub fn uninstall_skill_unified(
|
||||
id: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn restore_skill_backup(
|
||||
backup_id: String,
|
||||
current_app: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<InstalledSkill, String> {
|
||||
let app_type = parse_app_type(¤t_app)?;
|
||||
SkillService::restore_from_backup(&app_state.db, &backup_id, &app_type)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 切换 Skill 的应用启用状态
|
||||
@@ -85,10 +112,10 @@ pub fn scan_unmanaged_skills(
|
||||
/// 从应用目录导入 Skills
|
||||
#[tauri::command]
|
||||
pub fn import_skills_from_apps(
|
||||
directories: Vec<String>,
|
||||
imports: Vec<ImportSkillSelection>,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<Vec<InstalledSkill>, String> {
|
||||
SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string())
|
||||
SkillService::import_from_apps(&app_state.db, imports).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 发现功能命令 ==========
|
||||
@@ -192,7 +219,10 @@ pub async fn install_skill_for_app(
|
||||
|
||||
/// 卸载技能(兼容旧 API)
|
||||
#[tauri::command]
|
||||
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
|
||||
pub fn uninstall_skill(
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
uninstall_skill_for_app("claude".to_string(), directory, app_state)
|
||||
}
|
||||
|
||||
@@ -202,7 +232,7 @@ pub fn uninstall_skill_for_app(
|
||||
app: String,
|
||||
directory: String,
|
||||
app_state: State<'_, AppState>,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<SkillUninstallResult, String> {
|
||||
let _ = parse_app_type(&app)?; // 验证参数
|
||||
|
||||
// 通过 directory 找到对应的 skill id
|
||||
@@ -213,9 +243,7 @@ pub fn uninstall_skill_for_app(
|
||||
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
|
||||
.ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
|
||||
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ========== 仓库管理命令 ==========
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 流式健康检查命令
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::commands::copilot::CopilotAuthState;
|
||||
use crate::error::AppError;
|
||||
use crate::services::stream_check::{
|
||||
HealthStatus, StreamCheckConfig, StreamCheckResult, StreamCheckService,
|
||||
@@ -13,6 +14,7 @@ use tauri::State;
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_provider(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
app_type: AppType,
|
||||
provider_id: String,
|
||||
) -> Result<StreamCheckResult, AppError> {
|
||||
@@ -23,7 +25,23 @@ pub async fn stream_check_provider(
|
||||
.get(&provider_id)
|
||||
.ok_or_else(|| AppError::Message(format!("供应商 {provider_id} 不存在")))?;
|
||||
|
||||
let result = StreamCheckService::check_with_retry(&app_type, provider, &config).await?;
|
||||
let auth_override = resolve_copilot_auth_override(provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 记录日志
|
||||
let _ =
|
||||
@@ -38,6 +56,7 @@ pub async fn stream_check_provider(
|
||||
#[tauri::command]
|
||||
pub async fn stream_check_all_providers(
|
||||
state: State<'_, AppState>,
|
||||
copilot_state: State<'_, CopilotAuthState>,
|
||||
app_type: AppType,
|
||||
proxy_targets_only: bool,
|
||||
) -> Result<Vec<(String, StreamCheckResult)>, AppError> {
|
||||
@@ -67,18 +86,41 @@ pub async fn stream_check_all_providers(
|
||||
}
|
||||
}
|
||||
|
||||
let result = StreamCheckService::check_with_retry(&app_type, &provider, &config)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
let auth_override = resolve_copilot_auth_override(&provider, &copilot_state).await?;
|
||||
let claude_api_format_override = resolve_claude_api_format_override(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
&copilot_state,
|
||||
auth_override.as_ref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
log::warn!(
|
||||
"[StreamCheck] Failed to resolve Claude API format override for {}: {}",
|
||||
provider.id,
|
||||
e
|
||||
);
|
||||
None
|
||||
});
|
||||
let result = StreamCheckService::check_with_retry(
|
||||
&app_type,
|
||||
&provider,
|
||||
&config,
|
||||
auth_override,
|
||||
claude_api_format_override,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| StreamCheckResult {
|
||||
status: HealthStatus::Failed,
|
||||
success: false,
|
||||
message: e.to_string(),
|
||||
response_time_ms: None,
|
||||
http_status: None,
|
||||
model_used: String::new(),
|
||||
tested_at: chrono::Utc::now().timestamp(),
|
||||
retry_count: 0,
|
||||
});
|
||||
|
||||
let _ = state
|
||||
.db
|
||||
@@ -104,3 +146,94 @@ pub fn save_stream_check_config(
|
||||
) -> Result<(), AppError> {
|
||||
state.db.save_stream_check_config(&config)
|
||||
}
|
||||
|
||||
async fn resolve_copilot_auth_override(
|
||||
provider: &crate::provider::Provider,
|
||||
copilot_state: &State<'_, CopilotAuthState>,
|
||||
) -> Result<Option<crate::proxy::providers::AuthInfo>, AppError> {
|
||||
let is_copilot = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.provider_type.as_deref())
|
||||
== Some("github_copilot")
|
||||
|| provider
|
||||
.settings_config
|
||||
.pointer("/env/ANTHROPIC_BASE_URL")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|url| url.contains("githubcopilot.com"))
|
||||
.unwrap_or(false);
|
||||
|
||||
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.github_account_id.clone());
|
||||
|
||||
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_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()))
|
||||
}
|
||||
|
||||
@@ -15,6 +15,24 @@ use tempfile::NamedTempFile;
|
||||
|
||||
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||
|
||||
/// Tables whose data rows are skipped when exporting for WebDAV sync.
|
||||
const SYNC_SKIP_TABLES: &[&str] = &[
|
||||
"proxy_request_logs",
|
||||
"stream_check_logs",
|
||||
"provider_health",
|
||||
"proxy_live_backup",
|
||||
"usage_daily_rollups",
|
||||
];
|
||||
|
||||
/// Tables whose local data is preserved (restored from local snapshot) during WebDAV import.
|
||||
/// Excludes ephemeral tables like provider_health that can safely rebuild at runtime.
|
||||
const SYNC_PRESERVE_TABLES: &[&str] = &[
|
||||
"proxy_request_logs",
|
||||
"stream_check_logs",
|
||||
"proxy_live_backup",
|
||||
"usage_daily_rollups",
|
||||
];
|
||||
|
||||
/// A database backup entry for the UI
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -25,10 +43,16 @@ pub struct BackupEntry {
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串)
|
||||
/// 导出为 SQLite 兼容的 SQL 文本(内存字符串,完整导出)
|
||||
pub fn export_sql_string(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot)
|
||||
Self::dump_sql(&snapshot, &[])
|
||||
}
|
||||
|
||||
/// Export SQL for sync (WebDAV), skipping local-only tables' data
|
||||
pub fn export_sql_string_for_sync(&self) -> Result<String, AppError> {
|
||||
let snapshot = self.snapshot_to_memory()?;
|
||||
Self::dump_sql(&snapshot, SYNC_SKIP_TABLES)
|
||||
}
|
||||
|
||||
/// 导出为 SQLite 兼容的 SQL 文本
|
||||
@@ -58,12 +82,32 @@ impl Database {
|
||||
|
||||
/// 从 SQL 字符串导入,返回生成的备份 ID(若无备份则为空字符串)
|
||||
pub fn import_sql_string(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, &[])
|
||||
}
|
||||
|
||||
/// Import SQL generated for sync, then restore local-only tables from the
|
||||
/// current device snapshot before replacing the main database.
|
||||
pub(crate) fn import_sql_string_for_sync(&self, sql_raw: &str) -> Result<String, AppError> {
|
||||
self.import_sql_string_inner(sql_raw, SYNC_PRESERVE_TABLES)
|
||||
}
|
||||
|
||||
fn import_sql_string_inner(
|
||||
&self,
|
||||
sql_raw: &str,
|
||||
preserve_tables: &[&str],
|
||||
) -> Result<String, AppError> {
|
||||
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||
|
||||
// 导入前备份现有数据库
|
||||
let backup_path = self.backup_database_file()?;
|
||||
|
||||
let local_snapshot = if preserve_tables.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.snapshot_to_memory()?)
|
||||
};
|
||||
|
||||
// 在临时数据库执行导入,确保失败不会污染主库
|
||||
let temp_file = NamedTempFile::new().map_err(|e| AppError::IoContext {
|
||||
context: "创建临时数据库文件失败".to_string(),
|
||||
@@ -81,6 +125,9 @@ impl Database {
|
||||
Self::create_tables_on_conn(&temp_conn)?;
|
||||
Self::apply_schema_migrations_on_conn(&temp_conn)?;
|
||||
Self::validate_basic_state(&temp_conn)?;
|
||||
if let Some(local_snapshot) = local_snapshot.as_ref() {
|
||||
Self::restore_tables(local_snapshot, &temp_conn, preserve_tables)?;
|
||||
}
|
||||
|
||||
// 使用 Backup 将临时库原子写回主库
|
||||
{
|
||||
@@ -129,42 +176,121 @@ impl Database {
|
||||
))
|
||||
}
|
||||
|
||||
fn restore_tables(
|
||||
source_conn: &Connection,
|
||||
target_conn: &Connection,
|
||||
tables: &[&str],
|
||||
) -> Result<(), AppError> {
|
||||
for table in tables {
|
||||
if !Self::table_exists(source_conn, table)? || !Self::table_exists(target_conn, table)?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let columns = Self::get_table_columns(source_conn, table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&format!("DELETE FROM \"{table}\""), [])
|
||||
.map_err(|e| AppError::Database(format!("清空表 {table} 失败: {e}")))?;
|
||||
|
||||
let placeholders = (1..=columns.len())
|
||||
.map(|idx| format!("?{idx}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let cols = columns
|
||||
.iter()
|
||||
.map(|column| format!("\"{column}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let insert_sql = format!("INSERT INTO \"{table}\" ({cols}) VALUES ({placeholders})");
|
||||
|
||||
let mut stmt = source_conn
|
||||
.prepare(&format!("SELECT * FROM \"{table}\""))
|
||||
.map_err(|e| AppError::Database(format!("读取表 {table} 失败: {e}")))?;
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| AppError::Database(format!("查询表 {table} 数据失败: {e}")))?;
|
||||
|
||||
while let Some(row) = rows.next().map_err(|e| AppError::Database(e.to_string()))? {
|
||||
let mut values = Vec::with_capacity(columns.len());
|
||||
for idx in 0..columns.len() {
|
||||
values.push(
|
||||
row.get::<_, rusqlite::types::Value>(idx)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?,
|
||||
);
|
||||
}
|
||||
|
||||
target_conn
|
||||
.execute(&insert_sql, rusqlite::params_from_iter(values.iter()))
|
||||
.map_err(|e| AppError::Database(format!("恢复表 {table} 数据失败: {e}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodic backup: create a new backup if the latest one is older than the configured interval
|
||||
pub(crate) fn periodic_backup_if_needed(&self) -> Result<(), AppError> {
|
||||
let interval_hours = crate::settings::effective_backup_interval_hours();
|
||||
if interval_hours == 0 {
|
||||
return Ok(()); // Auto-backup disabled
|
||||
}
|
||||
if interval_hours > 0 {
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
} else {
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let backup_dir = get_app_config_dir().join("backups");
|
||||
if !backup_dir.exists() {
|
||||
self.backup_database_file()?;
|
||||
return Ok(());
|
||||
}
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
}
|
||||
};
|
||||
|
||||
let latest = fs::read_dir(&backup_dir).ok().and_then(|entries| {
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|ext| ext == "db").unwrap_or(false))
|
||||
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
|
||||
.max()
|
||||
});
|
||||
|
||||
let interval_secs = u64::from(interval_hours) * 3600;
|
||||
let needs_backup = match latest {
|
||||
None => true,
|
||||
Some(last_modified) => {
|
||||
last_modified.elapsed().unwrap_or_default()
|
||||
> std::time::Duration::from_secs(interval_secs)
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if needs_backup {
|
||||
log::info!(
|
||||
"Periodic backup: latest backup is older than {interval_hours} hours, creating new backup"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
// Periodic maintenance is always enabled, regardless of auto-backup settings.
|
||||
let mut reclaimed_rows = 0u64;
|
||||
match self.cleanup_old_stream_check_logs(7) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
}
|
||||
match self.rollup_and_prune(30) {
|
||||
Ok(deleted) => {
|
||||
reclaimed_rows += deleted;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Periodic rollup_and_prune failed: {e}");
|
||||
}
|
||||
}
|
||||
if reclaimed_rows > 0 {
|
||||
let conn = lock_conn!(self.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Periodic incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -258,7 +384,7 @@ impl Database {
|
||||
}
|
||||
|
||||
/// 导出数据库为 SQL 文本
|
||||
fn dump_sql(conn: &Connection) -> Result<String, AppError> {
|
||||
fn dump_sql(conn: &Connection, skip_tables: &[&str]) -> Result<String, AppError> {
|
||||
let mut output = String::new();
|
||||
let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let user_version: i64 = conn
|
||||
@@ -306,6 +432,9 @@ impl Database {
|
||||
|
||||
// 导出数据
|
||||
for table in tables {
|
||||
if skip_tables.iter().any(|t| *t == table) {
|
||||
continue;
|
||||
}
|
||||
let columns = Self::get_table_columns(conn, &table)?;
|
||||
if columns.is_empty() {
|
||||
continue;
|
||||
@@ -557,3 +686,173 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Database;
|
||||
use crate::error::AppError;
|
||||
use crate::settings::{update_settings, AppSettings};
|
||||
use serial_test::serial;
|
||||
|
||||
#[test]
|
||||
fn sync_import_preserves_local_only_tables() -> Result<(), AppError> {
|
||||
let remote_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(remote_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
let remote_sql = remote_db.export_sql_string_for_sync()?;
|
||||
|
||||
let local_db = Database::memory()?;
|
||||
{
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id, app_type, name, settings_config, meta)
|
||||
VALUES ('local-provider', 'claude', 'Local Provider', '{}', '{}')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('req-1', 'local-provider', 'claude', 'claude-3', 100, 50, '0.01', 120, 200, 1000)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO usage_daily_rollups (
|
||||
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
|
||||
) VALUES ('2026-03-01', 'claude', 'local-provider', 'claude-3', 7, 7, 700, 350, 0, 0, '0.07', 120)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('local-provider', 'Local Provider', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, 1000)",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
local_db.import_sql_string_for_sync(&remote_sql)?;
|
||||
|
||||
let remote_provider_exists: i64 = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE id = 'remote-provider' AND app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
assert_eq!(
|
||||
remote_provider_exists, 1,
|
||||
"remote config should be imported"
|
||||
);
|
||||
|
||||
let (request_logs, rollups, stream_logs): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(local_db.conn);
|
||||
let request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(request_logs, rollups, stream_logs)
|
||||
};
|
||||
assert_eq!(request_logs, 1, "local request logs should be preserved");
|
||||
assert_eq!(rollups, 1, "local rollups should be preserved");
|
||||
assert_eq!(
|
||||
stream_logs, 1,
|
||||
"local stream check logs should be preserved"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn periodic_maintenance_runs_even_when_auto_backup_disabled() -> Result<(), AppError> {
|
||||
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||||
let test_home =
|
||||
std::env::temp_dir().join("cc-switch-periodic-maintenance-backup-disabled-test");
|
||||
let _ = std::fs::remove_dir_all(&test_home);
|
||||
std::fs::create_dir_all(&test_home).expect("create test home");
|
||||
std::env::set_var("CC_SWITCH_TEST_HOME", &test_home);
|
||||
|
||||
let mut settings = AppSettings::default();
|
||||
settings.backup_interval_hours = Some(0);
|
||||
update_settings(settings).expect("disable auto backup");
|
||||
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400;
|
||||
let old_stream_ts = now - 8 * 86400;
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES ('old-req', 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?1)",
|
||||
[old_ts],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO stream_check_logs (
|
||||
provider_id, provider_name, app_type, status, success, message,
|
||||
response_time_ms, http_status, model_used, retry_count, tested_at
|
||||
) VALUES ('p1', 'Provider 1', 'claude', 'operational', 1, 'ok', 42, 200, 'claude-3', 0, ?1)",
|
||||
[old_stream_ts],
|
||||
)?;
|
||||
}
|
||||
|
||||
db.periodic_backup_if_needed()?;
|
||||
|
||||
let (remaining_request_logs, stream_logs, rollups): (i64, i64, i64) = {
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let remaining_request_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let stream_logs =
|
||||
conn.query_row("SELECT COUNT(*) FROM stream_check_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
let rollups =
|
||||
conn.query_row("SELECT COUNT(*) FROM usage_daily_rollups", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
(remaining_request_logs, stream_logs, rollups)
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
remaining_request_logs, 0,
|
||||
"old request logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(
|
||||
stream_logs, 0,
|
||||
"old stream check logs should still be pruned when auto backup is disabled"
|
||||
);
|
||||
assert_eq!(rollups, 1, "old request logs should be rolled up");
|
||||
|
||||
match old_test_home {
|
||||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod settings;
|
||||
pub mod skills;
|
||||
pub mod stream_check;
|
||||
pub mod universal_providers;
|
||||
pub mod usage_rollup;
|
||||
|
||||
// 所有 DAO 方法都通过 Database impl 提供,无需单独导出
|
||||
// 导出 FailoverQueueItem 供外部使用
|
||||
|
||||
@@ -7,6 +7,12 @@ use crate::error::AppError;
|
||||
use rusqlite::params;
|
||||
|
||||
impl Database {
|
||||
const LEGACY_COMMON_CONFIG_MIGRATED_KEY: &'static str = "common_config_legacy_migrated_v1";
|
||||
|
||||
fn config_snippet_cleared_key(app_type: &str) -> String {
|
||||
format!("common_config_{app_type}_cleared")
|
||||
}
|
||||
|
||||
/// 获取设置值
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
@@ -45,6 +51,60 @@ impl Database {
|
||||
self.get_setting(&format!("common_config_{app_type}"))
|
||||
}
|
||||
|
||||
/// 检查通用配置片段是否被用户显式清空
|
||||
pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
Ok(self
|
||||
.get_setting(&Self::config_snippet_cleared_key(app_type))?
|
||||
.as_deref()
|
||||
== Some("true"))
|
||||
}
|
||||
|
||||
/// 设置通用配置片段是否被显式清空
|
||||
pub fn set_config_snippet_cleared(
|
||||
&self,
|
||||
app_type: &str,
|
||||
cleared: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let key = Self::config_snippet_cleared_key(app_type);
|
||||
if cleared {
|
||||
self.set_setting(&key, "true")
|
||||
} else {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前是否允许从 live 配置自动抽取通用配置片段
|
||||
pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result<bool, AppError> {
|
||||
Ok(self.get_config_snippet(app_type)?.is_none()
|
||||
&& !self.is_config_snippet_cleared(app_type)?)
|
||||
}
|
||||
|
||||
/// 检查历史通用配置迁移是否已经执行过
|
||||
pub fn is_legacy_common_config_migrated(&self) -> Result<bool, AppError> {
|
||||
Ok(self
|
||||
.get_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY)?
|
||||
.as_deref()
|
||||
== Some("true"))
|
||||
}
|
||||
|
||||
/// 标记历史通用配置迁移已经执行完成
|
||||
pub fn set_legacy_common_config_migrated(&self, migrated: bool) -> Result<(), AppError> {
|
||||
if migrated {
|
||||
self.set_setting(Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY, "true")
|
||||
} else {
|
||||
let conn = lock_conn!(self.conn);
|
||||
conn.execute(
|
||||
"DELETE FROM settings WHERE key = ?1",
|
||||
params![Self::LEGACY_COMMON_CONFIG_MIGRATED_KEY],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置通用配置片段
|
||||
pub fn set_config_snippet(
|
||||
&self,
|
||||
@@ -187,6 +247,29 @@ impl Database {
|
||||
self.set_setting("rectifier_config", &json)
|
||||
}
|
||||
|
||||
// --- 优化器配置 ---
|
||||
|
||||
/// 获取优化器配置
|
||||
///
|
||||
/// 返回优化器配置,如果不存在则返回默认值(默认关闭)
|
||||
pub fn get_optimizer_config(&self) -> Result<crate::proxy::types::OptimizerConfig, AppError> {
|
||||
match self.get_setting("optimizer_config")? {
|
||||
Some(json) => serde_json::from_str(&json)
|
||||
.map_err(|e| AppError::Database(format!("解析优化器配置失败: {e}"))),
|
||||
None => Ok(crate::proxy::types::OptimizerConfig::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新优化器配置
|
||||
pub fn set_optimizer_config(
|
||||
&self,
|
||||
config: &crate::proxy::types::OptimizerConfig,
|
||||
) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
.map_err(|e| AppError::Database(format!("序列化优化器配置失败: {e}")))?;
|
||||
self.set_setting("optimizer_config", &json)
|
||||
}
|
||||
|
||||
// --- 日志配置 ---
|
||||
|
||||
/// 获取日志配置
|
||||
|
||||
@@ -48,6 +48,23 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete stream check logs older than `retain_days` days.
|
||||
/// Returns the number of deleted rows.
|
||||
pub fn cleanup_old_stream_check_logs(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM stream_check_logs WHERE tested_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!("Cleaned up {deleted} stream_check_logs older than {retain_days} days");
|
||||
}
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
|
||||
/// 保存流式检查配置
|
||||
pub fn save_stream_check_config(&self, config: &StreamCheckConfig) -> Result<(), AppError> {
|
||||
let json = serde_json::to_string(config)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Usage rollup DAO
|
||||
//!
|
||||
//! Aggregates proxy_request_logs into daily rollups and prunes old detail rows.
|
||||
|
||||
use crate::database::{lock_conn, Database};
|
||||
use crate::error::AppError;
|
||||
|
||||
impl Database {
|
||||
/// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups,
|
||||
/// then delete the aggregated detail rows.
|
||||
/// Returns the number of deleted detail rows.
|
||||
pub fn rollup_and_prune(&self, retain_days: i64) -> Result<u64, AppError> {
|
||||
let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400;
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
// Check if there are any rows to process
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if count == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// Use a savepoint for atomicity
|
||||
conn.execute("SAVEPOINT rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let result = Self::do_rollup_and_prune(&conn, cutoff);
|
||||
|
||||
match result {
|
||||
Ok(deleted) => {
|
||||
conn.execute("RELEASE rollup_prune;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if deleted > 0 {
|
||||
log::info!(
|
||||
"Rolled up and pruned {deleted} proxy_request_logs (retain={retain_days}d)"
|
||||
);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
Err(e) => {
|
||||
conn.execute("ROLLBACK TO rollup_prune;", []).ok();
|
||||
conn.execute("RELEASE rollup_prune;", []).ok();
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO usage_daily_rollups
|
||||
(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)
|
||||
SELECT
|
||||
d, a, p, m,
|
||||
COALESCE(old.request_count, 0) + new_req,
|
||||
COALESCE(old.success_count, 0) + new_succ,
|
||||
COALESCE(old.input_tokens, 0) + new_in,
|
||||
COALESCE(old.output_tokens, 0) + new_out,
|
||||
COALESCE(old.cache_read_tokens, 0) + new_cr,
|
||||
COALESCE(old.cache_creation_tokens, 0) + new_cc,
|
||||
CAST(COALESCE(CAST(old.total_cost_usd AS REAL), 0) + new_cost AS TEXT),
|
||||
CASE WHEN COALESCE(old.request_count, 0) + new_req > 0
|
||||
THEN (COALESCE(old.avg_latency_ms, 0) * COALESCE(old.request_count, 0)
|
||||
+ new_lat * new_req)
|
||||
/ (COALESCE(old.request_count, 0) + new_req)
|
||||
ELSE 0 END
|
||||
FROM (
|
||||
SELECT
|
||||
date(created_at, 'unixepoch', 'localtime') as d,
|
||||
app_type as a, provider_id as p, model as m,
|
||||
COUNT(*) as new_req,
|
||||
SUM(CASE WHEN status_code >= 200 AND status_code < 300 THEN 1 ELSE 0 END) as new_succ,
|
||||
COALESCE(SUM(input_tokens), 0) as new_in,
|
||||
COALESCE(SUM(output_tokens), 0) as new_out,
|
||||
COALESCE(SUM(cache_read_tokens), 0) as new_cr,
|
||||
COALESCE(SUM(cache_creation_tokens), 0) as new_cc,
|
||||
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as new_cost,
|
||||
COALESCE(AVG(latency_ms), 0) as new_lat
|
||||
FROM proxy_request_logs WHERE created_at < ?1
|
||||
GROUP BY d, a, p, m
|
||||
) agg
|
||||
LEFT JOIN usage_daily_rollups old
|
||||
ON old.date = agg.d AND old.app_type = agg.a
|
||||
AND old.provider_id = agg.p AND old.model = agg.m",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Rollup aggregation failed: {e}")))?;
|
||||
|
||||
// Delete the aggregated detail rows
|
||||
let deleted = conn
|
||||
.execute(
|
||||
"DELETE FROM proxy_request_logs WHERE created_at < ?1",
|
||||
[cutoff],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("Pruning old logs failed: {e}")))?;
|
||||
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::database::Database;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[test]
|
||||
fn test_rollup_and_prune() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let old_ts = now - 40 * 86400; // 40 days ago
|
||||
let recent_ts = now - 5 * 86400; // 5 days ago
|
||||
|
||||
{
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
for i in 0..5 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 100, 200, ?2)",
|
||||
rusqlite::params![format!("old-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 200, 100, '0.02', 150, 200, ?2)",
|
||||
rusqlite::params![format!("recent-{i}"), recent_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 5);
|
||||
|
||||
// Verify rollup data
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT request_count FROM usage_daily_rollups WHERE app_type = 'claude'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 5);
|
||||
|
||||
// Verify recent logs untouched
|
||||
let remaining: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM proxy_request_logs", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
assert_eq!(remaining, 3);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_noop_when_no_old_data() -> Result<(), AppError> {
|
||||
let db = Database::memory()?;
|
||||
assert_eq!(db.rollup_and_prune(30)?, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rollup_merges_with_existing() -> 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);
|
||||
let date_str = chrono::DateTime::from_timestamp(old_ts, 0)
|
||||
.unwrap()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
conn.execute(
|
||||
"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 (?1, 'claude', 'p1', 'claude-3', 10, 10, 1000, 500, '0.10', 100)",
|
||||
[&date_str],
|
||||
)?;
|
||||
for i in 0..3 {
|
||||
conn.execute(
|
||||
"INSERT INTO proxy_request_logs (
|
||||
request_id, provider_id, app_type, model,
|
||||
input_tokens, output_tokens, total_cost_usd,
|
||||
latency_ms, status_code, created_at
|
||||
) VALUES (?1, 'p1', 'claude', 'claude-3', 100, 50, '0.01', 200, 200, ?2)",
|
||||
rusqlite::params![format!("merge-{i}"), old_ts + i as i64],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted = db.rollup_and_prune(30)?;
|
||||
assert_eq!(deleted, 3);
|
||||
|
||||
let conn = crate::database::lock_conn!(db.conn);
|
||||
let (count, input): (i64, i64) = conn.query_row(
|
||||
"SELECT request_count, input_tokens FROM usage_daily_rollups
|
||||
WHERE app_type = 'claude' AND provider_id = 'p1'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
assert_eq!(count, 13, "10 existing + 3 new");
|
||||
assert_eq!(input, 1300, "1000 existing + 300 new");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ use std::sync::Mutex;
|
||||
|
||||
/// 当前 Schema 版本号
|
||||
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 5;
|
||||
pub(crate) const SCHEMA_VERSION: i32 = 6;
|
||||
|
||||
/// 安全地序列化 JSON,避免 unwrap panic
|
||||
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
|
||||
@@ -89,6 +89,7 @@ impl Database {
|
||||
/// 数据库文件位于 `~/.cc-switch/cc-switch.db`
|
||||
pub fn init() -> Result<Self, AppError> {
|
||||
let db_path = get_app_config_dir().join("cc-switch.db");
|
||||
let db_exists = db_path.exists();
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = db_path.parent() {
|
||||
@@ -100,6 +101,12 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
if !db_exists {
|
||||
// For a brand-new database, configure incremental auto-vacuum
|
||||
// before creating any tables so no rebuild is needed later.
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -123,8 +130,26 @@ impl Database {
|
||||
}
|
||||
|
||||
db.apply_schema_migrations()?;
|
||||
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
}
|
||||
db.ensure_model_pricing_seeded()?;
|
||||
|
||||
// Startup cleanup: prune old logs and reclaim space
|
||||
if let Err(e) = db.cleanup_old_stream_check_logs(7) {
|
||||
log::warn!("Startup stream_check_logs cleanup failed: {e}");
|
||||
}
|
||||
if let Err(e) = db.rollup_and_prune(30) {
|
||||
log::warn!("Startup rollup_and_prune failed: {e}");
|
||||
}
|
||||
// Reclaim disk space after cleanup
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
if let Err(e) = conn.execute_batch("PRAGMA incremental_vacuum;") {
|
||||
log::warn!("Startup incremental vacuum failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -135,6 +160,8 @@ impl Database {
|
||||
// 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
register_db_change_hook(&conn);
|
||||
|
||||
let db = Self {
|
||||
@@ -146,6 +173,79 @@ impl Database {
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub(crate) fn get_auto_vacuum_mode(conn: &Connection) -> Result<i32, AppError> {
|
||||
conn.query_row("PRAGMA auto_vacuum;", [], |row| row.get(0))
|
||||
.map_err(|e| AppError::Database(format!("读取 auto_vacuum 失败: {e}")))
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
||||
conn: &Connection,
|
||||
) -> Result<bool, AppError> {
|
||||
let mode = Self::get_auto_vacuum_mode(conn)?;
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = Self::has_user_tables(conn)?;
|
||||
conn.execute("PRAGMA auto_vacuum = INCREMENTAL;", [])
|
||||
.map_err(|e| AppError::Database(format!("设置 auto_vacuum 失败: {e}")))?;
|
||||
|
||||
if !has_tables {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
conn.execute("VACUUM;", [])
|
||||
.map_err(|e| AppError::Database(format!("执行 VACUUM 失败: {e}")))?;
|
||||
conn.execute("PRAGMA foreign_keys = ON;", [])
|
||||
.map_err(|e| AppError::Database(format!("恢复 foreign_keys 失败: {e}")))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum(&self) -> Result<bool, AppError> {
|
||||
let mode = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::get_auto_vacuum_mode(&conn)?
|
||||
};
|
||||
if mode == 2 {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let has_tables = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::has_user_tables(&conn)?
|
||||
};
|
||||
if has_tables {
|
||||
log::info!(
|
||||
"Detected auto_vacuum={mode}, rebuilding database to enable incremental vacuum"
|
||||
);
|
||||
self.backup_database_file()?;
|
||||
}
|
||||
|
||||
let rebuilt = {
|
||||
let conn = lock_conn!(self.conn);
|
||||
Self::ensure_incremental_auto_vacuum_on_conn(&conn)?
|
||||
};
|
||||
|
||||
if rebuilt {
|
||||
log::info!("Incremental auto-vacuum enabled after database rebuild");
|
||||
} else {
|
||||
log::info!("Incremental auto-vacuum configured for new database");
|
||||
}
|
||||
|
||||
Ok(rebuilt)
|
||||
}
|
||||
|
||||
/// 检查 MCP 服务器表是否为空
|
||||
pub fn is_mcp_table_empty(&self) -> Result<bool, AppError> {
|
||||
let conn = lock_conn!(self.conn);
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
|
||||
use super::{lock_conn, Database, SCHEMA_VERSION};
|
||||
use crate::error::AppError;
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LegacySkillMigrationRow {
|
||||
directory: String,
|
||||
app_type: String,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
/// 创建所有数据库表
|
||||
@@ -241,6 +248,27 @@ impl Database {
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 17. Usage Daily Rollups 表 (日聚合统计)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS 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)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 尝试添加 live_takeover_active 列到 proxy_config 表
|
||||
let _ = conn.execute(
|
||||
"ALTER TABLE proxy_config ADD COLUMN live_takeover_active INTEGER NOT NULL DEFAULT 0",
|
||||
@@ -360,6 +388,11 @@ impl Database {
|
||||
Self::migrate_v4_to_v5(conn)?;
|
||||
Self::set_user_version(conn, 5)?;
|
||||
}
|
||||
5 => {
|
||||
log::info!("迁移数据库从 v5 到 v6(使用量聚合表 + Copilot 模板类型统一)");
|
||||
Self::migrate_v5_to_v6(conn)?;
|
||||
Self::set_user_version(conn, 6)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::Database(format!(
|
||||
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
|
||||
@@ -820,12 +853,31 @@ impl Database {
|
||||
|
||||
log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)...");
|
||||
|
||||
// 1. 备份旧数据(用于日志)
|
||||
// 1. 备份旧数据(用于日志和后续启动迁移)
|
||||
let old_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
log::info!("旧 skills 表有 {old_count} 条记录");
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT directory, app_type FROM skills
|
||||
WHERE installed = 1",
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("查询旧 skills 快照失败: {e}")))?;
|
||||
let snapshot_rows: Vec<LegacySkillMigrationRow> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(LegacySkillMigrationRow {
|
||||
directory: row.get(0)?,
|
||||
app_type: row.get(1)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| AppError::Database(format!("读取旧 skills 快照失败: {e}")))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| AppError::Database(format!("解析旧 skills 快照失败: {e}")))?;
|
||||
let snapshot_json = serde_json::to_string(&snapshot_rows)
|
||||
.map_err(|e| AppError::Database(format!("序列化旧 skills 快照失败: {e}")))?;
|
||||
|
||||
// 标记:需要在启动后从文件系统扫描并重建 Skills 数据
|
||||
// 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/,
|
||||
// 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。
|
||||
@@ -833,6 +885,10 @@ impl Database {
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')",
|
||||
[],
|
||||
);
|
||||
let _ = conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_snapshot', ?1)",
|
||||
[snapshot_json],
|
||||
);
|
||||
|
||||
// 2. 删除旧表
|
||||
conn.execute("DROP TABLE IF EXISTS skills", [])
|
||||
@@ -914,6 +970,81 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// v5 -> v6 迁移:添加使用量日聚合表 + 统一 Copilot 模板类型
|
||||
fn migrate_v5_to_v6(conn: &Connection) -> Result<(), AppError> {
|
||||
// 1. 添加使用量日聚合表
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS 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)
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| AppError::Database(format!("创建 usage_daily_rollups 表失败: {e}")))?;
|
||||
|
||||
// 2. 统一 Copilot 模板类型为 github_copilot
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, app_type, meta FROM providers")
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
let mut updates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, app_type, meta_str) = row.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
|
||||
let mut updated = false;
|
||||
|
||||
if let Some(usage_script) = meta.get_mut("usage_script") {
|
||||
if let Some(template_type) = usage_script.get_mut("template_type") {
|
||||
if template_type == "copilot" {
|
||||
*template_type =
|
||||
serde_json::Value::String("github_copilot".to_string());
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
let new_meta_str = serde_json::to_string(&meta)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
updates.push((id, app_type, new_meta_str));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (id, app_type, new_meta) in updates {
|
||||
conn.execute(
|
||||
"UPDATE providers SET meta = ?1 WHERE id = ?2 AND app_type = ?3",
|
||||
params![new_meta, id, app_type],
|
||||
)
|
||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
log::info!("v5 -> v6 迁移完成:已添加使用量日聚合表,统一 copilot 模板类型");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插入默认模型定价数据
|
||||
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
|
||||
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
|
||||
@@ -928,6 +1059,14 @@ impl Database {
|
||||
"0.50",
|
||||
"6.25",
|
||||
),
|
||||
(
|
||||
"claude-sonnet-4-6-20260217",
|
||||
"Claude Sonnet 4.6",
|
||||
"3",
|
||||
"15",
|
||||
"0.30",
|
||||
"3.75",
|
||||
),
|
||||
// Claude 4.5 系列
|
||||
(
|
||||
"claude-opus-4-5-20251101",
|
||||
@@ -1189,6 +1328,15 @@ impl Database {
|
||||
"0.03",
|
||||
"0",
|
||||
),
|
||||
// StepFun 系列
|
||||
(
|
||||
"step-3.5-flash",
|
||||
"Step 3.5 Flash",
|
||||
"0.10",
|
||||
"0.30",
|
||||
"0.02",
|
||||
"0",
|
||||
),
|
||||
// ====== 国产模型 (CNY/1M tokens) ======
|
||||
// Doubao (字节跳动)
|
||||
(
|
||||
|
||||
@@ -9,6 +9,7 @@ use indexmap::IndexMap;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const LEGACY_SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE providers (
|
||||
@@ -295,6 +296,14 @@ fn schema_migration_v4_adds_pricing_model_columns() {
|
||||
let conn = Connection::open_in_memory().expect("open memory db");
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE providers (
|
||||
id TEXT NOT NULL,
|
||||
app_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
settings_config TEXT NOT NULL DEFAULT '{}',
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (id, app_type)
|
||||
);
|
||||
CREATE TABLE proxy_config (app_type TEXT PRIMARY KEY);
|
||||
CREATE TABLE proxy_request_logs (request_id TEXT PRIMARY KEY, model TEXT NOT NULL);
|
||||
CREATE TABLE mcp_servers (
|
||||
@@ -487,6 +496,25 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
|
||||
matches!(pending.as_deref(), Some("true") | Some("1")),
|
||||
"skills_ssot_migration_pending should be set after v2->v3 migration"
|
||||
);
|
||||
let snapshot: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = 'skills_ssot_migration_snapshot'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok();
|
||||
let snapshot = snapshot.expect("skills migration snapshot should be recorded");
|
||||
let snapshot_rows: serde_json::Value =
|
||||
serde_json::from_str(&snapshot).expect("parse skills migration snapshot");
|
||||
assert!(
|
||||
snapshot_rows
|
||||
.as_array()
|
||||
.is_some_and(|rows| rows.iter().any(|row| {
|
||||
row.get("directory").and_then(|v| v.as_str()) == Some("demo-skill")
|
||||
&& row.get("app_type").and_then(|v| v.as_str()) == Some("claude")
|
||||
})),
|
||||
"skills migration snapshot should preserve legacy app mapping"
|
||||
);
|
||||
|
||||
// v3.9+ 新增:proxy_config 三行 seed 必须存在(否则 UI 会查不到默认值)
|
||||
let proxy_rows: i64 = conn
|
||||
@@ -633,3 +661,32 @@ fn schema_model_pricing_is_seeded_on_init() {
|
||||
gemini_count
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_incremental_auto_vacuum_rebuilds_existing_file_db() {
|
||||
let temp = NamedTempFile::new().expect("create temp db file");
|
||||
let path = temp.path().to_path_buf();
|
||||
|
||||
let conn = Connection::open(&path).expect("open temp db");
|
||||
conn.execute("PRAGMA auto_vacuum = NONE;", [])
|
||||
.expect("set none auto_vacuum");
|
||||
Database::create_tables_on_conn(&conn).expect("create tables");
|
||||
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&conn).expect("auto_vacuum before rebuild"),
|
||||
0,
|
||||
"existing file db should start with NONE auto_vacuum"
|
||||
);
|
||||
|
||||
let rebuilt =
|
||||
Database::ensure_incremental_auto_vacuum_on_conn(&conn).expect("enable incremental mode");
|
||||
assert!(rebuilt, "existing db should require rebuild via VACUUM");
|
||||
drop(conn);
|
||||
|
||||
let reopened = Connection::open(&path).expect("reopen temp db");
|
||||
assert_eq!(
|
||||
Database::get_auto_vacuum_mode(&reopened).expect("auto_vacuum after rebuild"),
|
||||
2,
|
||||
"file db should persist INCREMENTAL auto_vacuum after VACUUM rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,10 +25,11 @@ mod services;
|
||||
mod session_manager;
|
||||
mod settings;
|
||||
mod store;
|
||||
|
||||
mod tray;
|
||||
mod usage_script;
|
||||
|
||||
pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig};
|
||||
pub use app_config::{AppType, InstalledSkill, McpApps, McpServer, MultiAppConfig, SkillApps};
|
||||
pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic};
|
||||
pub use commands::open_provider_terminal;
|
||||
pub use commands::*;
|
||||
@@ -44,6 +45,7 @@ pub use mcp::{
|
||||
};
|
||||
pub use provider::{Provider, ProviderMeta};
|
||||
pub use services::{
|
||||
skill::{migrate_skills_to_ssot, ImportSkillSelection},
|
||||
ConfigService, EndpointLatency, McpService, PromptService, ProviderService, ProxyService,
|
||||
SkillService, SpeedtestService,
|
||||
};
|
||||
@@ -560,59 +562,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Auto-extract common config snippets from live files (when snippet is missing)
|
||||
for app_type in crate::app_config::AppType::all() {
|
||||
// Skip if snippet already exists
|
||||
if app_state
|
||||
.db
|
||||
.get_config_snippet(app_type.as_str())
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to read the live config file for this app type
|
||||
let settings =
|
||||
match crate::services::provider::ProviderService::read_live_settings(
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue, // No live config file, skip silently
|
||||
};
|
||||
|
||||
// Extract common config (strip provider-specific fields)
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
&settings,
|
||||
) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
match app_state
|
||||
.db
|
||||
.set_config_snippet(app_type.as_str(), Some(snippet))
|
||||
{
|
||||
Ok(()) => log::info!(
|
||||
"✓ Auto-extracted common config snippet for {}",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to save config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"○ Live config for {} has no extractable common fields",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to extract config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 迁移旧的 app_config_dir 配置到 Store
|
||||
if let Err(e) = app_store::migrate_app_config_dir_from_settings(app.handle()) {
|
||||
log::warn!("迁移 app_config_dir 失败: {e}");
|
||||
@@ -741,6 +690,18 @@ pub fn run() {
|
||||
let skill_service = SkillService::new();
|
||||
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
|
||||
|
||||
// 初始化 CopilotAuthManager
|
||||
{
|
||||
use crate::proxy::providers::copilot_auth::CopilotAuthManager;
|
||||
use commands::CopilotAuthState;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
let app_config_dir = crate::config::get_app_config_dir();
|
||||
let copilot_auth_manager = CopilotAuthManager::new(app_config_dir);
|
||||
app.manage(CopilotAuthState(Arc::new(RwLock::new(copilot_auth_manager))));
|
||||
log::info!("✓ CopilotAuthManager initialized");
|
||||
}
|
||||
|
||||
// 初始化全局出站代理 HTTP 客户端
|
||||
{
|
||||
let db = &app.state::<AppState>().db;
|
||||
@@ -797,6 +758,8 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
initialize_common_config_snippets(&state);
|
||||
|
||||
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||
restore_proxy_state_on_startup(&state).await;
|
||||
|
||||
@@ -805,16 +768,18 @@ pub fn run() {
|
||||
log::warn!("Periodic backup failed on startup: {e}");
|
||||
}
|
||||
|
||||
// Periodic backup timer: check every hour while the app is running
|
||||
// Periodic maintenance timer: run once per day while the app is running
|
||||
let db_for_timer = state.db.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_secs(3600));
|
||||
const PERIODIC_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(
|
||||
PERIODIC_MAINTENANCE_INTERVAL_SECS,
|
||||
));
|
||||
interval.tick().await; // skip immediate first tick (already checked above)
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = db_for_timer.periodic_backup_if_needed() {
|
||||
log::warn!("Periodic backup timer failed: {e}");
|
||||
log::warn!("Periodic maintenance timer failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -853,6 +818,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -886,6 +852,8 @@ pub fn run() {
|
||||
commands::save_settings,
|
||||
commands::get_rectifier_config,
|
||||
commands::set_rectifier_config,
|
||||
commands::get_optimizer_config,
|
||||
commands::set_optimizer_config,
|
||||
commands::get_log_config,
|
||||
commands::set_log_config,
|
||||
commands::restart_app,
|
||||
@@ -964,8 +932,11 @@ pub fn run() {
|
||||
commands::restore_env_backup,
|
||||
// Skill management (v3.10.0+ unified)
|
||||
commands::get_installed_skills,
|
||||
commands::get_skill_backups,
|
||||
commands::delete_skill_backup,
|
||||
commands::install_skill_unified,
|
||||
commands::uninstall_skill_unified,
|
||||
commands::restore_skill_backup,
|
||||
commands::toggle_skill_app,
|
||||
commands::scan_unmanaged_skills,
|
||||
commands::import_skills_from_apps,
|
||||
@@ -1036,6 +1007,7 @@ pub fn run() {
|
||||
// Session manager
|
||||
commands::list_sessions,
|
||||
commands::get_session_messages,
|
||||
commands::delete_session,
|
||||
commands::launch_session_terminal,
|
||||
commands::get_tool_versions,
|
||||
// Provider terminal
|
||||
@@ -1052,6 +1024,8 @@ pub fn run() {
|
||||
// OpenClaw specific
|
||||
commands::import_openclaw_providers_from_live,
|
||||
commands::get_openclaw_live_provider_ids,
|
||||
commands::get_openclaw_live_provider,
|
||||
commands::scan_openclaw_config_health,
|
||||
commands::get_openclaw_default_model,
|
||||
commands::set_openclaw_default_model,
|
||||
commands::get_openclaw_model_catalog,
|
||||
@@ -1070,6 +1044,31 @@ pub fn run() {
|
||||
commands::scan_local_proxies,
|
||||
// Window theme control
|
||||
commands::set_window_theme,
|
||||
// Generic managed auth commands
|
||||
commands::auth_start_login,
|
||||
commands::auth_poll_for_account,
|
||||
commands::auth_list_accounts,
|
||||
commands::auth_get_status,
|
||||
commands::auth_remove_account,
|
||||
commands::auth_set_default_account,
|
||||
commands::auth_logout,
|
||||
// Copilot OAuth commands (multi-account support)
|
||||
commands::copilot_start_device_flow,
|
||||
commands::copilot_poll_for_auth,
|
||||
commands::copilot_poll_for_account,
|
||||
commands::copilot_list_accounts,
|
||||
commands::copilot_remove_account,
|
||||
commands::copilot_set_default_account,
|
||||
commands::copilot_get_auth_status,
|
||||
commands::copilot_logout,
|
||||
commands::copilot_is_authenticated,
|
||||
commands::copilot_get_token,
|
||||
commands::copilot_get_token_for_account,
|
||||
commands::copilot_get_models,
|
||||
commands::copilot_get_models_for_account,
|
||||
commands::copilot_get_usage,
|
||||
commands::copilot_get_usage_for_account,
|
||||
// OMO commands
|
||||
commands::read_omo_local_file,
|
||||
commands::get_current_omo_provider_id,
|
||||
commands::disable_current_omo,
|
||||
@@ -1298,6 +1297,85 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_common_config_snippets(state: &store::AppState) {
|
||||
// Auto-extract common config snippets from clean live files when snippet is missing.
|
||||
// This must run before proxy takeover is restored on startup, otherwise we'd read
|
||||
// proxy-placeholder configs instead of the user's actual live settings.
|
||||
for app_type in crate::app_config::AppType::all() {
|
||||
if !state
|
||||
.db
|
||||
.should_auto_extract_config_snippet(app_type.as_str())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let settings = match crate::services::provider::ProviderService::read_live_settings(
|
||||
app_type.clone(),
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
match crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
||||
app_type.clone(),
|
||||
&settings,
|
||||
) {
|
||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
||||
match state.db.set_config_snippet(app_type.as_str(), Some(snippet)) {
|
||||
Ok(()) => {
|
||||
let _ = state.db.set_config_snippet_cleared(app_type.as_str(), false);
|
||||
log::info!(
|
||||
"✓ Auto-extracted common config snippet for {}",
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to save config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
Ok(_) => log::debug!(
|
||||
"○ Live config for {} has no extractable common fields",
|
||||
app_type.as_str()
|
||||
),
|
||||
Err(e) => log::warn!(
|
||||
"✗ Failed to extract config snippet for {}: {e}",
|
||||
app_type.as_str()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let should_run_legacy_migration = state
|
||||
.db
|
||||
.is_legacy_common_config_migrated()
|
||||
.map(|done| !done)
|
||||
.unwrap_or(true);
|
||||
|
||||
if should_run_legacy_migration {
|
||||
for app_type in [
|
||||
crate::app_config::AppType::Claude,
|
||||
crate::app_config::AppType::Codex,
|
||||
crate::app_config::AppType::Gemini,
|
||||
] {
|
||||
if let Err(e) = crate::services::provider::ProviderService::migrate_legacy_common_config_usage_if_needed(
|
||||
state,
|
||||
app_type.clone(),
|
||||
) {
|
||||
log::warn!(
|
||||
"✗ Failed to migrate legacy common-config usage for {}: {e}",
|
||||
app_type.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = state.db.set_legacy_common_config_migrated(true) {
|
||||
log::warn!("✗ Failed to persist legacy common-config migration flag: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 迁移错误对话框辅助函数
|
||||
// ============================================================
|
||||
|
||||
@@ -191,12 +191,43 @@ pub struct ProviderProxyConfig {
|
||||
pub proxy_password: Option<String>,
|
||||
}
|
||||
|
||||
/// 认证绑定来源
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthBindingSource {
|
||||
/// 从 provider 自身配置读取认证信息(默认)
|
||||
#[default]
|
||||
ProviderConfig,
|
||||
/// 使用托管账号认证(如 GitHub Copilot OAuth)
|
||||
ManagedAccount,
|
||||
}
|
||||
|
||||
/// 通用认证绑定
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct AuthBinding {
|
||||
/// 认证来源
|
||||
#[serde(default)]
|
||||
pub source: AuthBindingSource,
|
||||
/// 托管认证供应商标识(如 github_copilot)
|
||||
#[serde(rename = "authProvider", skip_serializing_if = "Option::is_none")]
|
||||
pub auth_provider: Option<String>,
|
||||
/// 托管账号 ID;为空表示跟随该认证供应商的默认账号
|
||||
#[serde(rename = "accountId", skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 供应商元数据
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ProviderMeta {
|
||||
/// 自定义端点列表(按 URL 去重存储)
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub custom_endpoints: HashMap<String, crate::settings::CustomEndpoint>,
|
||||
/// 是否在写入 live 时应用通用配置片段
|
||||
#[serde(
|
||||
rename = "commonConfigEnabled",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub common_config_enabled: Option<bool>,
|
||||
/// 用量查询脚本配置
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage_script: Option<UsageScript>,
|
||||
@@ -233,8 +264,54 @@ pub struct ProviderMeta {
|
||||
/// Claude API 格式(仅 Claude 供应商使用)
|
||||
/// - "anthropic": 原生 Anthropic Messages API,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要转换
|
||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||
pub api_format: Option<String>,
|
||||
/// 通用认证绑定(provider_config / managed_account)
|
||||
///
|
||||
/// 新代码应只写入该字段;githubAccountId 仅保留兼容读取。
|
||||
#[serde(rename = "authBinding", skip_serializing_if = "Option::is_none")]
|
||||
pub auth_binding: Option<AuthBinding>,
|
||||
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY")
|
||||
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_field: Option<String>,
|
||||
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
|
||||
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
|
||||
pub is_full_url: Option<bool>,
|
||||
/// Prompt cache key for OpenAI-compatible endpoints.
|
||||
/// When set, injected into converted requests to improve cache hit rate.
|
||||
/// If not set, provider ID is used automatically during format conversion.
|
||||
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
/// 供应商类型标识(用于特殊供应商检测)
|
||||
/// - "github_copilot": GitHub Copilot 供应商
|
||||
#[serde(rename = "providerType", skip_serializing_if = "Option::is_none")]
|
||||
pub provider_type: Option<String>,
|
||||
/// GitHub Copilot 关联账号 ID(仅 github_copilot 供应商使用)
|
||||
/// 用于多账号支持,关联到特定的 GitHub 账号
|
||||
#[serde(rename = "githubAccountId", skip_serializing_if = "Option::is_none")]
|
||||
pub github_account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ProviderMeta {
|
||||
/// 解析指定托管认证供应商绑定的账号 ID。
|
||||
///
|
||||
/// 新版优先读取 authBinding,旧版继续兼容 githubAccountId。
|
||||
pub fn managed_account_id_for(&self, auth_provider: &str) -> Option<String> {
|
||||
if let Some(binding) = self.auth_binding.as_ref() {
|
||||
if binding.source == AuthBindingSource::ManagedAccount
|
||||
&& binding.auth_provider.as_deref() == Some(auth_provider)
|
||||
{
|
||||
return binding.account_id.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if auth_provider == "github_copilot" {
|
||||
return self.github_account_id.clone();
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Cache 断点注入器
|
||||
//!
|
||||
//! 在请求转发前自动注入 cache_control 标记,启用 Bedrock Prompt Caching
|
||||
|
||||
use super::types::OptimizerConfig;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// 在请求体关键位置注入 cache_control 断点
|
||||
pub fn inject(body: &mut Value, config: &OptimizerConfig) {
|
||||
if !config.cache_injection {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing = count_existing(body);
|
||||
|
||||
// 升级已有断点的 TTL
|
||||
upgrade_existing_ttl(body, &config.cache_ttl);
|
||||
|
||||
let mut budget = 4_usize.saturating_sub(existing);
|
||||
if budget == 0 {
|
||||
if existing > 0 {
|
||||
log::info!(
|
||||
"[OPT] cache: ttl-upgrade({existing}->{},existing={existing})",
|
||||
config.cache_ttl
|
||||
);
|
||||
} else {
|
||||
log::info!("[OPT] cache: no-op(existing={existing})");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut injected = Vec::new();
|
||||
|
||||
// (a) tools 末尾
|
||||
if budget > 0 {
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
if let Some(last) = tools.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("tools");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (b) system 末尾
|
||||
if budget > 0 {
|
||||
// 字符串 system → 转为数组
|
||||
if body.get("system").and_then(|s| s.as_str()).is_some() {
|
||||
let text = body["system"].as_str().unwrap().to_string();
|
||||
body["system"] = json!([{"type": "text", "text": text}]);
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
if let Some(last) = system.last_mut() {
|
||||
if last.get("cache_control").is_none() {
|
||||
if let Some(o) = last.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
budget -= 1;
|
||||
injected.push("system");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) 最后一条 assistant 消息的最后一个非 thinking block
|
||||
if budget > 0 {
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
if let Some(assistant_msg) = messages
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|m| m.get("role").and_then(|r| r.as_str()) == Some("assistant"))
|
||||
{
|
||||
if let Some(content) = assistant_msg
|
||||
.get_mut("content")
|
||||
.and_then(|c| c.as_array_mut())
|
||||
{
|
||||
// 逆序找最后一个非 thinking/redacted_thinking block
|
||||
if let Some(block) = content.iter_mut().rev().find(|b| {
|
||||
let bt = b.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
bt != "thinking" && bt != "redacted_thinking"
|
||||
}) {
|
||||
if block.get("cache_control").is_none() {
|
||||
if let Some(o) = block.as_object_mut() {
|
||||
o.insert(
|
||||
"cache_control".to_string(),
|
||||
make_cache_control(&config.cache_ttl),
|
||||
);
|
||||
}
|
||||
injected.push("msgs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[OPT] cache: {}bp({},{},pre={existing})",
|
||||
injected.len(),
|
||||
injected.join("+"),
|
||||
config.cache_ttl,
|
||||
);
|
||||
}
|
||||
|
||||
fn make_cache_control(ttl: &str) -> Value {
|
||||
if ttl == "5m" {
|
||||
json!({"type": "ephemeral"})
|
||||
} else {
|
||||
json!({"type": "ephemeral", "ttl": ttl})
|
||||
}
|
||||
}
|
||||
|
||||
fn count_existing(body: &Value) -> usize {
|
||||
let mut count = 0;
|
||||
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
count += tools
|
||||
.iter()
|
||||
.filter(|t| t.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(system) = body.get("system").and_then(|s| s.as_array()) {
|
||||
count += system
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get("messages").and_then(|m| m.as_array()) {
|
||||
for msg in messages {
|
||||
if let Some(content) = msg.get("content").and_then(|c| c.as_array()) {
|
||||
count += content
|
||||
.iter()
|
||||
.filter(|b| b.get("cache_control").is_some())
|
||||
.count();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
fn upgrade_existing_ttl(body: &mut Value, ttl: &str) {
|
||||
let upgrade = |val: &mut Value| {
|
||||
if let Some(cc) = val.get_mut("cache_control").and_then(|c| c.as_object_mut()) {
|
||||
if ttl == "5m" {
|
||||
cc.remove("ttl");
|
||||
} else {
|
||||
cc.insert("ttl".to_string(), json!(ttl));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tools) = body.get_mut("tools").and_then(|t| t.as_array_mut()) {
|
||||
for tool in tools.iter_mut() {
|
||||
upgrade(tool);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(system) = body.get_mut("system").and_then(|s| s.as_array_mut()) {
|
||||
for block in system.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) {
|
||||
for msg in messages.iter_mut() {
|
||||
if let Some(content) = msg.get_mut("content").and_then(|c| c.as_array_mut()) {
|
||||
for block in content.iter_mut() {
|
||||
upgrade(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn default_config() -> OptimizerConfig {
|
||||
OptimizerConfig {
|
||||
enabled: true,
|
||||
thinking_optimizer: true,
|
||||
cache_injection: true,
|
||||
cache_ttl: "1h".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_body_no_injection() {
|
||||
let mut body = json!({"model": "test", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]});
|
||||
let original = body.clone();
|
||||
inject(&mut body, &default_config());
|
||||
// No tools, no system, no assistant → no injection
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_three_breakpoints() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}, {"name": "tool2"}],
|
||||
"system": [{"type": "text", "text": "sys prompt"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "hi"}]},
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "hello"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// tools last element
|
||||
assert!(body["tools"][1].get("cache_control").is_some());
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
// system last element
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
// assistant last non-thinking block
|
||||
assert!(body["messages"][1]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_four_breakpoints_only_upgrades_ttl() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"system": [
|
||||
{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "text", "text": "ok", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// All TTLs upgraded to 1h, no new breakpoints
|
||||
assert_eq!(body["tools"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["tools"][1]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
|
||||
assert_eq!(
|
||||
body["messages"][0]["content"][0]["cache_control"]["ttl"],
|
||||
"1h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_existing_two_injects_two_more() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [
|
||||
{"name": "t1", "cache_control": {"type": "ephemeral"}},
|
||||
{"name": "t2", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// budget = 4 - 2 = 2, inject system + msgs
|
||||
assert!(body["system"][0].get("cache_control").is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_string_converted_to_array() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"system": "You are a helpful assistant",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
assert!(body["system"].is_array());
|
||||
let sys = body["system"].as_array().unwrap();
|
||||
assert_eq!(sys.len(), 1);
|
||||
assert_eq!(sys[0]["type"], "text");
|
||||
assert_eq!(sys[0]["text"], "You are a helpful assistant");
|
||||
assert!(sys[0].get("cache_control").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ttl_5m_no_ttl_field() {
|
||||
let config = OptimizerConfig {
|
||||
cache_ttl: "5m".to_string(),
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
});
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
let cc = &body["tools"][0]["cache_control"];
|
||||
assert_eq!(cc["type"], "ephemeral");
|
||||
assert!(cc.get("ttl").is_none() || cc["ttl"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_disabled_no_change() {
|
||||
let config = OptimizerConfig {
|
||||
cache_injection: false,
|
||||
..default_config()
|
||||
};
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"tools": [{"name": "tool1"}],
|
||||
"system": [{"type": "text", "text": "sys"}],
|
||||
"messages": [{"role": "assistant", "content": [{"type": "text", "text": "ok"}]}]
|
||||
});
|
||||
let original = body.clone();
|
||||
|
||||
inject(&mut body, &config);
|
||||
|
||||
assert_eq!(body, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skip_thinking_blocks_in_assistant() {
|
||||
let mut body = json!({
|
||||
"model": "test",
|
||||
"messages": [
|
||||
{"role": "assistant", "content": [
|
||||
{"type": "thinking", "thinking": "hmm"},
|
||||
{"type": "text", "text": "result"},
|
||||
{"type": "redacted_thinking", "data": "xxx"}
|
||||
]}
|
||||
]
|
||||
});
|
||||
|
||||
inject(&mut body, &default_config());
|
||||
|
||||
// Should inject on "text" block (last non-thinking), not on thinking/redacted_thinking
|
||||
assert!(body["messages"][0]["content"][1]
|
||||
.get("cache_control")
|
||||
.is_some());
|
||||
assert!(body["messages"][0]["content"][0]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
assert!(body["messages"][0]["content"][2]
|
||||
.get("cache_control")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ pub enum ProxyError {
|
||||
StreamIdleTimeout(u64),
|
||||
|
||||
/// 认证错误
|
||||
#[allow(dead_code)]
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::proxy::{
|
||||
extract_session_id,
|
||||
forwarder::RequestForwarder,
|
||||
server::ProxyState,
|
||||
types::{AppProxyConfig, RectifierConfig},
|
||||
types::{AppProxyConfig, OptimizerConfig, RectifierConfig},
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::HeaderMap;
|
||||
@@ -59,6 +59,8 @@ pub struct RequestContext {
|
||||
pub session_id: String,
|
||||
/// 整流器配置
|
||||
pub rectifier_config: RectifierConfig,
|
||||
/// 优化器配置
|
||||
pub optimizer_config: OptimizerConfig,
|
||||
}
|
||||
|
||||
impl RequestContext {
|
||||
@@ -93,6 +95,7 @@ impl RequestContext {
|
||||
|
||||
// 从数据库读取整流器配置
|
||||
let rectifier_config = state.db.get_rectifier_config().unwrap_or_default();
|
||||
let optimizer_config = state.db.get_optimizer_config().unwrap_or_default();
|
||||
|
||||
let current_provider_id =
|
||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||
@@ -156,6 +159,7 @@ impl RequestContext {
|
||||
app_type,
|
||||
session_id,
|
||||
rectifier_config,
|
||||
optimizer_config,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -216,6 +220,7 @@ impl RequestContext {
|
||||
first_byte_timeout,
|
||||
idle_timeout,
|
||||
self.rectifier_config.clone(),
|
||||
self.optimizer_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,15 @@ use super::{
|
||||
CLAUDE_PARSER_CONFIG, CODEX_PARSER_CONFIG, GEMINI_PARSER_CONFIG, OPENAI_PARSER_CONFIG,
|
||||
},
|
||||
handler_context::RequestContext,
|
||||
providers::{get_adapter, streaming::create_anthropic_sse_stream, transform},
|
||||
response_processor::{create_logged_passthrough_stream, process_response, SseUsageCollector},
|
||||
providers::{
|
||||
get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream,
|
||||
streaming_responses::create_anthropic_sse_stream_from_responses, transform,
|
||||
transform_responses,
|
||||
},
|
||||
response_processor::{
|
||||
create_logged_passthrough_stream, process_response, read_decoded_body,
|
||||
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
|
||||
},
|
||||
server::ProxyState,
|
||||
types::*,
|
||||
usage::parser::TokenUsage,
|
||||
@@ -22,6 +29,8 @@ use super::{
|
||||
};
|
||||
use crate::app_config::AppType;
|
||||
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
|
||||
use bytes::Bytes;
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ============================================================================
|
||||
@@ -56,12 +65,28 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
||||
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||
pub async fn handle_messages(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
|
||||
|
||||
let endpoint = uri
|
||||
.path_and_query()
|
||||
.map(|path_and_query| path_and_query.as_str())
|
||||
.unwrap_or(uri.path());
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
.and_then(|s| s.as_bool())
|
||||
@@ -72,9 +97,10 @@ pub async fn handle_messages(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Claude,
|
||||
"/v1/messages",
|
||||
endpoint,
|
||||
body.clone(),
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -90,6 +116,11 @@ pub async fn handle_messages(
|
||||
};
|
||||
|
||||
ctx.provider = result.provider;
|
||||
let api_format = result
|
||||
.claude_api_format
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| get_claude_api_format(&ctx.provider))
|
||||
.to_string();
|
||||
let response = result.response;
|
||||
|
||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||
@@ -98,7 +129,8 @@ pub async fn handle_messages(
|
||||
|
||||
// Claude 特有:格式转换处理
|
||||
if needs_transform {
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream).await;
|
||||
return handle_claude_transform(response, &ctx, &state, &body, is_stream, &api_format)
|
||||
.await;
|
||||
}
|
||||
|
||||
// 通用响应处理(透传模式)
|
||||
@@ -107,20 +139,27 @@ pub async fn handle_messages(
|
||||
|
||||
/// Claude 格式转换处理(独有逻辑)
|
||||
///
|
||||
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||
/// 支持 OpenAI Chat Completions 和 Responses API 两种格式的转换
|
||||
async fn handle_claude_transform(
|
||||
response: reqwest::Response,
|
||||
response: super::hyper_client::ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
_original_body: &Value,
|
||||
is_stream: bool,
|
||||
api_format: &str,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let status = response.status();
|
||||
|
||||
if is_stream {
|
||||
// 流式响应转换 (OpenAI SSE → Anthropic SSE)
|
||||
// 根据 api_format 选择流式转换器
|
||||
let stream = response.bytes_stream();
|
||||
let sse_stream = create_anthropic_sse_stream(stream);
|
||||
let sse_stream: Box<
|
||||
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + Send + Unpin,
|
||||
> = if api_format == "openai_responses" {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = {
|
||||
@@ -186,22 +225,30 @@ async fn handle_claude_transform(
|
||||
return Ok((headers, body).into_response());
|
||||
}
|
||||
|
||||
// 非流式响应转换 (OpenAI → Anthropic)
|
||||
let response_headers = response.headers().clone();
|
||||
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[Claude] 读取响应体失败: {e}");
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
// 非流式响应转换 (OpenAI/Responses → Anthropic)
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
std::time::Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let (mut response_headers, _status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
let openai_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析 OpenAI 响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse OpenAI response: {e}"))
|
||||
let upstream_response: Value = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}");
|
||||
ProxyError::TransformError(format!("Failed to parse upstream response: {e}"))
|
||||
})?;
|
||||
|
||||
let anthropic_response = transform::openai_to_anthropic(openai_response).map_err(|e| {
|
||||
// 根据 api_format 选择非流式转换器
|
||||
let anthropic_response = if api_format == "openai_responses" {
|
||||
transform_responses::responses_to_anthropic(upstream_response)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
.map_err(|e| {
|
||||
log::error!("[Claude] 转换响应失败: {e}");
|
||||
e
|
||||
})?;
|
||||
@@ -239,13 +286,10 @@ async fn handle_claude_transform(
|
||||
|
||||
// 构建响应
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
strip_entity_headers_for_rebuilt_body(&mut response_headers);
|
||||
|
||||
for (key, value) in response_headers.iter() {
|
||||
if key.as_str().to_lowercase() != "content-length"
|
||||
&& key.as_str().to_lowercase() != "transfer-encoding"
|
||||
{
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
|
||||
builder = builder.header("content-type", "application/json");
|
||||
@@ -262,6 +306,13 @@ async fn handle_claude_transform(
|
||||
})
|
||||
}
|
||||
|
||||
fn endpoint_with_query(uri: &axum::http::Uri, endpoint: &str) -> String {
|
||||
match uri.query() {
|
||||
Some(query) => format!("{endpoint}?{query}"),
|
||||
None => endpoint.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex API 处理器
|
||||
// ============================================================================
|
||||
@@ -269,11 +320,23 @@ async fn handle_claude_transform(
|
||||
/// 处理 /v1/chat/completions 请求(OpenAI Chat Completions API - Codex CLI)
|
||||
pub async fn handle_chat_completions(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/chat/completions");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -284,9 +347,10 @@ pub async fn handle_chat_completions(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/chat/completions",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -310,11 +374,23 @@ pub async fn handle_chat_completions(
|
||||
/// 处理 /v1/responses 请求(OpenAI Responses API - Codex CLI 透传)
|
||||
pub async fn handle_responses(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -325,9 +401,10 @@ pub async fn handle_responses(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -351,11 +428,23 @@ pub async fn handle_responses(
|
||||
/// 处理 /v1/responses/compact 请求(OpenAI Responses Compact API - Codex CLI 透传)
|
||||
pub async fn handle_responses_compact(
|
||||
State(state): State<ProxyState>,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let uri = parts.uri;
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
let mut ctx =
|
||||
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
|
||||
let endpoint = endpoint_with_query(&uri, "/responses/compact");
|
||||
|
||||
let is_stream = body
|
||||
.get("stream")
|
||||
@@ -366,9 +455,10 @@ pub async fn handle_responses_compact(
|
||||
let result = match forwarder
|
||||
.forward_with_retry(
|
||||
&AppType::Codex,
|
||||
"/responses/compact",
|
||||
&endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
@@ -397,9 +487,19 @@ pub async fn handle_responses_compact(
|
||||
pub async fn handle_gemini(
|
||||
State(state): State<ProxyState>,
|
||||
uri: axum::http::Uri,
|
||||
headers: axum::http::HeaderMap,
|
||||
Json(body): Json<Value>,
|
||||
request: axum::extract::Request,
|
||||
) -> Result<axum::response::Response, ProxyError> {
|
||||
let (parts, req_body) = request.into_parts();
|
||||
let headers = parts.headers;
|
||||
let extensions = parts.extensions;
|
||||
let body_bytes = req_body
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to read request body: {e}")))?
|
||||
.to_bytes();
|
||||
let body: Value = serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ProxyError::Internal(format!("Failed to parse request body: {e}")))?;
|
||||
|
||||
// Gemini 的模型名称在 URI 中
|
||||
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
|
||||
.await?
|
||||
@@ -423,6 +523,7 @@ pub async fn handle_gemini(
|
||||
endpoint,
|
||||
body,
|
||||
headers,
|
||||
extensions,
|
||||
ctx.get_providers(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -219,7 +219,12 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
|
||||
.timeout(Duration::from_secs(600))
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60));
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
// 禁用 reqwest 自动解压:防止 reqwest 覆盖客户端原始 accept-encoding header。
|
||||
// 响应解压由 response_processor 根据 content-encoding 手动处理。
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate();
|
||||
|
||||
// 有代理地址则使用代理,否则跟随系统代理
|
||||
if let Some(url) = proxy_url {
|
||||
@@ -332,7 +337,7 @@ pub fn mask_url(url: &str) -> String {
|
||||
/// 根据供应商单独代理配置构建代理 URL
|
||||
///
|
||||
/// 将 ProviderProxyConfig 转换为代理 URL 字符串
|
||||
fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
pub fn build_proxy_url_from_config(config: &ProviderProxyConfig) -> Option<String> {
|
||||
let proxy_type = config.proxy_type.as_deref().unwrap_or("http");
|
||||
let host = config.proxy_host.as_deref()?;
|
||||
let port = config.proxy_port?;
|
||||
@@ -387,6 +392,9 @@ pub fn build_client_for_provider(proxy_config: Option<&ProviderProxyConfig>) ->
|
||||
.connect_timeout(Duration::from_secs(30))
|
||||
.pool_max_idle_per_host(10)
|
||||
.tcp_keepalive(Duration::from_secs(60))
|
||||
.no_gzip()
|
||||
.no_brotli()
|
||||
.no_deflate()
|
||||
.proxy(proxy)
|
||||
.build()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Hyper-based HTTP client for proxy forwarding
|
||||
//!
|
||||
//! Uses raw TCP/TLS writes to preserve exact original header name casing.
|
||||
//! Supports HTTP CONNECT tunneling through upstream proxies.
|
||||
//! Falls back to hyper-util Client (title-case headers) when raw write is not feasible.
|
||||
|
||||
use super::ProxyError;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::Stream;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::{client::legacy::Client, rt::TokioExecutor};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Our own header case map: maps lowercase header name → original wire-casing bytes.
|
||||
///
|
||||
/// This is a backup mechanism independent of hyper's internal `HeaderCaseMap` (which is
|
||||
/// `pub(crate)` and cannot be directly inspected or constructed from outside hyper).
|
||||
///
|
||||
/// Populated in `server.rs` by peeking at raw TCP bytes before hyper parses them.
|
||||
/// Used in `send_request` to manually write headers with original casing when hyper's
|
||||
/// own mechanism fails.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct OriginalHeaderCases {
|
||||
/// Ordered list of (lowercase_name, original_wire_bytes) pairs.
|
||||
/// Multiple entries with the same name are allowed (for repeated headers).
|
||||
pub cases: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl OriginalHeaderCases {
|
||||
/// Parse raw HTTP request bytes (from TcpStream::peek) to extract original header casings.
|
||||
pub fn from_raw_bytes(buf: &[u8]) -> Self {
|
||||
let mut headers_buf = [httparse::EMPTY_HEADER; 128];
|
||||
let mut req = httparse::Request::new(&mut headers_buf);
|
||||
// We don't care if parsing is partial — we just want the header names we can get
|
||||
let _ = req.parse(buf);
|
||||
|
||||
let mut cases = Vec::new();
|
||||
for header in req.headers.iter() {
|
||||
if header.name.is_empty() {
|
||||
break;
|
||||
}
|
||||
cases.push((
|
||||
header.name.to_ascii_lowercase(),
|
||||
header.name.as_bytes().to_vec(),
|
||||
));
|
||||
}
|
||||
|
||||
Self { cases }
|
||||
}
|
||||
}
|
||||
|
||||
type HyperClient = Client<
|
||||
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
|
||||
http_body_util::Full<Bytes>,
|
||||
>;
|
||||
|
||||
/// Lazily-initialized hyper client with header-case preservation enabled.
|
||||
fn global_hyper_client() -> &'static HyperClient {
|
||||
static CLIENT: OnceLock<HyperClient> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
let connector = HttpsConnectorBuilder::new()
|
||||
.with_webpki_roots()
|
||||
.https_or_http()
|
||||
.enable_http1()
|
||||
.build();
|
||||
|
||||
Client::builder(TokioExecutor::new())
|
||||
.http1_preserve_header_case(true)
|
||||
.http1_title_case_headers(true)
|
||||
.build(connector)
|
||||
})
|
||||
}
|
||||
|
||||
/// Unified response wrapper that can hold either a hyper or reqwest response.
|
||||
///
|
||||
/// The hyper variant is used for the main (direct) path with header-case preservation.
|
||||
/// The reqwest variant is the fallback when an upstream HTTP/SOCKS5 proxy is configured.
|
||||
pub enum ProxyResponse {
|
||||
Hyper(hyper::Response<hyper::body::Incoming>),
|
||||
Reqwest(reqwest::Response),
|
||||
}
|
||||
|
||||
impl ProxyResponse {
|
||||
pub fn status(&self) -> http::StatusCode {
|
||||
match self {
|
||||
Self::Hyper(r) => r.status(),
|
||||
Self::Reqwest(r) => r.status(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn headers(&self) -> &http::HeaderMap {
|
||||
match self {
|
||||
Self::Hyper(r) => r.headers(),
|
||||
Self::Reqwest(r) => r.headers(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shortcut: extract `content-type` header value as `&str`.
|
||||
pub fn content_type(&self) -> Option<&str> {
|
||||
self.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
}
|
||||
|
||||
/// Check if the response is an SSE stream.
|
||||
pub fn is_sse(&self) -> bool {
|
||||
self.content_type()
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Consume the response and collect the full body into `Bytes`.
|
||||
pub async fn bytes(self) -> Result<Bytes, ProxyError> {
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let collected = r.into_body().collect().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
Ok(collected.to_bytes())
|
||||
}
|
||||
Self::Reqwest(r) => r.bytes().await.map_err(|e| {
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the response and return a byte-chunk stream (for SSE pass-through).
|
||||
pub fn bytes_stream(self) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
use futures::StreamExt;
|
||||
|
||||
match self {
|
||||
Self::Hyper(r) => {
|
||||
let body = r.into_body();
|
||||
let stream = futures::stream::unfold(body, |mut body| async {
|
||||
match body.frame().await {
|
||||
Some(Ok(frame)) => {
|
||||
if let Ok(data) = frame.into_data() {
|
||||
if data.is_empty() {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
} else {
|
||||
Some((Ok(data), body))
|
||||
}
|
||||
} else {
|
||||
Some((Ok(Bytes::new()), body))
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => Some((Err(std::io::Error::other(e.to_string())), body)),
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.filter(|result| {
|
||||
futures::future::ready(!matches!(result, Ok(ref b) if b.is_empty()))
|
||||
});
|
||||
Box::pin(stream)
|
||||
as std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>
|
||||
}
|
||||
Self::Reqwest(r) => {
|
||||
let stream = r
|
||||
.bytes_stream()
|
||||
.map(|r| r.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
Box::pin(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an HTTP request with header-case preservation.
|
||||
///
|
||||
/// Uses a two-tier strategy:
|
||||
/// 1. Primary: raw HTTP/1.1 write via TLS stream with exact original header casing
|
||||
/// (from `OriginalHeaderCases` captured by peek in server.rs), then hand off to
|
||||
/// hyper for response parsing.
|
||||
/// 2. Fallback: hyper-util Client with `title_case_headers(true)` when raw write
|
||||
/// isn't feasible (e.g., missing original cases).
|
||||
///
|
||||
/// The caller is expected to include `Host` in the supplied `headers` at the
|
||||
/// correct position.
|
||||
///
|
||||
/// `proxy_url`: optional upstream HTTP proxy URL (e.g. `http://127.0.0.1:7890`).
|
||||
/// When set, the raw write path uses HTTP CONNECT tunneling through the proxy,
|
||||
/// so header-case preservation works even when an upstream proxy is configured.
|
||||
pub async fn send_request(
|
||||
uri: http::Uri,
|
||||
method: http::Method,
|
||||
headers: http::HeaderMap,
|
||||
original_extensions: http::Extensions,
|
||||
body: Vec<u8>,
|
||||
timeout: std::time::Duration,
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
// Extract our own OriginalHeaderCases if available
|
||||
let original_cases = original_extensions.get::<OriginalHeaderCases>().cloned();
|
||||
let has_cases = original_cases
|
||||
.as_ref()
|
||||
.map(|c| !c.cases.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] Sending request: uri={uri}, header_count={}, \
|
||||
has_host={}, has_original_cases={has_cases}, proxy={:?}",
|
||||
headers.len(),
|
||||
headers.contains_key(http::header::HOST),
|
||||
proxy_url,
|
||||
);
|
||||
|
||||
if has_cases {
|
||||
// Primary path: use raw write + hyper handshake for exact header casing
|
||||
let result = tokio::time::timeout(
|
||||
timeout,
|
||||
send_raw_request(
|
||||
&uri,
|
||||
&method,
|
||||
&headers,
|
||||
original_cases.as_ref().unwrap(),
|
||||
&body,
|
||||
proxy_url,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?;
|
||||
|
||||
match result {
|
||||
Ok(resp) => return Ok(resp),
|
||||
Err(e) => {
|
||||
if proxy_url.is_some() {
|
||||
// Don't bypass configured proxy with direct connect fallback
|
||||
return Err(e);
|
||||
}
|
||||
log::warn!("[HyperClient] Raw write failed, falling back to hyper-util: {e}");
|
||||
// Fall through to hyper-util Client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hyper-util Client (title-case headers, no proxy support)
|
||||
let mut req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri(&uri)
|
||||
.body(http_body_util::Full::new(Bytes::from(body)))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Failed to build request: {e}")))?;
|
||||
|
||||
*req.headers_mut() = headers;
|
||||
*req.extensions_mut() = original_extensions;
|
||||
|
||||
let client = global_hyper_client();
|
||||
let resp = tokio::time::timeout(timeout, client.request(req))
|
||||
.await
|
||||
.map_err(|_| ProxyError::Timeout(format!("请求超时: {}s", timeout.as_secs())))?
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("上游请求失败: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// TCP or TLS stream returned by `connect_via_proxy`.
|
||||
///
|
||||
/// When the proxy URL uses `https://`, the connection to the proxy itself is
|
||||
/// TLS-wrapped before sending the CONNECT request. The enum lets
|
||||
/// `send_raw_request` work with either variant generically.
|
||||
enum ProxyStream {
|
||||
Tcp(tokio::net::TcpStream),
|
||||
Tls(Box<tokio_rustls::client::TlsStream<tokio::net::TcpStream>>),
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ProxyStream {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_read(cx, buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for ProxyStream {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_write(cx, buf),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_flush(cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.get_mut() {
|
||||
ProxyStream::Tcp(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
ProxyStream::Tls(s) => std::pin::Pin::new(s).poll_shutdown(cx),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send request via raw TCP/TLS with exact original header casing.
|
||||
///
|
||||
/// When `proxy_url` is provided, establishes an HTTP CONNECT tunnel through
|
||||
/// the proxy first, then performs TLS + raw write through the tunnel.
|
||||
/// This preserves header casing even when an upstream proxy is configured.
|
||||
async fn send_raw_request(
|
||||
uri: &http::Uri,
|
||||
method: &http::Method,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
proxy_url: Option<&str>,
|
||||
) -> Result<ProxyResponse, ProxyError> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let scheme = uri.scheme_str().unwrap_or("https");
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("URI has no host".into()))?;
|
||||
let port = uri
|
||||
.port_u16()
|
||||
.unwrap_or(if scheme == "https" { 443 } else { 80 });
|
||||
let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
|
||||
|
||||
// Build raw HTTP request bytes
|
||||
let raw = build_raw_request(method, path_and_query, headers, original_cases, body);
|
||||
|
||||
// Establish TCP connection — either direct or through HTTP CONNECT proxy
|
||||
let stream = if let Some(proxy) = proxy_url {
|
||||
connect_via_proxy(proxy, host, port).await?
|
||||
} else {
|
||||
ProxyStream::Tcp(
|
||||
tokio::net::TcpStream::connect((host, port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TCP connect failed: {e}")))?,
|
||||
)
|
||||
};
|
||||
|
||||
if scheme == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid server name: {e}")))?;
|
||||
let mut tls_stream = tls_connector
|
||||
.connect(server_name, stream)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("TLS handshake failed: {e}")))?;
|
||||
|
||||
tls_stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(tls_stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
} else {
|
||||
let mut stream = stream;
|
||||
stream
|
||||
.write_all(&raw)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Write failed: {e}")))?;
|
||||
|
||||
let filtered = WriteFilter::new(stream);
|
||||
do_hyper_response(filtered, method.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a connection through an HTTP CONNECT proxy tunnel.
|
||||
///
|
||||
/// 1. Connect TCP to the proxy server (TLS-wrapped when `https://` proxy)
|
||||
/// 2. Send `CONNECT host:port HTTP/1.1` with optional `Proxy-Authorization`
|
||||
/// 3. Read the proxy's 200 response (407 → `AuthError`)
|
||||
/// 4. Return the tunneled stream (ready for target TLS handshake + raw write)
|
||||
async fn connect_via_proxy(
|
||||
proxy_url: &str,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> Result<ProxyStream, ProxyError> {
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
|
||||
let parsed = url::Url::parse(proxy_url)
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy URL: {e}")))?;
|
||||
|
||||
let proxy_host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ProxyError::ForwardFailed("Proxy URL has no host".into()))?;
|
||||
let proxy_port = parsed
|
||||
.port()
|
||||
.unwrap_or(if parsed.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
// Build Proxy-Authorization header if credentials are present
|
||||
let proxy_auth = if !parsed.username().is_empty() {
|
||||
let password = parsed.password().unwrap_or("");
|
||||
let credentials = format!("{}:{}", parsed.username(), password);
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(credentials);
|
||||
Some(format!("Proxy-Authorization: Basic {encoded}\r\n"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Connect to the proxy
|
||||
let tcp = tokio::net::TcpStream::connect((proxy_host, proxy_port))
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TCP connect failed: {e}")))?;
|
||||
|
||||
// Wrap with TLS if the proxy URL uses https://
|
||||
let mut stream: ProxyStream = if parsed.scheme() == "https" {
|
||||
let tls_connector = global_tls_connector();
|
||||
let server_name = rustls::pki_types::ServerName::try_from(proxy_host.to_string())
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Invalid proxy server name: {e}")))?;
|
||||
let tls_stream = tls_connector
|
||||
.connect(server_name, tcp)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Proxy TLS handshake failed: {e}")))?;
|
||||
ProxyStream::Tls(Box::new(tls_stream))
|
||||
} else {
|
||||
ProxyStream::Tcp(tcp)
|
||||
};
|
||||
|
||||
// Send CONNECT request
|
||||
let mut connect_req = format!(
|
||||
"CONNECT {target_host}:{target_port} HTTP/1.1\r\n\
|
||||
Host: {target_host}:{target_port}\r\n"
|
||||
);
|
||||
if let Some(auth) = &proxy_auth {
|
||||
connect_req.push_str(auth);
|
||||
}
|
||||
connect_req.push_str("\r\n");
|
||||
|
||||
stream
|
||||
.write_all(connect_req.as_bytes())
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT write failed: {e}")))?;
|
||||
|
||||
// Read the proxy's response status line
|
||||
let mut reader = BufReader::new(&mut stream);
|
||||
let mut status_line = String::new();
|
||||
reader
|
||||
.read_line(&mut status_line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT read failed: {e}")))?;
|
||||
|
||||
// Expect "HTTP/1.1 200 ..." or "HTTP/1.0 200 ..."
|
||||
if !status_line.contains(" 200 ") {
|
||||
if status_line.contains(" 407 ") {
|
||||
return Err(ProxyError::AuthError(format!(
|
||||
"Proxy authentication required (407): {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
return Err(ProxyError::ForwardFailed(format!(
|
||||
"Proxy CONNECT rejected: {}",
|
||||
status_line.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// Drain remaining response headers (until empty line)
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("CONNECT header read: {e}")))?;
|
||||
if line.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// BufReader might have buffered data; drop it to get raw stream back.
|
||||
// Since CONNECT response is headers-only (no body), and we read until \r\n\r\n,
|
||||
// the BufReader buffer should be empty at this point.
|
||||
drop(reader);
|
||||
|
||||
log::debug!(
|
||||
"[HyperClient] CONNECT tunnel established via {proxy_host}:{proxy_port} -> {target_host}:{target_port}"
|
||||
);
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Lazily-initialized TLS connector for raw connections.
|
||||
///
|
||||
/// Loads both webpki roots AND native system certificates so that
|
||||
/// proxy MITM CAs (e.g. Clash, mitmproxy) installed in the system
|
||||
/// keychain are trusted through the CONNECT tunnel.
|
||||
fn global_tls_connector() -> &'static tokio_rustls::TlsConnector {
|
||||
static CONNECTOR: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
|
||||
CONNECTOR.get_or_init(|| {
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
// Baseline: Mozilla/webpki roots
|
||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
||||
// Native system certs (includes user-installed proxy CAs)
|
||||
let native = rustls_native_certs::load_native_certs();
|
||||
let (added, _errors) = root_store.add_parsable_certificates(native.certs);
|
||||
log::debug!("[HyperClient] TLS root store: webpki + {added} native certs");
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
tokio_rustls::TlsConnector::from(std::sync::Arc::new(config))
|
||||
})
|
||||
}
|
||||
|
||||
/// Build raw HTTP/1.1 request bytes with original header casing.
|
||||
fn build_raw_request(
|
||||
method: &http::Method,
|
||||
path_and_query: &str,
|
||||
headers: &http::HeaderMap,
|
||||
original_cases: &OriginalHeaderCases,
|
||||
body: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut raw = Vec::with_capacity(4096 + body.len());
|
||||
|
||||
// Request line
|
||||
raw.extend_from_slice(method.as_str().as_bytes());
|
||||
raw.extend_from_slice(b" ");
|
||||
raw.extend_from_slice(path_and_query.as_bytes());
|
||||
raw.extend_from_slice(b" HTTP/1.1\r\n");
|
||||
|
||||
// Headers with original casing, emitted in original wire order.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Walk `original_cases.cases` in order — this preserves the exact
|
||||
// header sequence the client sent. For each entry, emit the stored
|
||||
// original-casing name plus the current value from `headers` (the
|
||||
// proxy may have rewritten the value, e.g. Authorization).
|
||||
// Repeated headers with the same name are handled by tracking a
|
||||
// per-name value cursor so we step through `get_all()` in order.
|
||||
// 2. After the original headers, append any headers that exist in
|
||||
// `headers` but were not present in the original request (i.e. added
|
||||
// by the proxy). These are emitted in lowercase.
|
||||
//
|
||||
// This replaces the old `for name in headers.keys()` loop which iterated
|
||||
// in hash-map order, destroying the original header sequence.
|
||||
let mut emitted: std::collections::HashSet<String> =
|
||||
std::collections::HashSet::with_capacity(original_cases.cases.len());
|
||||
// Per-name cursor: how many values we have already emitted for each name.
|
||||
let mut value_cursor: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::with_capacity(original_cases.cases.len());
|
||||
|
||||
for (lower_name, orig_name_bytes) in &original_cases.cases {
|
||||
if let Ok(header_name) = http::header::HeaderName::from_bytes(lower_name.as_bytes()) {
|
||||
let all_values: Vec<_> = headers.get_all(&header_name).iter().collect();
|
||||
let cursor = value_cursor.entry(lower_name.clone()).or_insert(0);
|
||||
if let Some(value) = all_values.get(*cursor) {
|
||||
raw.extend_from_slice(orig_name_bytes);
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
*cursor += 1;
|
||||
emitted.insert(lower_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append proxy-added headers (not present in the original request).
|
||||
for name in headers.keys() {
|
||||
let lower = name.as_str().to_ascii_lowercase();
|
||||
if !emitted.contains(&lower) {
|
||||
for value in headers.get_all(name) {
|
||||
raw.extend_from_slice(name.as_str().as_bytes());
|
||||
raw.extend_from_slice(b": ");
|
||||
raw.extend_from_slice(value.as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
emitted.insert(lower);
|
||||
}
|
||||
}
|
||||
|
||||
// Add Content-Length if not already present
|
||||
if !headers.contains_key(http::header::CONTENT_LENGTH) {
|
||||
raw.extend_from_slice(b"Content-Length: ");
|
||||
raw.extend_from_slice(body.len().to_string().as_bytes());
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
// End of headers + body
|
||||
raw.extend_from_slice(b"\r\n");
|
||||
raw.extend_from_slice(body);
|
||||
|
||||
raw
|
||||
}
|
||||
|
||||
/// Use hyper's low-level client to parse the response on a stream where we've
|
||||
/// already written the request.
|
||||
///
|
||||
/// `WriteFilter` discards any writes from hyper (it would try to send its own
|
||||
/// request encoding), while passing reads through transparently.
|
||||
async fn do_hyper_response<S>(
|
||||
stream: WriteFilter<S>,
|
||||
method: http::Method,
|
||||
) -> Result<ProxyResponse, ProxyError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let io = hyper_util::rt::TokioIo::new(stream);
|
||||
|
||||
let (mut sender, conn) = hyper::client::conn::http1::Builder::new()
|
||||
.preserve_header_case(true)
|
||||
.handshake::<_, http_body_util::Full<Bytes>>(io)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Handshake failed: {e}")))?;
|
||||
|
||||
// Spawn the connection driver (reads responses from the stream)
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = conn.await {
|
||||
log::debug!("[HyperClient] raw conn driver error: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
// Send a dummy request through hyper — hyper will encode this and try to write it,
|
||||
// but WriteFilter discards all writes. Hyper will then read the response from the stream.
|
||||
let dummy_req = http::Request::builder()
|
||||
.method(method)
|
||||
.uri("/")
|
||||
.body(http_body_util::Full::new(Bytes::new()))
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Build dummy request: {e}")))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(dummy_req)
|
||||
.await
|
||||
.map_err(|e| ProxyError::ForwardFailed(format!("Response parse failed: {e}")))?;
|
||||
|
||||
Ok(ProxyResponse::Hyper(resp))
|
||||
}
|
||||
|
||||
/// A stream wrapper that discards all writes but passes reads through.
|
||||
///
|
||||
/// This lets hyper's connection driver think it sent a request (its encoded bytes
|
||||
/// go to /dev/null), while correctly parsing the response that the upstream server
|
||||
/// sends in reply to our raw-written request.
|
||||
struct WriteFilter<S> {
|
||||
inner: S,
|
||||
}
|
||||
|
||||
impl<S> WriteFilter<S> {
|
||||
fn new(inner: S) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: tokio::io::AsyncRead + Unpin> tokio::io::AsyncRead for WriteFilter<S> {
|
||||
fn poll_read(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
// Pass reads through to the underlying stream
|
||||
let inner = std::pin::Pin::new(&mut self.get_mut().inner);
|
||||
inner.poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Unpin> tokio::io::AsyncWrite for WriteFilter<S> {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
// Discard all writes — pretend they succeeded
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,15 @@ pub mod srv {
|
||||
pub const STOPPED: &str = "SRV-002";
|
||||
pub const STOP_TIMEOUT: &str = "SRV-003";
|
||||
pub const TASK_ERROR: &str = "SRV-004";
|
||||
pub const ACCEPT_ERR: &str = "SRV-005";
|
||||
pub const CONN_ERR: &str = "SRV-006";
|
||||
}
|
||||
|
||||
/// 转发器日志码
|
||||
pub mod fwd {
|
||||
pub const PROVIDER_FAILED_RETRY: &str = "FWD-001";
|
||||
pub const ALL_PROVIDERS_FAILED: &str = "FWD-002";
|
||||
pub const SINGLE_PROVIDER_FAILED: &str = "FWD-003";
|
||||
}
|
||||
|
||||
/// 故障转移日志码
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
|
||||
|
||||
pub mod body_filter;
|
||||
pub mod cache_injector;
|
||||
pub mod circuit_breaker;
|
||||
pub mod error;
|
||||
pub mod error_mapper;
|
||||
@@ -13,6 +14,7 @@ pub mod handler_context;
|
||||
mod handlers;
|
||||
mod health;
|
||||
pub mod http_client;
|
||||
pub mod hyper_client;
|
||||
pub mod log_codes;
|
||||
pub mod model_mapper;
|
||||
pub mod provider_router;
|
||||
@@ -21,7 +23,9 @@ pub mod response_handler;
|
||||
pub mod response_processor;
|
||||
pub(crate) mod server;
|
||||
pub mod session;
|
||||
pub(crate) mod sse;
|
||||
pub mod thinking_budget_rectifier;
|
||||
pub mod thinking_optimizer;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod usage;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
use super::auth::AuthInfo;
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde_json::Value;
|
||||
|
||||
/// 供应商适配器 Trait
|
||||
@@ -14,116 +13,36 @@ use serde_json::Value;
|
||||
/// - URL 构建
|
||||
/// - 认证信息提取和头部注入
|
||||
/// - 请求/响应格式转换(可选)
|
||||
///
|
||||
/// # 示例
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub struct ClaudeAdapter;
|
||||
///
|
||||
/// impl ProviderAdapter for ClaudeAdapter {
|
||||
/// fn name(&self) -> &'static str { "Claude" }
|
||||
///
|
||||
/// fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError> {
|
||||
/// // 从 provider 配置中提取 base_url
|
||||
/// }
|
||||
///
|
||||
/// fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
/// // 从 provider 配置中提取认证信息
|
||||
/// }
|
||||
///
|
||||
/// fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
/// format!("{}{}", base_url.trim_end_matches('/'), endpoint)
|
||||
/// }
|
||||
///
|
||||
/// fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
/// // 添加认证头
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
/// 适配器名称(用于日志和调试)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// 从 Provider 配置中提取 base_url
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(String)` - 提取到的 base_url(已去除尾部斜杠)
|
||||
/// * `Err(ProxyError)` - 提取失败
|
||||
fn extract_base_url(&self, provider: &Provider) -> Result<String, ProxyError>;
|
||||
|
||||
/// 从 Provider 配置中提取认证信息
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Some(AuthInfo)` - 提取到的认证信息
|
||||
/// * `None` - 未找到认证信息
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo>;
|
||||
|
||||
/// 构建请求 URL
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `base_url` - 基础 URL
|
||||
/// * `endpoint` - 请求端点(如 `/v1/messages`)
|
||||
///
|
||||
/// # Returns
|
||||
/// 完整的请求 URL
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String;
|
||||
|
||||
/// 添加认证头到请求
|
||||
/// Return auth headers as `(name, value)` pairs.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - reqwest RequestBuilder
|
||||
/// * `auth` - 认证信息
|
||||
///
|
||||
/// # Returns
|
||||
/// 添加了认证头的 RequestBuilder
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder;
|
||||
/// The forwarder inserts these at the position of the original auth header
|
||||
/// so that header order is preserved.
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)>;
|
||||
|
||||
/// 是否需要格式转换
|
||||
///
|
||||
/// 默认返回 `false`(透传模式)。
|
||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `provider` - Provider 配置
|
||||
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 转换请求体
|
||||
///
|
||||
/// 将请求体从一种格式转换为另一种格式(如 Anthropic → OpenAI)。
|
||||
/// 默认实现直接返回原始请求体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始请求体
|
||||
/// * `provider` - Provider 配置(用于获取模型映射等)
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的请求体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
fn transform_request(&self, body: Value, _provider: &Provider) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
/// 转换响应体
|
||||
///
|
||||
/// 将响应体从一种格式转换为另一种格式(如 OpenAI → Anthropic)。
|
||||
/// 默认实现直接返回原始响应体(透传)。
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `body` - 原始响应体
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Value)` - 转换后的响应体
|
||||
/// * `Err(ProxyError)` - 转换失败
|
||||
///
|
||||
/// Note: 响应转换将在 handler 层集成,目前预留接口
|
||||
#[allow(dead_code)]
|
||||
fn transform_response(&self, body: Value) -> Result<Value, ProxyError> {
|
||||
Ok(body)
|
||||
|
||||
@@ -112,6 +112,13 @@ pub enum AuthStrategy {
|
||||
///
|
||||
/// 用于 Gemini CLI 等需要 OAuth 的场景
|
||||
GoogleOAuth,
|
||||
|
||||
/// GitHub Copilot 认证方式
|
||||
///
|
||||
/// - Header: `Authorization: Bearer <copilot_token>`
|
||||
///
|
||||
/// 使用动态获取的 Copilot Token(通过 GitHub OAuth 设备码流程获取)
|
||||
GitHubCopilot,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -226,6 +233,7 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
AuthStrategy::Google,
|
||||
AuthStrategy::GoogleOAuth,
|
||||
AuthStrategy::GitHubCopilot,
|
||||
];
|
||||
|
||||
for (i, s1) in strategies.iter().enumerate() {
|
||||
|
||||
@@ -1,20 +1,93 @@
|
||||
//! Claude (Anthropic) Provider Adapter
|
||||
//!
|
||||
//! 支持透传模式和 OpenAI Chat Completions 格式转换模式
|
||||
//! 支持透传模式和 OpenAI 格式转换模式
|
||||
//!
|
||||
//! ## API 格式
|
||||
//! - **anthropic** (默认): Anthropic Messages API 格式,直接透传
|
||||
//! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换
|
||||
//! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换
|
||||
//!
|
||||
//! ## 认证模式
|
||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传
|
||||
//! - **GitHubCopilot**: GitHub Copilot (OAuth + Copilot Token)
|
||||
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// 获取 Claude 供应商的 API 格式
|
||||
///
|
||||
/// 供 handler/forwarder 外部使用的公开函数。
|
||||
/// 优先级:meta.apiFormat > settings_config.api_format > openrouter_compat_mode > 默认 "anthropic"
|
||||
pub fn get_claude_api_format(provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return match api_format {
|
||||
"openai_chat" => "openai_chat",
|
||||
"openai_responses" => "openai_responses",
|
||||
_ => "anthropic",
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn claude_api_format_needs_transform(api_format: &str) -> bool {
|
||||
matches!(api_format, "openai_chat" | "openai_responses")
|
||||
}
|
||||
|
||||
pub fn transform_claude_request_for_api_format(
|
||||
body: serde_json::Value,
|
||||
provider: &Provider,
|
||||
api_format: &str,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Claude 适配器
|
||||
pub struct ClaudeAdapter;
|
||||
@@ -27,10 +100,16 @@ impl ClaudeAdapter {
|
||||
/// 获取供应商类型
|
||||
///
|
||||
/// 根据 base_url 和 auth_mode 检测具体的供应商类型:
|
||||
/// - GitHubCopilot: meta.provider_type 为 github_copilot 或 base_url 包含 githubcopilot.com
|
||||
/// - OpenRouter: base_url 包含 openrouter.ai
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
|
||||
// 检测 OpenRouter
|
||||
if self.is_openrouter(provider) {
|
||||
return ProviderType::OpenRouter;
|
||||
@@ -44,6 +123,25 @@ impl ClaudeAdapter {
|
||||
ProviderType::Claude
|
||||
}
|
||||
|
||||
/// 检测是否为 GitHub Copilot 供应商
|
||||
fn is_github_copilot(&self, provider: &Provider) -> bool {
|
||||
// 方式1: 检查 meta.provider_type
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 方式2: 检查 base_url(兼容旧数据的 fallback,后续应优先依赖 providerType)
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
if base_url.contains("githubcopilot.com") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测是否使用 OpenRouter
|
||||
fn is_openrouter(&self, provider: &Provider) -> bool {
|
||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
||||
@@ -57,48 +155,9 @@ impl ClaudeAdapter {
|
||||
/// 从 provider.meta.api_format 读取格式设置:
|
||||
/// - "anthropic" (默认): Anthropic Messages API 格式,直接透传
|
||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||
/// - "openai_responses": OpenAI Responses API 格式,需要格式转换
|
||||
fn get_api_format(&self, provider: &Provider) -> &'static str {
|
||||
// 1) Preferred: meta.apiFormat (SSOT, never written to Claude Code config)
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if let Some(api_format) = meta.api_format.as_deref() {
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Backward compatibility: legacy settings_config.api_format
|
||||
if let Some(api_format) = provider
|
||||
.settings_config
|
||||
.get("api_format")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return if api_format == "openai_chat" {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
};
|
||||
}
|
||||
|
||||
// 3) Backward compatibility: legacy openrouter_compat_mode (bool/number/string)
|
||||
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||
let enabled = match raw {
|
||||
Some(serde_json::Value::Bool(v)) => *v,
|
||||
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
let normalized = value.trim().to_lowercase();
|
||||
normalized == "true" || normalized == "1"
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if enabled {
|
||||
"openai_chat"
|
||||
} else {
|
||||
"anthropic"
|
||||
}
|
||||
get_claude_api_format(provider)
|
||||
}
|
||||
|
||||
/// 检测是否为仅 Bearer 认证模式
|
||||
@@ -234,6 +293,17 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
|
||||
fn extract_auth(&self, provider: &Provider) -> Option<AuthInfo> {
|
||||
let provider_type = self.provider_type(provider);
|
||||
|
||||
// GitHub Copilot 使用特殊的认证策略
|
||||
// 实际的 token 会在代理请求时动态获取
|
||||
if provider_type == ProviderType::GitHubCopilot {
|
||||
// 返回一个占位符,实际 token 由 CopilotAuthManager 动态提供
|
||||
return Some(AuthInfo::new(
|
||||
"copilot_placeholder".to_string(),
|
||||
AuthStrategy::GitHubCopilot,
|
||||
));
|
||||
}
|
||||
|
||||
let strategy = match provider_type {
|
||||
ProviderType::OpenRouter => AuthStrategy::Bearer,
|
||||
ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth,
|
||||
@@ -251,7 +321,7 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
//
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
//
|
||||
let mut base = format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
@@ -263,58 +333,91 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
|
||||
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions,
|
||||
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
|
||||
if endpoint.contains("/v1/messages")
|
||||
&& !endpoint.contains("/v1/chat/completions")
|
||||
&& !endpoint.contains('?')
|
||||
{
|
||||
format!("{base}?beta=true")
|
||||
} else {
|
||||
base
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
// 注意:anthropic-version 由 forwarder.rs 统一处理(透传客户端值或设置默认值)
|
||||
// 这里不再设置 anthropic-version,避免 header 重复
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
match auth.strategy {
|
||||
// Anthropic 官方: Authorization Bearer + x-api-key
|
||||
AuthStrategy::Anthropic => request
|
||||
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
.header("x-api-key", &auth.api_key),
|
||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||
AuthStrategy::ClaudeAuth => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
AuthStrategy::Anthropic | AuthStrategy::ClaudeAuth | AuthStrategy::Bearer => {
|
||||
vec![(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
// OpenRouter: Bearer
|
||||
AuthStrategy::Bearer => {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
AuthStrategy::GitHubCopilot => {
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&bearer).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_EDITOR_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("editor-plugin-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_PLUGIN_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("copilot-integration-id"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_INTEGRATION_ID),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("user-agent"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_USER_AGENT),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-github-api-version"),
|
||||
HeaderValue::from_static(super::copilot_auth::COPILOT_API_VERSION),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("openai-intent"),
|
||||
HeaderValue::from_static("conversation-panel"),
|
||||
),
|
||||
]
|
||||
}
|
||||
_ => request,
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
||||
// GitHub Copilot 总是需要格式转换 (Anthropic → OpenAI)
|
||||
if self.is_github_copilot(provider) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 根据 api_format 配置决定是否需要格式转换
|
||||
// - "anthropic" (默认): 直接透传,无需转换
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI 格式转换
|
||||
self.get_api_format(provider) == "openai_chat"
|
||||
// - "openai_chat": 需要 Anthropic ↔ OpenAI Chat Completions 格式转换
|
||||
// - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换
|
||||
matches!(
|
||||
self.get_api_format(provider),
|
||||
"openai_chat" | "openai_responses"
|
||||
)
|
||||
}
|
||||
|
||||
fn transform_request(
|
||||
&self,
|
||||
body: serde_json::Value,
|
||||
_provider: &Provider,
|
||||
provider: &Provider,
|
||||
) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::anthropic_to_openai(body)
|
||||
transform_claude_request_for_api_format(body, provider, self.get_api_format(provider))
|
||||
}
|
||||
|
||||
fn transform_response(&self, body: serde_json::Value) -> Result<serde_json::Value, ProxyError> {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
// Heuristic: detect response format by presence of top-level fields.
|
||||
// The ProviderAdapter trait's transform_response doesn't receive the Provider
|
||||
// config, so we can't check api_format here. Instead we rely on the fact that
|
||||
// Responses API always returns "output" while Chat Completions returns "choices".
|
||||
// This is safe because the two formats are structurally disjoint.
|
||||
if body.get("output").is_some() {
|
||||
super::transform_responses::responses_to_anthropic(body)
|
||||
} else {
|
||||
super::transform::openai_to_anthropic(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,23 +590,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_anthropic() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_openrouter() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// /v1/messages 端点会自动添加 ?beta=true 参数
|
||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
|
||||
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_other_endpoints() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 非 /v1/messages 端点不添加 ?beta=true
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/complete");
|
||||
}
|
||||
@@ -511,16 +611,20 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_url_preserve_existing_query() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// 已有查询参数时不重复添加
|
||||
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
|
||||
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_github_copilot() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
let url = adapter.build_url("https://api.githubcopilot.com", "/v1/messages");
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_no_beta_for_openai_chat_completions() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
// OpenAI Chat Completions 端点不添加 ?beta=true
|
||||
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
|
||||
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
|
||||
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
|
||||
}
|
||||
@@ -599,6 +703,20 @@ mod tests {
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_chat_provider));
|
||||
|
||||
// OpenAI Responses format in meta: needs transform
|
||||
let openai_responses_provider = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
api_format: Some("openai_responses".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(adapter.needs_transform(&openai_responses_provider));
|
||||
|
||||
// meta takes precedence over legacy settings_config fields
|
||||
let meta_precedence_over_settings = create_provider_with_meta(
|
||||
json!({
|
||||
@@ -629,4 +747,88 @@ mod tests {
|
||||
);
|
||||
assert!(!adapter.needs_transform(&unknown_format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_detection_by_url() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// GitHub Copilot by base_url
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
assert_eq!(adapter.provider_type(&copilot), ProviderType::GitHubCopilot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_detection_by_meta() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
// GitHub Copilot by meta.provider_type
|
||||
let copilot_meta = create_provider_with_meta(
|
||||
json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.example.com"
|
||||
}
|
||||
}),
|
||||
ProviderMeta {
|
||||
provider_type: Some("github_copilot".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
adapter.provider_type(&copilot_meta),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_auth() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
|
||||
let auth = adapter.extract_auth(&copilot).unwrap();
|
||||
assert_eq!(auth.strategy, AuthStrategy::GitHubCopilot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_copilot_needs_transform() {
|
||||
let adapter = ClaudeAdapter::new();
|
||||
|
||||
let copilot = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
|
||||
// GitHub Copilot always needs transform
|
||||
assert!(adapter.needs_transform(&copilot));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transform_claude_request_for_api_format_responses() {
|
||||
let provider = create_provider(json!({
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://api.githubcopilot.com"
|
||||
}
|
||||
}));
|
||||
let body = json!({
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{ "role": "user", "content": "hello" }],
|
||||
"max_tokens": 128
|
||||
});
|
||||
|
||||
let transformed =
|
||||
transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap();
|
||||
|
||||
assert_eq!(transformed["model"], "gpt-5.4");
|
||||
assert!(transformed.get("input").is_some());
|
||||
assert!(transformed.get("max_output_tokens").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// 官方 Codex 客户端 User-Agent 正则
|
||||
@@ -174,8 +173,12 @@ impl ProviderAdapter for CodexAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
let bearer = format!("Bearer {}", auth.api_key);
|
||||
vec![(
|
||||
http::HeaderName::from_static("authorization"),
|
||||
http::HeaderValue::from_str(&bearer).unwrap(),
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
pub struct GeminiAdapter;
|
||||
@@ -217,17 +216,26 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
url
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
fn get_auth_headers(&self, auth: &AuthInfo) -> Vec<(http::HeaderName, http::HeaderValue)> {
|
||||
use http::{HeaderName, HeaderValue};
|
||||
match auth.strategy {
|
||||
// OAuth Bearer 认证
|
||||
AuthStrategy::GoogleOAuth => {
|
||||
let token = auth.access_token.as_ref().unwrap_or(&auth.api_key);
|
||||
request
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.header("x-goog-api-client", "GeminiCLI/1.0")
|
||||
vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("x-goog-api-client"),
|
||||
HeaderValue::from_static("GeminiCLI/1.0"),
|
||||
),
|
||||
]
|
||||
}
|
||||
// API Key 认证
|
||||
_ => request.header("x-goog-api-key", &auth.api_key),
|
||||
_ => vec![(
|
||||
HeaderName::from_static("x-goog-api-key"),
|
||||
HeaderValue::from_str(&auth.api_key).unwrap(),
|
||||
)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,13 @@ mod adapter;
|
||||
mod auth;
|
||||
mod claude;
|
||||
mod codex;
|
||||
pub mod copilot_auth;
|
||||
mod gemini;
|
||||
pub mod models;
|
||||
pub mod streaming;
|
||||
pub mod streaming_responses;
|
||||
pub mod transform;
|
||||
pub mod transform_responses;
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::provider::Provider;
|
||||
@@ -27,7 +30,10 @@ use serde::{Deserialize, Serialize};
|
||||
// 公开导出
|
||||
pub use adapter::ProviderAdapter;
|
||||
pub use auth::{AuthInfo, AuthStrategy};
|
||||
pub use claude::ClaudeAdapter;
|
||||
pub use claude::{
|
||||
claude_api_format_needs_transform, get_claude_api_format,
|
||||
transform_claude_request_for_api_format, ClaudeAdapter,
|
||||
};
|
||||
pub use codex::CodexAdapter;
|
||||
pub use gemini::GeminiAdapter;
|
||||
|
||||
@@ -50,6 +56,8 @@ pub enum ProviderType {
|
||||
GeminiCli,
|
||||
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||
OpenRouter,
|
||||
/// GitHub Copilot (OAuth + Copilot Token,需要 Anthropic ↔ OpenAI 转换)
|
||||
GitHubCopilot,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
@@ -57,9 +65,11 @@ impl ProviderType {
|
||||
///
|
||||
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||||
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||||
/// GitHub Copilot 需要转换(Anthropic → OpenAI 格式)。
|
||||
#[allow(dead_code)]
|
||||
pub fn needs_transform(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::GitHubCopilot => true,
|
||||
ProviderType::OpenRouter => false,
|
||||
_ => false,
|
||||
}
|
||||
@@ -75,6 +85,7 @@ impl ProviderType {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
ProviderType::OpenRouter => "https://openrouter.ai/api",
|
||||
ProviderType::GitHubCopilot => "https://api.githubcopilot.com",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +96,20 @@ impl ProviderType {
|
||||
pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self {
|
||||
match app_type {
|
||||
AppType::Claude => {
|
||||
// 检测是否为 OpenRouter
|
||||
// 检测是否为 GitHub Copilot
|
||||
if let Some(meta) = provider.meta.as_ref() {
|
||||
if meta.provider_type.as_deref() == Some("github_copilot") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
}
|
||||
|
||||
// 检测 base_url 是否为 GitHub Copilot
|
||||
let adapter = ClaudeAdapter::new();
|
||||
if let Ok(base_url) = adapter.extract_base_url(provider) {
|
||||
if base_url.contains("githubcopilot.com") {
|
||||
return ProviderType::GitHubCopilot;
|
||||
}
|
||||
// 检测是否为 OpenRouter
|
||||
if base_url.contains("openrouter.ai") {
|
||||
return ProviderType::OpenRouter;
|
||||
}
|
||||
@@ -152,6 +174,7 @@ impl ProviderType {
|
||||
ProviderType::Gemini => "gemini",
|
||||
ProviderType::GeminiCli => "gemini_cli",
|
||||
ProviderType::OpenRouter => "openrouter",
|
||||
ProviderType::GitHubCopilot => "github_copilot",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +196,9 @@ impl std::str::FromStr for ProviderType {
|
||||
"gemini" => Ok(ProviderType::Gemini),
|
||||
"gemini_cli" | "gemini-cli" => Ok(ProviderType::GeminiCli),
|
||||
"openrouter" => Ok(ProviderType::OpenRouter),
|
||||
"github_copilot" | "github-copilot" | "githubcopilot" => {
|
||||
Ok(ProviderType::GitHubCopilot)
|
||||
}
|
||||
_ => Err(format!("Invalid provider type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -199,9 +225,10 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
|
||||
#[allow(dead_code)]
|
||||
pub fn get_adapter_for_provider_type(provider_type: &ProviderType) -> Box<dyn ProviderAdapter> {
|
||||
match provider_type {
|
||||
ProviderType::Claude | ProviderType::ClaudeAuth | ProviderType::OpenRouter => {
|
||||
Box::new(ClaudeAdapter::new())
|
||||
}
|
||||
ProviderType::Claude
|
||||
| ProviderType::ClaudeAuth
|
||||
| ProviderType::OpenRouter
|
||||
| ProviderType::GitHubCopilot => Box::new(ClaudeAdapter::new()),
|
||||
ProviderType::Codex => Box::new(CodexAdapter::new()),
|
||||
ProviderType::Gemini | ProviderType::GeminiCli => Box::new(GeminiAdapter::new()),
|
||||
}
|
||||
@@ -237,6 +264,7 @@ mod tests {
|
||||
assert!(!ProviderType::Gemini.needs_transform());
|
||||
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||
assert!(!ProviderType::OpenRouter.needs_transform());
|
||||
assert!(ProviderType::GitHubCopilot.needs_transform());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -265,6 +293,10 @@ mod tests {
|
||||
ProviderType::OpenRouter.default_endpoint(),
|
||||
"https://openrouter.ai/api"
|
||||
);
|
||||
assert_eq!(
|
||||
ProviderType::GitHubCopilot.default_endpoint(),
|
||||
"https://api.githubcopilot.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -301,6 +333,18 @@ mod tests {
|
||||
"openrouter".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::OpenRouter
|
||||
);
|
||||
assert_eq!(
|
||||
"github_copilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert_eq!(
|
||||
"github-copilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert_eq!(
|
||||
"githubcopilot".parse::<ProviderType>().unwrap(),
|
||||
ProviderType::GitHubCopilot
|
||||
);
|
||||
assert!("invalid".parse::<ProviderType>().is_err());
|
||||
}
|
||||
|
||||
@@ -312,6 +356,7 @@ mod tests {
|
||||
assert_eq!(ProviderType::Gemini.as_str(), "gemini");
|
||||
assert_eq!(ProviderType::GeminiCli.as_str(), "gemini_cli");
|
||||
assert_eq!(ProviderType::OpenRouter.as_str(), "openrouter");
|
||||
assert_eq!(ProviderType::GitHubCopilot.as_str(), "github_copilot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -432,6 +477,9 @@ mod tests {
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::OpenRouter);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::GitHubCopilot);
|
||||
assert_eq!(adapter.name(), "Claude");
|
||||
|
||||
let adapter = get_adapter_for_provider_type(&ProviderType::Codex);
|
||||
assert_eq!(adapter.name(), "Codex");
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
//!
|
||||
//! 实现 OpenAI SSE → Anthropic SSE 格式转换
|
||||
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// OpenAI 流式响应数据结构
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -60,20 +62,45 @@ struct Usage {
|
||||
prompt_tokens: u32,
|
||||
#[serde(default)]
|
||||
completion_tokens: u32,
|
||||
#[serde(default)]
|
||||
prompt_tokens_details: Option<PromptTokensDetails>,
|
||||
/// Some compatible servers return Anthropic-style cache fields directly
|
||||
#[serde(default)]
|
||||
cache_read_input_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
cache_creation_input_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
/// Nested token details from OpenAI format
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PromptTokensDetails {
|
||||
#[serde(default)]
|
||||
cached_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ToolBlockState {
|
||||
anthropic_index: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
started: bool,
|
||||
pending_args: String,
|
||||
}
|
||||
|
||||
/// 创建 Anthropic SSE 流
|
||||
pub fn create_anthropic_sse_stream(
|
||||
stream: impl Stream<Item = Result<Bytes, reqwest::Error>> + Send + 'static,
|
||||
pub fn create_anthropic_sse_stream<E: std::error::Error + Send + 'static>(
|
||||
stream: impl Stream<Item = Result<Bytes, E>> + Send + 'static,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||
async_stream::stream! {
|
||||
let mut buffer = String::new();
|
||||
let mut message_id = None;
|
||||
let mut current_model = None;
|
||||
let mut content_index = 0;
|
||||
let mut next_content_index: u32 = 0;
|
||||
let mut has_sent_message_start = false;
|
||||
let mut current_block_type: Option<String> = None;
|
||||
let mut tool_call_id = None;
|
||||
let mut current_non_tool_block_type: Option<&'static str> = None;
|
||||
let mut current_non_tool_block_index: Option<u32> = None;
|
||||
let mut tool_blocks_by_index: HashMap<usize, ToolBlockState> = HashMap::new();
|
||||
let mut open_tool_block_indices: HashSet<u32> = HashSet::new();
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
@@ -92,7 +119,7 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
|
||||
for l in line.lines() {
|
||||
if let Some(data) = l.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(l, "data") {
|
||||
if data.trim() == "[DONE]" {
|
||||
log::debug!("[Claude/OpenRouter] <<< OpenAI SSE: [DONE]");
|
||||
let event = json!({"type": "message_stop"});
|
||||
@@ -115,6 +142,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
if let Some(choice) = chunk.choices.first() {
|
||||
if !has_sent_message_start {
|
||||
// Build usage with cache tokens if available from first chunk
|
||||
let mut start_usage = json!({
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
});
|
||||
if let Some(u) = &chunk.usage {
|
||||
start_usage["input_tokens"] = json!(u.prompt_tokens);
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
start_usage["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
start_usage["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "message_start",
|
||||
"message": {
|
||||
@@ -122,10 +164,7 @@ pub fn create_anthropic_sse_stream(
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": current_model.clone().unwrap_or_default(),
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0
|
||||
}
|
||||
"usage": start_usage
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: message_start\ndata: {}\n\n",
|
||||
@@ -136,10 +175,21 @@ pub fn create_anthropic_sse_stream(
|
||||
|
||||
// 处理 reasoning(thinking)
|
||||
if let Some(reasoning) = &choice.delta.reasoning {
|
||||
if current_block_type.is_none() {
|
||||
if current_non_tool_block_type != Some("thinking") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "thinking",
|
||||
"thinking": ""
|
||||
@@ -148,57 +198,17 @@ pub fn create_anthropic_sse_stream(
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("thinking".to_string());
|
||||
current_non_tool_block_type = Some("thinking");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_block_type.as_deref() != Some("text") {
|
||||
if current_block_type.is_some() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("text".to_string());
|
||||
}
|
||||
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoning
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
@@ -207,76 +217,288 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
if let Some(id) = &tool_call.id {
|
||||
if current_block_type.is_some() {
|
||||
// 处理文本内容
|
||||
if let Some(content) = &choice.delta.content {
|
||||
if !content.is_empty() {
|
||||
if current_non_tool_block_type != Some("text") {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
content_index += 1;
|
||||
}
|
||||
|
||||
tool_call_id = Some(id.clone());
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "text",
|
||||
"text": ""
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_non_tool_block_type = Some("text");
|
||||
current_non_tool_block_index = Some(index);
|
||||
}
|
||||
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": content_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": tool_call_id.clone().unwrap_or_default(),
|
||||
"name": name
|
||||
if let Some(index) = current_non_tool_block_index {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "text_delta",
|
||||
"text": content
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if let Some(tool_calls) = &choice.delta.tool_calls {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
for tool_call in tool_calls {
|
||||
let (
|
||||
anthropic_index,
|
||||
id,
|
||||
name,
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
) = {
|
||||
let state = tool_blocks_by_index
|
||||
.entry(tool_call.index)
|
||||
.or_insert_with(|| {
|
||||
let index = next_content_index;
|
||||
next_content_index += 1;
|
||||
ToolBlockState {
|
||||
anthropic_index: index,
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
started: false,
|
||||
pending_args: String::new(),
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
current_block_type = Some("tool_use".to_string());
|
||||
|
||||
if let Some(id) = &tool_call.id {
|
||||
state.id = id.clone();
|
||||
}
|
||||
if let Some(function) = &tool_call.function {
|
||||
if let Some(name) = &function.name {
|
||||
state.name = name.clone();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &function.arguments {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": content_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
let should_start =
|
||||
!state.started
|
||||
&& !state.id.is_empty()
|
||||
&& !state.name.is_empty();
|
||||
if should_start {
|
||||
state.started = true;
|
||||
}
|
||||
let pending_after_start = if should_start
|
||||
&& !state.pending_args.is_empty()
|
||||
{
|
||||
Some(std::mem::take(&mut state.pending_args))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let args_delta = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.arguments.clone());
|
||||
let immediate_delta = if let Some(args) = args_delta {
|
||||
if state.started {
|
||||
Some(args)
|
||||
} else {
|
||||
state.pending_args.push_str(&args);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
state.anthropic_index,
|
||||
state.id.clone(),
|
||||
state.name.clone(),
|
||||
should_start,
|
||||
pending_after_start,
|
||||
immediate_delta,
|
||||
)
|
||||
};
|
||||
|
||||
if should_start {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": anthropic_index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
open_tool_block_indices.insert(anthropic_index);
|
||||
}
|
||||
|
||||
if let Some(args) = pending_after_start {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
|
||||
if let Some(args) = immediate_delta {
|
||||
let event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": anthropic_index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": args
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 finish_reason
|
||||
if let Some(finish_reason) = &choice.finish_reason {
|
||||
if current_block_type.is_some() {
|
||||
if let Some(index) = current_non_tool_block_index.take() {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": content_index
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
current_non_tool_block_type = None;
|
||||
|
||||
// Late start for blocks that accumulated args before id/name arrived.
|
||||
let mut late_tool_starts: Vec<(u32, String, String, String)> =
|
||||
Vec::new();
|
||||
for (tool_idx, state) in tool_blocks_by_index.iter_mut() {
|
||||
if state.started {
|
||||
continue;
|
||||
}
|
||||
let has_payload = !state.pending_args.is_empty()
|
||||
|| !state.id.is_empty()
|
||||
|| !state.name.is_empty();
|
||||
if !has_payload {
|
||||
continue;
|
||||
}
|
||||
let fallback_id = if state.id.is_empty() {
|
||||
format!("tool_call_{tool_idx}")
|
||||
} else {
|
||||
state.id.clone()
|
||||
};
|
||||
let fallback_name = if state.name.is_empty() {
|
||||
"unknown_tool".to_string()
|
||||
} else {
|
||||
state.name.clone()
|
||||
};
|
||||
state.started = true;
|
||||
let pending = std::mem::take(&mut state.pending_args);
|
||||
late_tool_starts.push((
|
||||
state.anthropic_index,
|
||||
fallback_id,
|
||||
fallback_name,
|
||||
pending,
|
||||
));
|
||||
}
|
||||
late_tool_starts.sort_unstable_by_key(|(index, _, _, _)| *index);
|
||||
for (index, id, name, pending) in late_tool_starts {
|
||||
let event = json!({
|
||||
"type": "content_block_start",
|
||||
"index": index,
|
||||
"content_block": {
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name
|
||||
}
|
||||
});
|
||||
let sse_data = format!("event: content_block_start\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
open_tool_block_indices.insert(index);
|
||||
if !pending.is_empty() {
|
||||
let delta_event = json!({
|
||||
"type": "content_block_delta",
|
||||
"index": index,
|
||||
"delta": {
|
||||
"type": "input_json_delta",
|
||||
"partial_json": pending
|
||||
}
|
||||
});
|
||||
let delta_sse = format!("event: content_block_delta\ndata: {}\n\n",
|
||||
serde_json::to_string(&delta_event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(delta_sse));
|
||||
}
|
||||
}
|
||||
|
||||
if !open_tool_block_indices.is_empty() {
|
||||
let mut tool_indices: Vec<u32> =
|
||||
open_tool_block_indices.iter().copied().collect();
|
||||
tool_indices.sort_unstable();
|
||||
for index in tool_indices {
|
||||
let event = json!({
|
||||
"type": "content_block_stop",
|
||||
"index": index
|
||||
});
|
||||
let sse_data = format!("event: content_block_stop\ndata: {}\n\n",
|
||||
serde_json::to_string(&event).unwrap_or_default());
|
||||
yield Ok(Bytes::from(sse_data));
|
||||
}
|
||||
open_tool_block_indices.clear();
|
||||
}
|
||||
|
||||
let stop_reason = map_stop_reason(Some(finish_reason));
|
||||
// 构建 usage 信息,包含 input_tokens 和 output_tokens
|
||||
let usage_json = chunk.usage.as_ref().map(|u| json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
}));
|
||||
// Build usage with cache token fields
|
||||
let usage_json = chunk.usage.as_ref().map(|u| {
|
||||
let mut uj = json!({
|
||||
"input_tokens": u.prompt_tokens,
|
||||
"output_tokens": u.completion_tokens
|
||||
});
|
||||
if let Some(cached) = extract_cache_read_tokens(u) {
|
||||
uj["cache_read_input_tokens"] = json!(cached);
|
||||
}
|
||||
if let Some(created) = u.cache_creation_input_tokens {
|
||||
uj["cache_creation_input_tokens"] = json!(created);
|
||||
}
|
||||
uj
|
||||
});
|
||||
let event = json!({
|
||||
"type": "message_delta",
|
||||
"delta": {
|
||||
@@ -314,15 +536,218 @@ pub fn create_anthropic_sse_stream(
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract cache_read tokens from Usage, checking both direct field and nested details
|
||||
fn extract_cache_read_tokens(usage: &Usage) -> Option<u32> {
|
||||
// Direct field takes priority (compatible servers)
|
||||
if let Some(v) = usage.cache_read_input_tokens {
|
||||
return Some(v);
|
||||
}
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
usage
|
||||
.prompt_tokens_details
|
||||
.as_ref()
|
||||
.map(|d| d.cached_tokens)
|
||||
.filter(|&v| v > 0)
|
||||
}
|
||||
|
||||
/// 映射停止原因
|
||||
fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
||||
finish_reason.map(|r| {
|
||||
match r {
|
||||
"tool_calls" => "tool_use",
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
_ => "end_turn",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!("[Claude/OpenRouter] Unknown finish_reason in streaming: {other}");
|
||||
"end_turn"
|
||||
}
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use futures::stream;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn test_map_stop_reason_legacy_and_filtered_values() {
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("function_call")),
|
||||
Some("tool_use".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
map_stop_reason(Some("content_filter")),
|
||||
Some("end_turn".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_tool_calls_routed_by_index() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"second_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":1,\"function\":{\"arguments\":\"{\\\"b\\\":2}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_1\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":4}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_index_by_call: HashMap<String, u64> = HashMap::new();
|
||||
for event in &events {
|
||||
if event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
{
|
||||
if let (Some(call_id), Some(index)) = (
|
||||
event.pointer("/content_block/id").and_then(|v| v.as_str()),
|
||||
event.get("index").and_then(|v| v.as_u64()),
|
||||
) {
|
||||
tool_index_by_call.insert(call_id.to_string(), index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(tool_index_by_call.len(), 2);
|
||||
assert_ne!(
|
||||
tool_index_by_call.get("call_0"),
|
||||
tool_index_by_call.get("call_1")
|
||||
);
|
||||
|
||||
let deltas: Vec<(u64, String)> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
let index = event.get("index").and_then(|v| v.as_u64())?;
|
||||
let partial_json = event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())?
|
||||
.to_string();
|
||||
Some((index, partial_json))
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(deltas.len(), 2);
|
||||
let second_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"b\":2}").then_some(*index))
|
||||
.unwrap();
|
||||
let first_idx = deltas
|
||||
.iter()
|
||||
.find_map(|(index, payload)| (payload == "{\"a\":1}").then_some(*index))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(second_idx, *tool_index_by_call.get("call_1").unwrap());
|
||||
assert_eq!(first_idx, *tool_index_by_call.get("call_0").unwrap());
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("message_delta")
|
||||
&& event.pointer("/delta/stop_reason").and_then(|v| v.as_str()) == Some("tool_use")
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_delays_tool_start_until_id_and_name_ready() {
|
||||
let input = concat!(
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"a\\\":\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_0\",\"type\":\"function\",\"function\":{\"name\":\"first_tool\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}}]}}]}\n\n",
|
||||
"data: {\"id\":\"chatcmpl_2\",\"model\":\"gpt-4o\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":6,\"completion_tokens\":2}}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let upstream = stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from(
|
||||
input.as_bytes().to_vec(),
|
||||
))]);
|
||||
let converted = create_anthropic_sse_stream(upstream);
|
||||
let chunks: Vec<_> = converted.collect().await;
|
||||
let merged = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| String::from_utf8_lossy(chunk.unwrap().as_ref()).to_string())
|
||||
.collect::<String>();
|
||||
|
||||
let events: Vec<Value> = merged
|
||||
.split("\n\n")
|
||||
.filter_map(|block| {
|
||||
let data = block
|
||||
.lines()
|
||||
.find_map(|line| strip_sse_field(line, "data"))?;
|
||||
serde_json::from_str::<Value>(data).ok()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let starts: Vec<&Value> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_start")
|
||||
&& event
|
||||
.pointer("/content_block/type")
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("tool_use")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"call_0"
|
||||
);
|
||||
assert_eq!(
|
||||
starts[0]
|
||||
.pointer("/content_block/name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(""),
|
||||
"first_tool"
|
||||
);
|
||||
|
||||
let deltas: Vec<&str> = events
|
||||
.iter()
|
||||
.filter(|event| {
|
||||
event.get("type").and_then(|v| v.as_str()) == Some("content_block_delta")
|
||||
&& event.pointer("/delta/type").and_then(|v| v.as_str())
|
||||
== Some("input_json_delta")
|
||||
})
|
||||
.filter_map(|event| {
|
||||
event
|
||||
.pointer("/delta/partial_json")
|
||||
.and_then(|v| v.as_str())
|
||||
})
|
||||
.collect();
|
||||
assert!(deltas.contains(&"{\"a\":"));
|
||||
assert!(deltas.contains(&"1}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,75 @@
|
||||
use crate::proxy::error::ProxyError;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Detect OpenAI o-series reasoning models (o1, o3, o4-mini, etc.)
|
||||
/// These models require `max_completion_tokens` instead of `max_tokens`.
|
||||
pub fn is_openai_o_series(model: &str) -> bool {
|
||||
model.len() > 1
|
||||
&& model.starts_with('o')
|
||||
&& model.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Detect OpenAI models that support reasoning_effort.
|
||||
///
|
||||
/// Supported families:
|
||||
/// - o-series: o1, o3, o4-mini, etc.
|
||||
/// - GPT-5+: gpt-5, gpt-5.1, gpt-5.4, gpt-5-codex, etc.
|
||||
pub fn supports_reasoning_effort(model: &str) -> bool {
|
||||
is_openai_o_series(model)
|
||||
|| model
|
||||
.to_lowercase()
|
||||
.strip_prefix("gpt-")
|
||||
.and_then(|rest| rest.chars().next())
|
||||
.is_some_and(|c| c.is_ascii_digit() && c >= '5')
|
||||
}
|
||||
|
||||
/// Resolve the appropriate OpenAI `reasoning_effort` from an Anthropic request body.
|
||||
///
|
||||
/// Priority:
|
||||
/// 1. Explicit `output_config.effort` — preserves the user's intent directly.
|
||||
/// `low`/`medium`/`high` map 1:1; `max` maps to `xhigh`
|
||||
/// (supported by mainstream GPT models). Unknown values are ignored.
|
||||
/// 2. Fallback: `thinking.type` + `budget_tokens`:
|
||||
/// - `adaptive` → `high` (mirrors optimizer semantics where adaptive ≈ max effort)
|
||||
/// - `enabled` with budget → `low` (<4 000) / `medium` (4 000–15 999) / `high` (≥16 000)
|
||||
/// - `enabled` without budget → `high` (conservative default)
|
||||
/// - `disabled` / absent → `None`
|
||||
pub fn resolve_reasoning_effort(body: &Value) -> Option<&'static str> {
|
||||
// --- Priority 1: explicit output_config.effort ---
|
||||
if let Some(effort) = body
|
||||
.pointer("/output_config/effort")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
return match effort {
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"max" => Some("xhigh"), // OpenAI xhigh = maximum reasoning effort
|
||||
_ => None, // unknown value — do not inject
|
||||
};
|
||||
}
|
||||
|
||||
// --- Priority 2: thinking.type + budget_tokens fallback ---
|
||||
let thinking = body.get("thinking")?;
|
||||
match thinking.get("type").and_then(|t| t.as_str()) {
|
||||
Some("adaptive") => Some("high"),
|
||||
Some("enabled") => {
|
||||
let budget = thinking.get("budget_tokens").and_then(|b| b.as_u64());
|
||||
match budget {
|
||||
Some(b) if b < 4_000 => Some("low"),
|
||||
Some(b) if b < 16_000 => Some("medium"),
|
||||
Some(_) => Some("high"),
|
||||
None => Some("high"), // enabled but no budget — assume strong reasoning
|
||||
}
|
||||
}
|
||||
_ => None, // disabled or missing
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic 请求 → OpenAI 请求
|
||||
pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
///
|
||||
/// `cache_key`: optional prompt_cache_key to inject for improved cache routing
|
||||
pub fn anthropic_to_openai(body: Value, cache_key: Option<&str>) -> Result<Value, ProxyError> {
|
||||
let mut result = json!({});
|
||||
|
||||
// NOTE: 模型映射由上游统一处理(proxy::model_mapper),格式转换层只做结构转换。
|
||||
@@ -23,10 +90,14 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
// 单个字符串
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
} else if let Some(arr) = system.as_array() {
|
||||
// 多个 system message
|
||||
// 多个 system message — preserve cache_control for compatible proxies
|
||||
for msg in arr {
|
||||
if let Some(text) = msg.get("text").and_then(|t| t.as_str()) {
|
||||
messages.push(json!({"role": "system", "content": text}));
|
||||
let mut sys_msg = json!({"role": "system", "content": text});
|
||||
if let Some(cc) = msg.get("cache_control") {
|
||||
sys_msg["cache_control"] = cc.clone();
|
||||
}
|
||||
messages.push(sys_msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,9 +115,14 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
|
||||
result["messages"] = json!(messages);
|
||||
|
||||
// 转换参数
|
||||
// 转换参数 — o-series 模型需要 max_completion_tokens
|
||||
let model = body.get("model").and_then(|m| m.as_str()).unwrap_or("");
|
||||
if let Some(v) = body.get("max_tokens") {
|
||||
result["max_tokens"] = v.clone();
|
||||
if is_openai_o_series(model) {
|
||||
result["max_completion_tokens"] = v.clone();
|
||||
} else {
|
||||
result["max_tokens"] = v.clone();
|
||||
}
|
||||
}
|
||||
if let Some(v) = body.get("temperature") {
|
||||
result["temperature"] = v.clone();
|
||||
@@ -61,20 +137,31 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
result["stream"] = v.clone();
|
||||
}
|
||||
|
||||
// Map Anthropic thinking → OpenAI reasoning_effort
|
||||
if supports_reasoning_effort(model) {
|
||||
if let Some(effort) = resolve_reasoning_effort(&body) {
|
||||
result["reasoning_effort"] = json!(effort);
|
||||
}
|
||||
}
|
||||
|
||||
// 转换 tools (过滤 BatchTool)
|
||||
if let Some(tools) = body.get("tools").and_then(|t| t.as_array()) {
|
||||
let openai_tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.filter(|t| t.get("type").and_then(|v| v.as_str()) != Some("BatchTool"))
|
||||
.map(|t| {
|
||||
json!({
|
||||
let mut tool = json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.get("name").and_then(|n| n.as_str()).unwrap_or(""),
|
||||
"description": t.get("description"),
|
||||
"parameters": clean_schema(t.get("input_schema").cloned().unwrap_or(json!({})))
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(cc) = t.get("cache_control") {
|
||||
tool["cache_control"] = cc.clone();
|
||||
}
|
||||
tool
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -87,6 +174,11 @@ pub fn anthropic_to_openai(body: Value) -> Result<Value, ProxyError> {
|
||||
result["tool_choice"] = v.clone();
|
||||
}
|
||||
|
||||
// Inject prompt_cache_key for improved cache routing on OpenAI-compatible endpoints
|
||||
if let Some(key) = cache_key {
|
||||
result["prompt_cache_key"] = json!(key);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -122,7 +214,11 @@ fn convert_message_to_openai(
|
||||
match block_type {
|
||||
"text" => {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
content_parts.push(json!({"type": "text", "text": text}));
|
||||
let mut part = json!({"type": "text", "text": text});
|
||||
if let Some(cc) = block.get("cache_control") {
|
||||
part["cache_control"] = cc.clone();
|
||||
}
|
||||
content_parts.push(part);
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
@@ -184,8 +280,14 @@ fn convert_message_to_openai(
|
||||
if content_parts.is_empty() {
|
||||
msg["content"] = Value::Null;
|
||||
} else if content_parts.len() == 1 {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
// When cache_control is present, keep array format to preserve it
|
||||
let has_cache_control = content_parts[0].get("cache_control").is_some();
|
||||
if !has_cache_control {
|
||||
if let Some(text) = content_parts[0].get("text") {
|
||||
msg["content"] = text.clone();
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
} else {
|
||||
msg["content"] = json!(content_parts);
|
||||
}
|
||||
@@ -210,7 +312,7 @@ fn convert_message_to_openai(
|
||||
}
|
||||
|
||||
/// 清理 JSON schema(移除不支持的 format)
|
||||
fn clean_schema(mut schema: Value) -> Value {
|
||||
pub fn clean_schema(mut schema: Value) -> Value {
|
||||
if let Some(obj) = schema.as_object_mut() {
|
||||
// 移除 "format": "uri"
|
||||
if obj.get("format").and_then(|v| v.as_str()) == Some("uri") {
|
||||
@@ -247,16 +349,49 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.ok_or_else(|| ProxyError::TransformError("No message in choice".to_string()))?;
|
||||
|
||||
let mut content = Vec::new();
|
||||
let mut has_tool_use = false;
|
||||
|
||||
// 文本内容
|
||||
if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
// 文本/拒绝内容
|
||||
if let Some(msg_content) = message.get("content") {
|
||||
if let Some(text) = msg_content.as_str() {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
} else if let Some(parts) = msg_content.as_array() {
|
||||
for part in parts {
|
||||
let part_type = part.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match part_type {
|
||||
"text" | "output_text" => {
|
||||
if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"refusal" => {
|
||||
if let Some(refusal) = part.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Some providers put refusal at message-level.
|
||||
if let Some(refusal) = message.get("refusal").and_then(|r| r.as_str()) {
|
||||
if !refusal.is_empty() {
|
||||
content.push(json!({"type": "text", "text": refusal}));
|
||||
}
|
||||
}
|
||||
|
||||
// 工具调用
|
||||
// 工具调用(tool_calls)
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
if !tool_calls.is_empty() {
|
||||
has_tool_use = true;
|
||||
}
|
||||
for tc in tool_calls {
|
||||
let id = tc.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let empty_obj = json!({});
|
||||
@@ -276,6 +411,36 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
}));
|
||||
}
|
||||
}
|
||||
// 兼容旧格式(function_call)
|
||||
if !has_tool_use {
|
||||
if let Some(function_call) = message.get("function_call") {
|
||||
let id = function_call
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let has_arguments = function_call.get("arguments").is_some();
|
||||
|
||||
let input = match function_call.get("arguments") {
|
||||
Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(json!({})),
|
||||
Some(v @ Value::Object(_)) | Some(v @ Value::Array(_)) => v.clone(),
|
||||
_ => json!({}),
|
||||
};
|
||||
|
||||
if !name.is_empty() || has_arguments {
|
||||
content.push(json!({
|
||||
"type": "tool_use",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"input": input
|
||||
}));
|
||||
has_tool_use = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 映射 finish_reason → stop_reason
|
||||
let stop_reason = choice
|
||||
@@ -284,11 +449,18 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.map(|r| match r {
|
||||
"stop" => "end_turn",
|
||||
"length" => "max_tokens",
|
||||
"tool_calls" => "tool_use",
|
||||
other => other,
|
||||
});
|
||||
"tool_calls" | "function_call" => "tool_use",
|
||||
"content_filter" => "end_turn",
|
||||
other => {
|
||||
log::warn!(
|
||||
"[Claude/OpenAI] Unknown finish_reason in non-streaming response: {other}"
|
||||
);
|
||||
"end_turn"
|
||||
}
|
||||
})
|
||||
.or(if has_tool_use { Some("tool_use") } else { None });
|
||||
|
||||
// usage
|
||||
// usage — map cache tokens from OpenAI format to Anthropic format
|
||||
let usage = body.get("usage").cloned().unwrap_or(json!({}));
|
||||
let input_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
@@ -299,6 +471,26 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as u32;
|
||||
|
||||
let mut usage_json = json!({
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
});
|
||||
|
||||
// OpenAI standard: prompt_tokens_details.cached_tokens
|
||||
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);
|
||||
}
|
||||
// Some compatible servers return these fields directly
|
||||
if let Some(v) = usage.get("cache_read_input_tokens") {
|
||||
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!({
|
||||
"id": body.get("id").and_then(|i| i.as_str()).unwrap_or(""),
|
||||
"type": "message",
|
||||
@@ -307,10 +499,7 @@ pub fn openai_to_anthropic(body: Value) -> Result<Value, ProxyError> {
|
||||
"model": body.get("model").and_then(|m| m.as_str()).unwrap_or(""),
|
||||
"stop_reason": stop_reason,
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": input_tokens,
|
||||
"output_tokens": output_tokens
|
||||
}
|
||||
"usage": usage_json
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
@@ -328,7 +517,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "claude-3-opus");
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert_eq!(result["messages"][0]["role"], "user");
|
||||
@@ -344,7 +533,7 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["messages"][0]["role"], "system");
|
||||
assert_eq!(
|
||||
result["messages"][0]["content"],
|
||||
@@ -366,7 +555,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["tools"][0]["type"], "function");
|
||||
assert_eq!(result["tools"][0]["function"]["name"], "get_weather");
|
||||
}
|
||||
@@ -385,7 +574,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "assistant");
|
||||
assert!(msg.get("tool_calls").is_some());
|
||||
@@ -405,7 +594,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
let msg = &result["messages"][0];
|
||||
assert_eq!(msg["role"], "tool");
|
||||
assert_eq!(msg["tool_call_id"], "call_123");
|
||||
@@ -477,7 +666,409 @@ mod tests {
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input).unwrap();
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["model"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_with_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, Some("provider-123")).unwrap();
|
||||
assert_eq!(result["prompt_cache_key"], "provider-123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_no_cache_key() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("prompt_cache_key").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_cache_control_preserved() {
|
||||
let input = json!({
|
||||
"model": "claude-3-opus",
|
||||
"max_tokens": 1024,
|
||||
"system": [
|
||||
{"type": "text", "text": "System prompt", "cache_control": {"type": "ephemeral"}}
|
||||
],
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello", "cache_control": {"type": "ephemeral", "ttl": "5m"}}
|
||||
]
|
||||
}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {"type": "object"},
|
||||
"cache_control": {"type": "ephemeral"}
|
||||
}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
// System message cache_control preserved
|
||||
assert_eq!(result["messages"][0]["cache_control"]["type"], "ephemeral");
|
||||
// Text block cache_control preserved
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["type"],
|
||||
"ephemeral"
|
||||
);
|
||||
assert_eq!(
|
||||
result["messages"][1]["content"][0]["cache_control"]["ttl"],
|
||||
"5m"
|
||||
);
|
||||
// Tool cache_control preserved
|
||||
assert_eq!(result["tools"][0]["cache_control"]["type"], "ephemeral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_cache_tokens() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 80
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["input_tokens"], 100);
|
||||
assert_eq!(result["usage"]["output_tokens"], 50);
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_direct_cache_fields() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 50,
|
||||
"cache_read_input_tokens": 60,
|
||||
"cache_creation_input_tokens": 20
|
||||
}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["usage"]["cache_read_input_tokens"], 60);
|
||||
assert_eq!(result["usage"]["cache_creation_input_tokens"], 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "Blocked"},
|
||||
"finish_reason": "content_filter"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 1}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["stop_reason"], "end_turn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_legacy_function_call() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"function_call": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"Tokyo\"}"
|
||||
}
|
||||
},
|
||||
"finish_reason": "function_call"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "tool_use");
|
||||
assert_eq!(result["content"][0]["name"], "get_weather");
|
||||
assert_eq!(result["content"][0]["input"]["location"], "Tokyo");
|
||||
assert_eq!(result["stop_reason"], "tool_use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_openai_to_anthropic_with_content_parts_and_refusal() {
|
||||
let input = json!({
|
||||
"id": "chatcmpl-123",
|
||||
"model": "gpt-4",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello"},
|
||||
{"type": "refusal", "refusal": "I can't do that"}
|
||||
]
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
|
||||
});
|
||||
|
||||
let result = openai_to_anthropic(input).unwrap();
|
||||
assert_eq!(result["content"][0]["type"], "text");
|
||||
assert_eq!(result["content"][0]["text"], "Hello");
|
||||
assert_eq!(result["content"][1]["type"], "text");
|
||||
assert_eq!(result["content"][1]["text"], "I can't do that");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_openai_o_series() {
|
||||
assert!(is_openai_o_series("o1"));
|
||||
assert!(is_openai_o_series("o1-preview"));
|
||||
assert!(is_openai_o_series("o1-mini"));
|
||||
assert!(is_openai_o_series("o3"));
|
||||
assert!(is_openai_o_series("o3-mini"));
|
||||
assert!(is_openai_o_series("o4-mini"));
|
||||
assert!(!is_openai_o_series("gpt-4o"));
|
||||
assert!(!is_openai_o_series("openai-gpt"));
|
||||
assert!(!is_openai_o_series("o"));
|
||||
assert!(!is_openai_o_series(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_reasoning_effort() {
|
||||
assert!(supports_reasoning_effort("o1"));
|
||||
assert!(supports_reasoning_effort("o3-mini"));
|
||||
assert!(supports_reasoning_effort("gpt-5"));
|
||||
assert!(supports_reasoning_effort("gpt-5.4"));
|
||||
assert!(supports_reasoning_effort("gpt-5-codex"));
|
||||
assert!(!supports_reasoning_effort("gpt-4o"));
|
||||
assert!(!supports_reasoning_effort("claude-sonnet-4-6"));
|
||||
}
|
||||
|
||||
// ── resolve_reasoning_effort unit tests ──
|
||||
|
||||
#[test]
|
||||
fn test_output_config_low_maps_to_reasoning_effort_low() {
|
||||
let body = json!({"output_config": {"effort": "low"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_medium_maps_to_reasoning_effort_medium() {
|
||||
let body = json!({"output_config": {"effort": "medium"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_high_maps_to_reasoning_effort_high() {
|
||||
let body = json!({"output_config": {"effort": "high"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_max_maps_to_reasoning_effort_xhigh() {
|
||||
let body = json!({"output_config": {"effort": "max"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("xhigh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_takes_priority_over_thinking() {
|
||||
// Even with thinking.adaptive present, explicit effort wins
|
||||
let body = json!({
|
||||
"output_config": {"effort": "low"},
|
||||
"thinking": {"type": "adaptive"}
|
||||
});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_config_unknown_value_no_reasoning_effort() {
|
||||
let body = json!({"output_config": {"effort": "turbo"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_small_budget_maps_low() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 1024}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("low"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_medium_budget_maps_medium() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 8000}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("medium"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_large_budget_maps_high() {
|
||||
let body = json!({"thinking": {"type": "enabled", "budget_tokens": 32000}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_enabled_without_budget_maps_high() {
|
||||
let body = json!({"thinking": {"type": "enabled"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_adaptive_maps_high() {
|
||||
let body = json!({"thinking": {"type": "adaptive"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), Some("high"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thinking_disabled_no_reasoning_effort() {
|
||||
let body = json!({"thinking": {"type": "disabled"}});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_thinking_field_no_reasoning_effort() {
|
||||
let body = json!({"messages": [{"role": "user", "content": "Hello"}]});
|
||||
assert_eq!(resolve_reasoning_effort(&body), None);
|
||||
}
|
||||
|
||||
// ── Integration: anthropic_to_openai with resolve_reasoning_effort ──
|
||||
|
||||
#[test]
|
||||
fn test_non_reasoning_model_no_reasoning_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_with_output_config_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "medium"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_with_output_config_max() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"output_config": {"effort": "max"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_thinking_enabled_small_budget() {
|
||||
let input = json!({
|
||||
"model": "o3",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "enabled", "budget_tokens": 2048},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "low");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_thinking_adaptive() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"thinking": {"type": "adaptive"},
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["reasoning_effort"], "high");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reasoning_model_no_thinking_no_effort() {
|
||||
let input = json!({
|
||||
"model": "gpt-5.4",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(result.get("reasoning_effort").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_o_series_max_completion_tokens() {
|
||||
for model in &["o1", "o3-mini", "o4-mini"] {
|
||||
let input = json!({
|
||||
"model": model,
|
||||
"max_tokens": 4096,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert!(
|
||||
result.get("max_tokens").is_none(),
|
||||
"{model} should not have max_tokens"
|
||||
);
|
||||
assert_eq!(
|
||||
result["max_completion_tokens"], 4096,
|
||||
"{model} should use max_completion_tokens"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_anthropic_to_openai_non_o_series_keeps_max_tokens() {
|
||||
let input = json!({
|
||||
"model": "gpt-4o",
|
||||
"max_tokens": 1024,
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
});
|
||||
|
||||
let result = anthropic_to_openai(input, None).unwrap();
|
||||
assert_eq!(result["max_tokens"], 1024);
|
||||
assert!(result.get("max_completion_tokens").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use super::session::ProxySession;
|
||||
use super::usage::parser::TokenUsage;
|
||||
use super::ProxyError;
|
||||
use crate::proxy::sse::strip_sse_field;
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use serde_json::Value;
|
||||
@@ -90,7 +91,7 @@ impl StreamHandler {
|
||||
buffer = buffer[pos + 2..].to_string();
|
||||
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json) = serde_json::from_str::<Value>(data) {
|
||||
let mut guard = events.lock().await;
|
||||
@@ -211,4 +212,24 @@ mod tests {
|
||||
let handler = StreamHandler::new(30);
|
||||
assert_eq!(handler.idle_timeout, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,19 @@
|
||||
use super::{
|
||||
handler_config::UsageParserConfig,
|
||||
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||
hyper_client::ProxyResponse,
|
||||
server::ProxyState,
|
||||
sse::strip_sse_field,
|
||||
usage::parser::TokenUsage,
|
||||
ProxyError,
|
||||
};
|
||||
use axum::http::header::HeaderMap;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use bytes::Bytes;
|
||||
use futures::stream::{Stream, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
io::Read,
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
@@ -23,24 +26,123 @@ use std::{
|
||||
};
|
||||
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")
|
||||
}
|
||||
|
||||
/// 移除在重建响应体后会失真的实体头。
|
||||
pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) {
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
headers.remove(axum::http::header::TRANSFER_ENCODING);
|
||||
}
|
||||
|
||||
/// 读取响应体并在需要时解压,确保 headers 与返回 body 一致。
|
||||
///
|
||||
/// `body_timeout`: 整包超时。当非零时用 `tokio::time::timeout` 包住 `.bytes()` 调用,
|
||||
/// 防止上游发完响应头后卡住 body 导致请求永远挂住。
|
||||
/// 传入 `Duration::ZERO` 表示不启用超时(故障转移关闭时)。
|
||||
pub(crate) async fn read_decoded_body(
|
||||
response: ProxyResponse,
|
||||
tag: &str,
|
||||
body_timeout: Duration,
|
||||
) -> Result<(HeaderMap, http::StatusCode, Bytes), ProxyError> {
|
||||
let mut headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
let raw_bytes = if body_timeout.is_zero() {
|
||||
response.bytes().await?
|
||||
} else {
|
||||
tokio::time::timeout(body_timeout, response.bytes())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Timeout(format!(
|
||||
"响应体读取超时: {}s(上游发完响应头后 body 未到达)",
|
||||
body_timeout.as_secs()
|
||||
))
|
||||
})??
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
status.as_u16(),
|
||||
raw_bytes.len(),
|
||||
format_headers(&headers)
|
||||
);
|
||||
|
||||
let mut body_bytes = raw_bytes.clone();
|
||||
let mut decoded = false;
|
||||
|
||||
if let Some(encoding) = get_content_encoding(&headers) {
|
||||
log::debug!("[{tag}] 解压非流式响应: content-encoding={encoding}");
|
||||
match decompress_body(&encoding, &raw_bytes) {
|
||||
Ok(decompressed) => {
|
||||
body_bytes = Bytes::from(decompressed);
|
||||
decoded = true;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[{tag}] 解压失败 ({encoding}): {e},使用原始数据");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decoded {
|
||||
strip_entity_headers_for_rebuilt_body(&mut headers);
|
||||
}
|
||||
|
||||
Ok((headers, status, body_bytes))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 公共接口
|
||||
// ============================================================================
|
||||
|
||||
/// 检测响应是否为 SSE 流式响应
|
||||
#[inline]
|
||||
pub fn is_sse_response(response: &reqwest::Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| ct.contains("text/event-stream"))
|
||||
.unwrap_or(false)
|
||||
pub fn is_sse_response(response: &ProxyResponse) -> bool {
|
||||
response.is_sse()
|
||||
}
|
||||
|
||||
/// 处理流式响应
|
||||
pub async fn handle_streaming(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
@@ -52,6 +154,15 @@ pub async fn handle_streaming(
|
||||
status.as_u16(),
|
||||
format_headers(response.headers())
|
||||
);
|
||||
// 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败)
|
||||
if let Some(encoding) = get_content_encoding(response.headers()) {
|
||||
log::warn!(
|
||||
"[{}] 流式响应含 content-encoding={encoding},SSE 解析可能失败。\
|
||||
上游在 accept-encoding 透传后压缩了 SSE 流。",
|
||||
ctx.tag
|
||||
);
|
||||
}
|
||||
|
||||
let mut builder = axum::response::Response::builder().status(status);
|
||||
|
||||
// 复制响应头
|
||||
@@ -60,9 +171,7 @@ pub async fn handle_streaming(
|
||||
}
|
||||
|
||||
// 创建字节流
|
||||
let stream = response
|
||||
.bytes_stream()
|
||||
.map(|chunk| chunk.map_err(|e| std::io::Error::other(e.to_string())));
|
||||
let stream = response.bytes_stream();
|
||||
|
||||
// 创建使用量收集器
|
||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||
@@ -86,26 +195,20 @@ pub async fn handle_streaming(
|
||||
|
||||
/// 处理非流式响应
|
||||
pub async fn handle_non_streaming(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> Result<Response, ProxyError> {
|
||||
let response_headers = response.headers().clone();
|
||||
let status = response.status();
|
||||
|
||||
// 读取响应体
|
||||
let body_bytes = response.bytes().await.map_err(|e| {
|
||||
log::error!("[{}] 读取响应失败: {e}", ctx.tag);
|
||||
ProxyError::ForwardFailed(format!("Failed to read response body: {e}"))
|
||||
})?;
|
||||
log::debug!(
|
||||
"[{}] 已接收上游响应体: status={}, bytes={}, headers={}",
|
||||
ctx.tag,
|
||||
status.as_u16(),
|
||||
body_bytes.len(),
|
||||
format_headers(&response_headers)
|
||||
);
|
||||
// 整包超时:仅在故障转移开启且配置值非零时生效
|
||||
let body_timeout =
|
||||
if ctx.app_config.auto_failover_enabled && ctx.app_config.non_streaming_timeout > 0 {
|
||||
Duration::from_secs(ctx.app_config.non_streaming_timeout as u64)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
let (response_headers, status, body_bytes) =
|
||||
read_decoded_body(response, ctx.tag, body_timeout).await?;
|
||||
|
||||
log::debug!(
|
||||
"[{}] 上游响应体内容: {}",
|
||||
@@ -189,7 +292,7 @@ pub async fn handle_non_streaming(
|
||||
///
|
||||
/// 根据响应类型自动选择流式或非流式处理
|
||||
pub async fn process_response(
|
||||
response: reqwest::Response,
|
||||
response: ProxyResponse,
|
||||
ctx: &RequestContext,
|
||||
state: &ProxyState,
|
||||
parser_config: &UsageParserConfig,
|
||||
@@ -283,6 +386,11 @@ fn create_usage_collector(
|
||||
status_code: u16,
|
||||
parser_config: &UsageParserConfig,
|
||||
) -> SseUsageCollector {
|
||||
let logging_enabled = state
|
||||
.config
|
||||
.try_read()
|
||||
.map(|c| c.enable_logging)
|
||||
.unwrap_or(true);
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let request_model = ctx.request_model.clone();
|
||||
@@ -294,6 +402,9 @@ fn create_usage_collector(
|
||||
let session_id = ctx.session_id.clone();
|
||||
|
||||
SseUsageCollector::new(start_time, move |events, first_token_ms| {
|
||||
if !logging_enabled {
|
||||
return;
|
||||
}
|
||||
if let Some(usage) = stream_parser(&events) {
|
||||
let model = model_extractor(&events, &request_model);
|
||||
let latency_ms = start_time.elapsed().as_millis() as u64;
|
||||
@@ -358,6 +469,13 @@ fn spawn_log_usage(
|
||||
status_code: u16,
|
||||
is_streaming: bool,
|
||||
) {
|
||||
// Check enable_logging before spawning the log task
|
||||
if let Ok(config) = state.config.try_read() {
|
||||
if !config.enable_logging {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let state = state.clone();
|
||||
let provider_id = ctx.provider.id.clone();
|
||||
let app_type_str = ctx.app_type_str.to_string();
|
||||
@@ -512,7 +630,7 @@ pub fn create_logged_passthrough_stream(
|
||||
if !event_text.trim().is_empty() {
|
||||
// 提取 data 部分并尝试解析为 JSON
|
||||
for line in event_text.lines() {
|
||||
if let Some(data) = line.strip_prefix("data: ") {
|
||||
if let Some(data) = strip_sse_field(line, "data") {
|
||||
if data.trim() != "[DONE]" {
|
||||
if let Ok(json_value) = serde_json::from_str::<Value>(data) {
|
||||
if let Some(c) = &collector {
|
||||
@@ -576,6 +694,27 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[test]
|
||||
fn test_strip_sse_field_accepts_optional_space() {
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data: {\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("data:{\"ok\":true}", "data"),
|
||||
Some("{\"ok\":true}")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event: message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(
|
||||
super::strip_sse_field("event:message_start", "event"),
|
||||
Some("message_start")
|
||||
);
|
||||
assert_eq!(super::strip_sse_field("id:1", "data"), None);
|
||||
}
|
||||
|
||||
fn build_state(db: Arc<Database>) -> ProxyState {
|
||||
ProxyState {
|
||||
db: db.clone(),
|
||||
|
||||