Merge branch 'main' into fix/skill-file-extension-1240

This commit is contained in:
YoVinchen
2026-03-13 18:42:47 +08:00
30 changed files with 2153 additions and 290 deletions

View File

@@ -5,7 +5,28 @@ All notable changes to CC Switch will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [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
---

View File

@@ -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` |

View File

@@ -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` |

View File

@@ -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 安装和自动更新 |
> **注意**:由于作者没有苹果开发者账号,首次打开可能出现"未知开发者"警告,请先关闭,然后前往"系统设置" → "隐私与安全性" → 点击"仍要打开",之后便可以正常打开
### HomebrewmacOS
```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` |

View File

@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.12.1",
"version": "3.12.2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {

2
src-tauri/Cargo.lock generated
View File

@@ -672,7 +672,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.12.1"
version = "3.12.2"
dependencies = [
"anyhow",
"async-stream",

View File

@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.12.1"
version = "3.12.2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"

View File

@@ -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"));
}
}

View File

@@ -212,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(())
}
@@ -246,6 +248,7 @@ pub async fn set_common_config_snippet(
snippet: String,
state: tauri::State<'_, crate::store::AppState>,
) -> Result<(), String> {
let is_cleared = snippet.trim().is_empty();
let old_snippet = state
.db
.get_config_snippet(&app_type)
@@ -253,11 +256,7 @@ pub async fn set_common_config_snippet(
validate_common_config_snippet(&app_type, &snippet)?;
let value = if snippet.trim().is_empty() {
None
} else {
Some(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
@@ -278,6 +277,10 @@ pub async fn set_common_config_snippet(
.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())?;

View File

@@ -103,20 +103,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)

View File

@@ -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,

View File

@@ -560,59 +560,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}");
@@ -797,6 +744,8 @@ pub fn run() {
}
}
initialize_common_config_snippets(&state);
// 检查 settings 表中的代理状态,自动恢复代理服务
restore_proxy_state_on_startup(&state).await;
@@ -1305,6 +1254,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}");
}
}
}
// ============================================================
// 迁移错误对话框辅助函数
// ============================================================

View File

@@ -461,19 +461,18 @@ fn apply_common_config_to_settings(
}
}
pub(crate) fn write_live_with_common_config(
pub(crate) fn build_effective_settings_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
) -> Result<Value, AppError> {
let snippet = db.get_config_snippet(app_type.as_str())?;
let mut effective_provider = provider.clone();
let mut effective_settings = provider.settings_config.clone();
if provider_uses_common_config(app_type, provider, snippet.as_deref()) {
if let Some(snippet_text) = snippet.as_deref() {
match apply_common_config_to_settings(app_type, &provider.settings_config, snippet_text)
{
Ok(settings) => effective_provider.settings_config = settings,
match apply_common_config_to_settings(app_type, &effective_settings, snippet_text) {
Ok(settings) => effective_settings = settings,
Err(err) => {
log::warn!(
"Failed to apply common config for {} provider '{}': {err}",
@@ -485,6 +484,18 @@ pub(crate) fn write_live_with_common_config(
}
}
Ok(effective_settings)
}
pub(crate) fn write_live_with_common_config(
db: &Database,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
let mut effective_provider = provider.clone();
effective_provider.settings_config =
build_effective_settings_with_common_config(db, app_type, provider)?;
write_live_snapshot(app_type, &effective_provider)
}

View File

@@ -28,8 +28,9 @@ pub use live::{
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{
normalize_provider_common_config_for_storage, strip_common_config_from_live_settings,
sync_current_provider_for_app_to_live, write_live_with_common_config,
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
write_live_with_common_config,
};
// Internal re-exports
@@ -613,6 +614,46 @@ impl ProviderService {
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return sync_current_provider_for_app_to_live(state, &app_type);
}
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => return Ok(()),
};
let providers = state.db.get_all_providers(app_type.as_str())?;
let Some(provider) = providers.get(&current_id) else {
return Ok(());
};
let takeover_enabled =
futures::executor::block_on(state.db.get_proxy_config_for_app(app_type.as_str()))
.map(|config| config.enabled)
.unwrap_or(false);
let has_live_backup =
futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))
.ok()
.flatten()
.is_some();
let live_taken_over = state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type);
if takeover_enabled && (has_live_backup || live_taken_over) {
futures::executor::block_on(
state
.proxy_service
.update_live_backup_from_provider(app_type.as_str(), provider),
)
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
return Ok(());
}
sync_current_provider_for_app_to_live(state, &app_type)
}
@@ -670,6 +711,25 @@ impl ProviderService {
Ok(())
}
pub fn migrate_legacy_common_config_usage_if_needed(
state: &AppState,
app_type: AppType,
) -> Result<(), AppError> {
if app_type.is_additive_mode() {
return Ok(());
}
let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {
return Ok(());
};
if snippet.trim().is_empty() {
return Ok(());
}
Self::migrate_legacy_common_config_usage(state, app_type, &snippet)
}
/// Extract common config snippet from current provider
///
/// Extracts the current provider's configuration and removes provider-specific fields

View File

@@ -8,7 +8,9 @@ use crate::database::Database;
use crate::provider::Provider;
use crate::proxy::server::ProxyServer;
use crate::proxy::types::*;
use crate::services::provider::write_live_with_common_config;
use crate::services::provider::{
build_effective_settings_with_common_config, write_live_with_common_config,
};
use serde_json::{json, Value};
use std::str::FromStr;
use std::sync::Arc;
@@ -17,7 +19,7 @@ use tokio::sync::RwLock;
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key同时不泄露真实 Token
const PROXY_TOKEN_PLACEHOLDER: &str = "PROXY_MANAGED";
/// 代理接管模式下需要从 Claude Live 配置中移除的模型覆盖字段。
/// 代理接管模式下需要从 Claude Live 配置中移除的"模型覆盖"字段。
///
/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,
/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。
@@ -50,7 +52,7 @@ impl ProxyService {
/// 清理接管模式下 Claude Live 配置中的模型覆盖字段。
///
/// 这可以避免接管开启后切换供应商仍使用旧模型的问题。
/// 这可以避免"接管开启后切换供应商仍使用旧模型"的问题。
/// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。
pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {
let mut config = self.read_claude_live()?;
@@ -1160,7 +1162,7 @@ impl ProxyService {
) -> Result<(), String> {
let app_type_str = app_type.as_str();
// 1) 优先从 Live 备份恢复(这是原始 Live的唯一可靠来源)
// 1) 优先从 Live 备份恢复(这是"原始 Live"的唯一可靠来源)
let backup = self
.db
.get_live_backup(app_type_str)
@@ -1179,7 +1181,7 @@ impl ProxyService {
return Ok(());
}
// 2.1) 优先从 SSOT当前供应商重建 Live清理字段更可用)
// 2.1) 优先从 SSOT当前供应商重建 Live"清理字段"更可用)
match self.restore_live_from_ssot_for_app(app_type) {
Ok(true) => {
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)");
@@ -1356,51 +1358,9 @@ impl ProxyService {
Ok(())
}
/// Remove local proxy base_url from TOML委托给 codex_config 共享实现)
fn remove_local_toml_base_url(toml_str: &str) -> String {
use toml_edit::DocumentMut;
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(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove {
provider_table.remove("base_url");
}
}
}
}
// 兜底:清理顶层 base_url仅当它看起来像本地代理地址
let should_remove_root = doc
.get("base_url")
.and_then(|item| item.as_str())
.map(Self::is_local_proxy_url)
.unwrap_or(false);
if should_remove_root {
doc.as_table_mut().remove("base_url");
}
doc.to_string()
crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)
}
fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {
@@ -1457,7 +1417,7 @@ impl ProxyService {
Ok(())
}
/// 检测 Live 配置是否处于被接管的残留状态
/// 检测 Live 配置是否处于"被接管"的残留状态
///
/// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,
/// 启动流程可以据此触发恢复逻辑。
@@ -1528,21 +1488,37 @@ impl ProxyService {
app_type: &str,
provider: &Provider,
) -> Result<(), String> {
let backup_json = match app_type {
"claude" => {
// Claude: settings_config 直接作为备份
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?
let app_type_enum =
AppType::from_str(app_type).map_err(|_| format!("未知的应用类型: {app_type}"))?;
let mut effective_settings =
build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)
.map_err(|e| format!("构建 {app_type} 有效配置失败: {e}"))?;
if matches!(app_type_enum, AppType::Codex) {
let existing_backup = self
.db
.get_live_backup(app_type)
.await
.map_err(|e| format!("读取 {app_type} 现有备份失败: {e}"))?;
if let Some(existing_backup) = existing_backup {
let existing_value: Value = serde_json::from_str(&existing_backup.original_config)
.map_err(|e| format!("解析 {app_type} 现有备份失败: {e}"))?;
Self::preserve_codex_mcp_servers_in_backup(
&mut effective_settings,
&existing_value,
)?;
}
"codex" => {
// Codex: settings_config 包含 {"auth": ..., "config": ...},直接使用
serde_json::to_string(&provider.settings_config)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?
}
"gemini" => {
// Gemini: 只提取 env 字段(与原始备份格式一致)
// proxy.rs 的 read_gemini_live() 返回 {"env": {...}}
let env_backup = if let Some(env) = provider.settings_config.get("env") {
}
let backup_json = match app_type_enum {
AppType::Claude => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Claude 配置失败: {e}"))?,
AppType::Codex => serde_json::to_string(&effective_settings)
.map_err(|e| format!("序列化 Codex 配置失败: {e}"))?,
AppType::Gemini => {
// Gemini takeover 仅修改 .envsettings.json含 mcpServers保持原样。
let env_backup = if let Some(env) = effective_settings.get("env") {
json!({ "env": env })
} else {
json!({ "env": {} })
@@ -1550,7 +1526,9 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
_ => return Err(format!("未知的应用类型: {app_type}")),
AppType::OpenCode | AppType::OpenClaw => {
return Err(format!("未知的应用类型: {app_type}"));
}
};
self.db
@@ -1562,6 +1540,67 @@ impl ProxyService {
Ok(())
}
fn preserve_codex_mcp_servers_in_backup(
target_settings: &mut Value,
existing_backup: &Value,
) -> Result<(), String> {
let target_obj = target_settings
.as_object_mut()
.ok_or_else(|| "Codex 备份必须是 JSON 对象".to_string())?;
let target_config = target_obj
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
let mut target_doc = if target_config.trim().is_empty() {
toml_edit::DocumentMut::new()
} else {
target_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析新的 Codex config.toml 失败: {e}"))?
};
let existing_config = existing_backup
.get("config")
.and_then(|v| v.as_str())
.unwrap_or("");
if existing_config.trim().is_empty() {
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
return Ok(());
}
let existing_doc = existing_config
.parse::<toml_edit::DocumentMut>()
.map_err(|e| format!("解析现有 Codex 备份失败: {e}"))?;
if let Some(existing_mcp_servers) = existing_doc.get("mcp_servers") {
match target_doc.get_mut("mcp_servers") {
Some(target_mcp_servers) => {
if let (Some(target_table), Some(existing_table)) = (
target_mcp_servers.as_table_like_mut(),
existing_mcp_servers.as_table_like(),
) {
for (server_id, server_item) in existing_table.iter() {
if target_table.get(server_id).is_none() {
target_table.insert(server_id, server_item.clone());
}
}
} else {
log::warn!(
"Codex config contains a non-table mcp_servers section; skipping backup MCP merge"
);
}
}
None => {
target_doc["mcp_servers"] = existing_mcp_servers.clone();
}
}
}
target_obj.insert("config".to_string(), json!(target_doc.to_string()));
Ok(())
}
/// 代理模式下切换供应商(热切换,不写 Live
pub async fn switch_proxy_target(
&self,
@@ -1614,49 +1653,10 @@ impl ProxyService {
// ==================== Live 配置读写辅助方法 ====================
/// 更新 TOML 字符串中的 base_url
/// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)
fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {
use toml_edit::DocumentMut;
let mut doc = match toml_str.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(_) => return toml_str.to_string(),
};
// Codex 的 config.toml 通常是:
// model_provider = "any"
//
// [model_providers.any]
// base_url = "https://.../v1"
//
// 所以接管时要“精准”修改当前 model_provider 对应的 model_providers.<name>.base_url
// 避免写错位置导致 Codex 仍然走旧地址。
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 doc.get("model_providers").is_none() {
doc["model_providers"] = toml_edit::table();
}
if let Some(model_providers) = doc["model_providers"].as_table_mut() {
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() {
provider_table["base_url"] = toml_edit::value(new_url);
return doc.to_string();
}
}
}
// 兜底:如果没有 model_provider 或结构不符合预期,则退回修改顶层 base_url。
doc["base_url"] = toml_edit::value(new_url);
doc.to_string()
crate::codex_config::update_codex_toml_field(toml_str, "base_url", new_url)
.unwrap_or_else(|_| toml_str.to_string())
}
fn read_claude_live(&self) -> Result<Value, String> {
@@ -1914,6 +1914,7 @@ impl ProxyService {
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::ProviderMeta;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
@@ -2166,7 +2167,7 @@ model = "gpt-5.1-codex"
db.set_current_provider("claude", "a")
.expect("set current provider");
// 模拟已接管状态:存在 Live 备份(内容不重要,会被热切换更新)
// 模拟"已接管"状态:存在 Live 备份(内容不重要,会被热切换更新)
db.save_live_backup("claude", "{\"env\":{}}")
.await
.expect("seed live backup");
@@ -2191,4 +2192,285 @@ model = "gpt-5.1-codex"
let expected = serde_json::to_string(&provider_b.settings_config).expect("serialize");
assert_eq!(backup.original_config, expected);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_claude_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"claude",
Some(
serde_json::json!({
"includeCoAuthoredBy": false
})
.to_string(),
),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("claude", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("claude")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
assert_eq!(
stored.get("includeCoAuthoredBy").and_then(|v| v.as_bool()),
Some(false),
"common config should be applied into Claude restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_applies_codex_common_config() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
db.set_config_snippet(
"codex",
Some("disable_response_storage = true\n".to_string()),
)
.expect("set common config snippet");
let service = ProxyService::new(db.clone());
let mut provider = Provider::with_id(
"p1".to_string(),
"P1".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://codex.example/v1"
"#
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("disable_response_storage = true"),
"common config should be applied into Codex restore backup"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"OPENAI_API_KEY": "old-token"
},
"config": r#"model_provider = "any"
model = "gpt-4"
[model_providers.any]
base_url = "https://old.example/v1"
[mcp_servers.echo]
command = "npx"
args = ["echo-server"]
"#
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let provider = Provider::with_id(
"p2".to_string(),
"P2".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "new-token"
},
"config": r#"model_provider = "any"
model = "gpt-5"
[model_providers.any]
base_url = "https://new.example/v1"
"#
}),
None,
);
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
assert!(
config.contains("[mcp_servers.echo]"),
"existing Codex MCP section should survive proxy hot-switch backup update"
);
assert!(
config.contains("https://new.example/v1"),
"provider-specific base_url should still update to the new provider"
);
}
#[tokio::test]
#[serial]
async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {
let _home = TempHome::new();
crate::settings::reload_settings().expect("reload settings");
let db = Arc::new(Database::memory().expect("init db"));
let service = ProxyService::new(db.clone());
db.save_live_backup(
"codex",
&serde_json::to_string(&json!({
"auth": {
"OPENAI_API_KEY": "old-token"
},
"config": r#"[mcp_servers.shared]
command = "old-command"
[mcp_servers.legacy]
command = "legacy-command"
"#
}))
.expect("serialize seed backup"),
)
.await
.expect("seed live backup");
let provider = Provider::with_id(
"p2".to_string(),
"P2".to_string(),
json!({
"auth": {
"OPENAI_API_KEY": "new-token"
},
"config": r#"[mcp_servers.shared]
command = "new-command"
[mcp_servers.latest]
command = "latest-command"
"#
}),
None,
);
service
.update_live_backup_from_provider("codex", &provider)
.await
.expect("update live backup");
let backup = db
.get_live_backup("codex")
.await
.expect("get live backup")
.expect("backup exists");
let stored: Value =
serde_json::from_str(&backup.original_config).expect("parse backup json");
let config = stored
.get("config")
.and_then(|v| v.as_str())
.expect("config string");
let parsed: toml::Value = toml::from_str(config).expect("parse merged codex config");
let mcp_servers = parsed
.get("mcp_servers")
.expect("mcp_servers should be present");
assert_eq!(
mcp_servers
.get("shared")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("new-command"),
"new provider/common-config MCP definition should win on conflict"
);
assert_eq!(
mcp_servers
.get("legacy")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("legacy-command"),
"backup-only MCP entries should still be preserved"
);
assert_eq!(
mcp_servers
.get("latest")
.and_then(|v| v.get("command"))
.and_then(|v| v.as_str()),
Some("latest-command"),
"new MCP entries should remain in the restore backup"
);
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.12.1",
"version": "3.12.2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",

View File

@@ -21,6 +21,80 @@ fn sanitize_provider_name(name: &str) -> String {
.to_lowercase()
}
#[test]
fn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "legacy-provider".to_string();
manager.providers.insert(
"legacy-provider".to_string(),
Provider::with_id(
"legacy-provider".to_string(),
"Legacy".to_string(),
json!({
"includeCoAuthoredBy": false,
"env": {
"ANTHROPIC_API_KEY": "legacy-key"
}
}),
None,
),
);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)
.expect("migrate legacy common config");
let providers = state
.db
.get_all_providers(AppType::Claude.as_str())
.expect("get providers after migration");
let provider = providers
.get("legacy-provider")
.expect("legacy provider exists");
assert_eq!(
provider
.meta
.as_ref()
.and_then(|meta| meta.common_config_enabled),
Some(true),
"historical provider should be explicitly marked as using common config"
);
assert!(
provider
.settings_config
.get("includeCoAuthoredBy")
.is_none(),
"common config fields should be stripped from provider storage after migration"
);
assert_eq!(
provider
.settings_config
.get("env")
.and_then(|v| v.get("ANTHROPIC_API_KEY"))
.and_then(|v| v.as_str()),
Some("legacy-key"),
"provider-specific auth should remain untouched"
);
}
#[test]
fn provider_service_switch_codex_updates_live_and_config() {
let _guard = test_mutex().lock().expect("acquire test mutex");
@@ -164,6 +238,184 @@ command = "say"
);
}
#[test]
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config
.get_manager_mut(&AppType::Claude)
.expect("claude manager");
manager.current = "current-provider".to_string();
let mut provider = Provider::with_id(
"current-provider".to_string(),
"Current".to_string(),
json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "real-token",
"ANTHROPIC_BASE_URL": "https://claude.example"
}
}),
None,
);
provider.meta = Some(ProviderMeta {
common_config_enabled: Some(true),
..Default::default()
});
manager
.providers
.insert("current-provider".to_string(), provider);
}
let state = create_test_state_with_config(&config).expect("create test state");
state
.db
.set_config_snippet(
AppType::Claude.as_str(),
Some(r#"{ "includeCoAuthoredBy": false }"#.to_string()),
)
.expect("set common config snippet");
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5000",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
futures::executor::block_on(state.db.save_live_backup("claude", "{\"env\":{}}"))
.expect("seed live backup");
let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app("claude"))
.expect("get proxy config");
proxy_config.enabled = true;
futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))
.expect("enable takeover");
ProviderService::sync_current_provider_for_app(&state, AppType::Claude)
.expect("sync current provider should succeed");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after sync");
assert_eq!(
live_after, taken_over_live,
"sync should not overwrite live config while takeover is active"
);
let backup = futures::executor::block_on(state.db.get_live_backup("claude"))
.expect("get live backup")
.expect("backup exists");
let backup_value: serde_json::Value =
serde_json::from_str(&backup.original_config).expect("parse backup value");
assert_eq!(
backup_value
.get("includeCoAuthoredBy")
.and_then(|v| v.as_bool()),
Some(false),
"restore backup should receive the updated effective config"
);
assert_eq!(
backup_value
.get("env")
.and_then(|v| v.get("ANTHROPIC_AUTH_TOKEN"))
.and_then(|v| v.as_str()),
Some("real-token"),
"restore backup should preserve the provider token rather than proxy placeholder"
);
}
#[test]
fn explicitly_cleared_common_snippet_is_not_auto_extracted() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), true)
.expect("mark snippet explicitly cleared");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract eligibility"),
"explicitly cleared snippets should block auto-extraction"
);
state
.db
.set_config_snippet(AppType::Claude.as_str(), Some("{}".to_string()))
.expect("set snippet");
state
.db
.set_config_snippet_cleared(AppType::Claude.as_str(), false)
.expect("clear explicit-empty marker");
assert!(
!state
.db
.should_auto_extract_config_snippet(AppType::Claude.as_str())
.expect("check auto-extract after snippet saved"),
"existing snippets should also block auto-extraction"
);
}
#[test]
fn legacy_common_config_migration_flag_roundtrip() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let state = create_test_state().expect("create test state");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("initial migration flag"),
"migration flag should default to false"
);
state
.db
.set_legacy_common_config_migrated(true)
.expect("set migration flag");
assert!(
state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag"),
"migration flag should persist once set"
);
state
.db
.set_legacy_common_config_migrated(false)
.expect("clear migration flag");
assert!(
!state
.db
.is_legacy_common_config_migrated()
.expect("read migration flag after clear"),
"migration flag should be removable for tests/debugging"
);
}
#[test]
fn switch_packycode_gemini_updates_security_selected_type() {
let _guard = test_mutex().lock().expect("acquire test mutex");

View File

@@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { geminiProviderPresets } from "@/config/geminiProviderPresets";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import type { OpenClawSuggestedDefaults } from "@/config/openclawProviderPresets";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
@@ -179,11 +180,9 @@ export function AddProviderDialog({
} else if (appId === "codex") {
const config = parsedConfig.config as string | undefined;
if (config) {
const baseUrlMatch = config.match(
/base_url\s*=\s*["']([^"']+)["']/,
);
if (baseUrlMatch?.[1]) {
addUrl(baseUrlMatch[1]);
const extractedBaseUrl = extractCodexBaseUrl(config);
if (extractedBaseUrl) {
addUrl(extractedBaseUrl);
}
}
} else if (appId === "gemini") {

View File

@@ -13,6 +13,7 @@ import { ProviderIcon } from "@/components/ProviderIcon";
import UsageFooter from "@/components/UsageFooter";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
@@ -76,9 +77,9 @@ const extractApiUrl = (provider: Provider, fallbackText: string) => {
const baseUrl = (config as Record<string, any>)?.config;
if (typeof baseUrl === "string" && baseUrl.includes("base_url")) {
const match = baseUrl.match(/base_url\s*=\s*['"]([^'"]+)['"]/);
if (match?.[1]) {
return match[1];
const extractedBaseUrl = extractCodexBaseUrl(baseUrl);
if (extractedBaseUrl) {
return extractedBaseUrl;
}
}
}

View File

@@ -1,17 +1,22 @@
import { Download, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import type { AppId } from "@/lib/api/types";
interface ProviderEmptyStateProps {
appId: AppId;
onCreate?: () => void;
onImport?: () => void;
}
export function ProviderEmptyState({
appId,
onCreate,
onImport,
}: ProviderEmptyStateProps) {
const { t } = useTranslation();
const showSnippetHint =
appId === "claude" || appId === "codex" || appId === "gemini";
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-10 text-center">
@@ -19,9 +24,14 @@ export function ProviderEmptyState({
<Users className="h-7 w-7 text-muted-foreground" />
</div>
<h3 className="text-lg font-semibold">{t("provider.noProviders")}</h3>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
<p className="mt-2 max-w-lg text-sm text-muted-foreground">
{t("provider.noProvidersDescription")}
</p>
{showSnippetHint && (
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
{t("provider.noProvidersDescriptionSnippet")}
</p>
)}
<div className="mt-6 flex flex-col gap-2">
{onImport && (
<Button onClick={onImport}>

View File

@@ -298,6 +298,7 @@ export function ProviderList({
if (sortedProviders.length === 0) {
return (
<ProviderEmptyState
appId={appId}
onCreate={onCreate}
onImport={() => importMutation.mutate()}
/>

View File

@@ -383,7 +383,9 @@ export function OpenClawFormFields({
className="flex items-center gap-1.5 cursor-pointer select-none"
>
<Checkbox
checked={(model.input ?? ["text"]).includes(type)}
checked={(model.input ?? ["text"]).includes(
type,
)}
onCheckedChange={(checked) => {
const current = model.input ?? ["text"];
const next = checked

View File

@@ -285,7 +285,13 @@ export function useCodexCommonConfig({
isUpdatingFromCommonConfig.current = false;
}, 0);
},
[codexConfig, commonConfigSnippet, onConfigChange, parseCommonConfigSnippet, t],
[
codexConfig,
commonConfigSnippet,
onConfigChange,
parseCommonConfigSnippet,
t,
],
);
// 处理通用配置片段变化

View File

@@ -171,18 +171,15 @@ export function useOpenclawFormState({
[updateOpenclawConfig],
);
const resetOpenclawState = useCallback(
(config?: OpenClawProviderConfig) => {
setOpenclawProviderKey("");
setOpenclawBaseUrl(config?.baseUrl || "");
setOpenclawApiKey(config?.apiKey || "");
setOpenclawApi(config?.api || "openai-completions");
setOpenclawModels(config?.models || []);
const ua = config?.headers ? "User-Agent" in config.headers : false;
setOpenclawUserAgent(ua);
},
[],
);
const resetOpenclawState = useCallback((config?: OpenClawProviderConfig) => {
setOpenclawProviderKey("");
setOpenclawBaseUrl(config?.baseUrl || "");
setOpenclawApiKey(config?.apiKey || "");
setOpenclawApi(config?.api || "openai-completions");
setOpenclawModels(config?.models || []);
const ua = config?.headers ? "User-Agent" in config.headers : false;
setOpenclawUserAgent(ua);
}, []);
return {
openclawProviderKey,

View File

@@ -3,6 +3,7 @@ import type { AppId } from "@/lib/api";
import type { ProviderPreset } from "@/config/claudeProviderPresets";
import type { CodexProviderPreset } from "@/config/codexProviderPresets";
import type { ProviderMeta, EndpointCandidate } from "@/types";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
type PresetEntry = {
id: string;
@@ -128,10 +129,9 @@ export function useSpeedTestEndpoints({
}
| undefined;
const configStr = initialCodexConfig?.config ?? "";
// 从 TOML 中提取 base_url
const match = /base_url\s*=\s*["']([^"']+)["']/i.exec(configStr);
if (match?.[1]) {
add(match[1]);
const extractedBaseUrl = extractCodexBaseUrl(configStr);
if (extractedBaseUrl) {
add(extractedBaseUrl);
}
// 3. 预设中的 endpointCandidates
@@ -141,11 +141,9 @@ export function useSpeedTestEndpoints({
const preset = entry.preset as CodexProviderPreset;
// 添加预设自己的 baseUrl
const presetConfig = preset.config || "";
const presetMatch = /base_url\s*=\s*["']([^"']+)["']/i.exec(
presetConfig,
);
if (presetMatch?.[1]) {
add(presetMatch[1]);
const presetBaseUrl = extractCodexBaseUrl(presetConfig);
if (presetBaseUrl) {
add(presetBaseUrl);
}
// 添加预设的候选端点
if (preset.endpointCandidates) {

View File

@@ -80,7 +80,8 @@
"tabProvider": "Provider",
"tabUniversal": "Universal",
"noProviders": "No providers added yet",
"noProvidersDescription": "Import your current live config below, or manually add a new provider",
"noProvidersDescription": "If you already have a config, click \"Import Current Config\" — all data will be safely saved in a default provider",
"noProvidersDescriptionSnippet": "Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers",
"importCurrent": "Import Current Config",
"importCurrentDescription": "Import current live config as default provider",
"currentlyUsing": "Currently Using",

View File

@@ -80,7 +80,8 @@
"tabProvider": "プロバイダー",
"tabUniversal": "統一プロバイダー",
"noProviders": "まだプロバイダーがありません",
"noProvidersDescription": "下の「現在の設定をインポート」ボタンで既存の設定を取り込むか、新しいプロバイダーを手動で追加してください",
"noProvidersDescription": "既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます",
"noProvidersDescriptionSnippet": "API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます",
"importCurrent": "現在の設定をインポート",
"importCurrentDescription": "現在使用中の設定をデフォルトプロバイダーとしてインポート",
"currentlyUsing": "現在使用中",

View File

@@ -80,7 +80,8 @@
"tabProvider": "供应商",
"tabUniversal": "统一供应商",
"noProviders": "还没有添加任何供应商",
"noProvidersDescription": "点击下方的\"导入当前配置\"按钮导入已有配置,或手动添加新的供应商",
"noProvidersDescription": "如果你已有配置,请点击\"导入当前配置\",所有数据将安全保存在 default 供应商",
"noProvidersDescriptionSnippet": "除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享",
"importCurrent": "导入当前配置",
"importCurrentDescription": "将当前正在使用的配置导入为默认供应商",
"currentlyUsing": "当前使用",

View File

@@ -1,7 +1,7 @@
// 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes, normalizeTomlText } from "@/utils/textNormalization";
import { normalizeTomlText } from "@/utils/textNormalization";
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
const isPlainObject = (value: unknown): value is Record<string, any> => {
@@ -414,17 +414,234 @@ export const hasTomlCommonConfigSnippet = (
// ========== Codex base_url utils ==========
const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
const TOML_BASE_URL_PATTERN =
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_LINE_PATTERN =
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_PATTERN =
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/m;
interface TomlSectionRange {
bodyEndIndex: number;
bodyStartIndex: number;
}
interface TomlAssignmentMatch {
index: number;
sectionName?: string;
value: string;
}
const finalizeTomlText = (lines: string[]): string =>
lines
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/^\n+/, "");
const getTomlSectionRange = (
lines: string[],
sectionName: string,
): TomlSectionRange | undefined => {
let headerLineIndex = -1;
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(TOML_SECTION_HEADER_PATTERN);
if (!match) {
continue;
}
if (headerLineIndex === -1) {
if (match[1] === sectionName) {
headerLineIndex = index;
}
continue;
}
return {
bodyStartIndex: headerLineIndex + 1,
bodyEndIndex: index,
};
}
if (headerLineIndex === -1) {
return undefined;
}
return {
bodyStartIndex: headerLineIndex + 1,
bodyEndIndex: lines.length,
};
};
const getTopLevelEndIndex = (lines: string[]): number => {
const firstSectionIndex = lines.findIndex((line) =>
TOML_SECTION_HEADER_PATTERN.test(line),
);
return firstSectionIndex === -1 ? lines.length : firstSectionIndex;
};
const getTomlSectionInsertIndex = (
lines: string[],
sectionRange: TomlSectionRange,
): number => {
let insertIndex = sectionRange.bodyEndIndex;
while (
insertIndex > sectionRange.bodyStartIndex &&
lines[insertIndex - 1].trim() === ""
) {
insertIndex -= 1;
}
return insertIndex;
};
const getCodexModelProviderName = (configText: string): string | undefined => {
const match = configText.match(TOML_MODEL_PROVIDER_PATTERN);
const providerName = match?.[2]?.trim();
return providerName || undefined;
};
const getCodexProviderSectionName = (
configText: string,
): string | undefined => {
const providerName = getCodexModelProviderName(configText);
return providerName ? `model_providers.${providerName}` : undefined;
};
const findTomlAssignmentInRange = (
lines: string[],
pattern: RegExp,
startIndex: number,
endIndex: number,
sectionName?: string,
): TomlAssignmentMatch | undefined => {
for (let index = startIndex; index < endIndex; index += 1) {
const match = lines[index].match(pattern);
if (match?.[2]) {
return {
index,
sectionName,
value: match[2],
};
}
}
return undefined;
};
const findTomlAssignments = (
lines: string[],
pattern: RegExp,
): TomlAssignmentMatch[] => {
const assignments: TomlAssignmentMatch[] = [];
let currentSectionName: string | undefined;
lines.forEach((line, index) => {
const sectionMatch = line.match(TOML_SECTION_HEADER_PATTERN);
if (sectionMatch) {
currentSectionName = sectionMatch[1];
return;
}
const match = line.match(pattern);
if (!match?.[2]) {
return;
}
assignments.push({
index,
sectionName: currentSectionName,
value: match[2],
});
});
return assignments;
};
const isMcpServerSection = (sectionName?: string): boolean =>
sectionName === "mcp_servers" ||
sectionName?.startsWith("mcp_servers.") === true;
const isOtherProviderSection = (
sectionName: string | undefined,
targetSectionName: string | undefined,
): boolean =>
Boolean(
sectionName &&
sectionName !== targetSectionName &&
(sectionName === "model_providers" ||
sectionName.startsWith("model_providers.")),
);
const getRecoverableBaseUrlAssignments = (
assignments: TomlAssignmentMatch[],
targetSectionName: string | undefined,
): TomlAssignmentMatch[] =>
assignments.filter(
({ sectionName }) =>
sectionName !== targetSectionName &&
!isMcpServerSection(sectionName) &&
!isOtherProviderSection(sectionName, targetSectionName),
);
const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
const topLevelEndIndex = getTopLevelEndIndex(lines);
for (let index = 0; index < topLevelEndIndex; index += 1) {
if (TOML_MODEL_PROVIDER_LINE_PATTERN.test(lines[index])) {
return index;
}
}
return -1;
};
// 从 Codex 的 TOML 配置文本中提取 base_url支持单/双引号)
export const extractCodexBaseUrl = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
const text = normalizeTomlText(raw);
if (!text) return undefined;
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
return m && m[2] ? m[2] : undefined;
const lines = text.split("\n");
const targetSectionName = getCodexProviderSectionName(text);
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
if (sectionRange) {
const match = findTomlAssignmentInRange(
lines,
TOML_BASE_URL_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
targetSectionName,
);
if (match?.value) {
return match.value;
}
}
}
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_BASE_URL_PATTERN,
0,
getTopLevelEndIndex(lines),
);
if (topLevelMatch?.value) {
return topLevelMatch.value;
}
const fallbackAssignments = getRecoverableBaseUrlAssignments(
findTomlAssignments(lines, TOML_BASE_URL_PATTERN),
targetSectionName,
);
return fallbackAssignments.length === 1
? fallbackAssignments[0].value
: undefined;
} catch {
return undefined;
}
@@ -451,36 +668,107 @@ export const setCodexBaseUrl = (
baseUrl: string,
): string => {
const trimmed = baseUrl.trim();
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const normalizedText = normalizeTomlText(configText);
const lines = normalizedText ? normalizedText.split("\n") : [];
const targetSectionName = getCodexProviderSectionName(normalizedText);
const allAssignments = findTomlAssignments(lines, TOML_BASE_URL_PATTERN);
const recoverableAssignments = getRecoverableBaseUrlAssignments(
allAssignments,
targetSectionName,
);
// 允许清空:当 baseUrl 为空时,移除 base_url 行
if (!trimmed) {
if (!normalizedText) return normalizedText;
const next = normalizedText
.split("\n")
.filter((line) => !/^\s*base_url\s*=/.test(line))
.join("\n")
// 避免移除后留下过多空行
.replace(/\n{3,}/g, "\n\n")
// 避免开头出现空行
.replace(/^\n+/, "");
return next;
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
const targetMatch = sectionRange
? findTomlAssignmentInRange(
lines,
TOML_BASE_URL_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
targetSectionName,
)
: undefined;
if (targetMatch) {
lines.splice(targetMatch.index, 1);
return finalizeTomlText(lines);
}
}
if (recoverableAssignments.length === 1) {
lines.splice(recoverableAssignments[0].index, 1);
return finalizeTomlText(lines);
}
return finalizeTomlText(lines);
}
const normalizedUrl = trimmed.replace(/\s+/g, "");
const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
if (targetSectionName) {
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
const targetMatch = targetSectionRange
? findTomlAssignmentInRange(
lines,
TOML_BASE_URL_PATTERN,
targetSectionRange.bodyStartIndex,
targetSectionRange.bodyEndIndex,
targetSectionName,
)
: undefined;
if (targetMatch) {
lines[targetMatch.index] = replacementLine;
return finalizeTomlText(lines);
}
if (recoverableAssignments.length === 1) {
lines.splice(recoverableAssignments[0].index, 1);
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
}
if (targetSectionRange) {
const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);
lines.splice(insertIndex, 0, replacementLine);
return finalizeTomlText(lines);
}
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
lines.push("");
}
lines.push(`[${targetSectionName}]`, replacementLine);
return finalizeTomlText(lines);
}
const prefix =
normalizedText && !normalizedText.endsWith("\n")
? `${normalizedText}\n`
: normalizedText;
return `${prefix}${replacementLine}\n`;
const topLevelEndIndex = getTopLevelEndIndex(lines);
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_BASE_URL_PATTERN,
0,
topLevelEndIndex,
);
if (topLevelMatch) {
lines[topLevelMatch.index] = replacementLine;
return finalizeTomlText(lines);
}
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
if (modelProviderIndex !== -1) {
lines.splice(modelProviderIndex + 1, 0, replacementLine);
return finalizeTomlText(lines);
}
if (lines.length === 0) {
return `${replacementLine}\n`;
}
const insertIndex = topLevelEndIndex;
lines.splice(insertIndex, 0, replacementLine);
return finalizeTomlText(lines);
};
// ========== Codex model name utils ==========
@@ -491,13 +779,16 @@ export const extractCodexModelName = (
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
const text = normalizeTomlText(raw);
if (!text) return undefined;
// 匹配 model = "xxx" 或 model = 'xxx'
const m = text.match(/^model\s*=\s*(['"])([^'"]+)\1/m);
return m && m[2] ? m[2] : undefined;
const lines = text.split("\n");
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_MODEL_PATTERN,
0,
getTopLevelEndIndex(lines),
);
return topLevelMatch?.value;
} catch {
return undefined;
}
@@ -509,47 +800,40 @@ export const setCodexModelName = (
modelName: string,
): string => {
const trimmed = modelName.trim();
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const normalizedText = normalizeTomlText(configText);
const lines = normalizedText ? normalizedText.split("\n") : [];
const topLevelEndIndex = getTopLevelEndIndex(lines);
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_MODEL_PATTERN,
0,
topLevelEndIndex,
);
// 允许清空:当 modelName 为空时,移除 model 行
if (!trimmed) {
if (!normalizedText) return normalizedText;
const next = normalizedText
.split("\n")
.filter((line) => !/^\s*model\s*=/.test(line))
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/^\n+/, "");
return next;
if (topLevelMatch) {
lines.splice(topLevelMatch.index, 1);
}
return finalizeTomlText(lines);
}
const replacementLine = `model = "${trimmed}"`;
const pattern = /^model\s*=\s*["']([^"']+)["']/m;
if (pattern.test(normalizedText)) {
return normalizedText.replace(pattern, replacementLine);
if (topLevelMatch) {
lines[topLevelMatch.index] = replacementLine;
return finalizeTomlText(lines);
}
// 如果不存在 model 字段,尝试在 model_provider 之后插入
// 如果 model_provider 也不存在,则插入到开头
const providerPattern = /^model_provider\s*=\s*["'][^"']+["']/m;
const match = normalizedText.match(providerPattern);
if (match && match.index !== undefined) {
// 在 model_provider 行之后插入
const endOfLine = normalizedText.indexOf("\n", match.index);
if (endOfLine !== -1) {
return (
normalizedText.slice(0, endOfLine + 1) +
replacementLine +
"\n" +
normalizedText.slice(endOfLine + 1)
);
}
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
if (modelProviderIndex !== -1) {
lines.splice(modelProviderIndex + 1, 0, replacementLine);
return finalizeTomlText(lines);
}
// 在文件开头插入
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
if (lines.length === 0) {
return `${replacementLine}\n`;
}
lines.splice(topLevelEndIndex, 0, replacementLine);
return finalizeTomlText(lines);
};

View File

@@ -22,17 +22,21 @@ describe("Codex TOML utils", () => {
expect(extractCodexModelName(output)).toBe("gpt-5-codex");
});
it("removes model line when set to empty", () => {
it("removes only the top-level model line when set to empty", () => {
const input = [
'model_provider = "openai"',
'base_url = "https://api.example.com/v1"',
'model = "gpt-5-codex"',
"",
"[profiles.default]",
'model = "profile-model"',
"",
].join("\n");
const output = setCodexModelName(input, "");
expect(output).not.toMatch(/^\s*model\s*=/m);
expect(output).not.toMatch(/^model\s*=\s*"gpt-5-codex"$/m);
expect(output).toMatch(/^\[profiles\.default\]\nmodel = "profile-model"$/m);
expect(extractCodexModelName(output)).toBeUndefined();
expect(extractCodexBaseUrl(output)).toBe("https://api.example.com/v1");
});
@@ -51,5 +55,97 @@ describe("Codex TOML utils", () => {
const output2 = setCodexModelName(output1, " new-model \n");
expect(extractCodexModelName(output2)).toBe("new-model");
});
});
it("reads and writes base_url in the active provider section", () => {
const input = [
'model_provider = "custom"',
'model = "gpt-5.4"',
"",
"[model_providers.custom]",
'name = "custom"',
'wire_api = "responses"',
"",
"[profiles.default]",
'approval_policy = "never"',
"",
].join("\n");
const output = setCodexBaseUrl(input, "https://api.example.com/v1");
expect(output).toContain(
'[model_providers.custom]\nname = "custom"\nwire_api = "responses"\nbase_url = "https://api.example.com/v1"',
);
expect(extractCodexBaseUrl(output)).toBe("https://api.example.com/v1");
});
it("recovers a single misplaced base_url from another section", () => {
const input = [
'model_provider = "custom"',
'model = "gpt-5.4"',
"",
"[model_providers.custom]",
'name = "custom"',
'wire_api = "responses"',
"",
"[profiles.default]",
'approval_policy = "never"',
'base_url = "https://wrong.example/v1"',
"",
].join("\n");
expect(extractCodexBaseUrl(input)).toBe("https://wrong.example/v1");
const output = setCodexBaseUrl(input, "https://fixed.example/v1");
expect(output).toContain(
'[model_providers.custom]\nname = "custom"\nwire_api = "responses"\nbase_url = "https://fixed.example/v1"',
);
expect(output).not.toContain("https://wrong.example/v1");
expect(output.match(/base_url\s*=/g)).toHaveLength(1);
});
it("does not treat mcp_servers base_url as provider base_url", () => {
const input = [
'model_provider = "azure"',
'model = "gpt-4"',
"",
"[model_providers.azure]",
'name = "Azure OpenAI"',
'wire_api = "responses"',
"",
"[mcp_servers.my_server]",
'base_url = "http://localhost:8080"',
"",
].join("\n");
expect(extractCodexBaseUrl(input)).toBeUndefined();
const output = setCodexBaseUrl(input, "https://new.azure/v1");
expect(output).toContain(
'[model_providers.azure]\nname = "Azure OpenAI"\nwire_api = "responses"\nbase_url = "https://new.azure/v1"',
);
expect(output).toContain(
'[mcp_servers.my_server]\nbase_url = "http://localhost:8080"',
);
});
it("reads model only from the top-level config", () => {
const input = [
'model_provider = "custom"',
"",
"[profiles.default]",
'model = "profile-model"',
"",
].join("\n");
expect(extractCodexModelName(input)).toBeUndefined();
});
it("handles single-quoted values", () => {
const input = "base_url = 'https://api.example.com/v1'\nmodel = 'gpt-5'\n";
expect(extractCodexBaseUrl(input)).toBe("https://api.example.com/v1");
expect(extractCodexModelName(input)).toBe("gpt-5");
});
});