diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c00e9c2..2619acff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.11.3] - 2026-10-22 + +### Fixed + +- **Database / Compatibility**: Support database schemas 21 and 22, fixing startup failures when opening a database created by a newer CC Switch desktop version. Align the migration steps and MCP/Skills fields with the shared schema, preserving existing data and application enablement flags through upgrades, edits, and WebDAV sync. Includes [#528](https://github.com/SaladDay/cc-switch-cli/pull/528), fixing [#526](https://github.com/SaladDay/cc-switch-cli/issues/526). + +### Upgrade notes + +- If startup reports database version 21 or 22 as unsupported, update to v5.11.3. The existing pre-migration backup runs before upgrading older databases; databases newer than schema 22 are still rejected. +- This release adds database compatibility, not CLI management for Antigravity or Kimi Code. After migration to schema 22, older CLI versions cannot open the upgraded database. +- Run `cc-switch update`, or use `brew upgrade cc-switch-cli` for Homebrew installations once the formula is updated. + +### Thanks + +Thank you to everyone who reported problems, proposed changes, shared diagnostics, reviewed code, or joined the discussions. The list below covers issues and pull requests active since v5.11.2, including earlier participants in those discussions and contributions that have not been merged. Being listed here does not mean a proposed feature is included in this release. + +| Contributor | Issues and pull requests | +| --- | --- | +| [@AtaraxiaEpiphany](https://github.com/AtaraxiaEpiphany) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@claude](https://github.com/claude) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@hao-lin-lwh](https://github.com/hao-lin-lwh) | [#525](https://github.com/SaladDay/cc-switch-cli/issues/525) | +| [@kelvkhiu](https://github.com/kelvkhiu) | [#526](https://github.com/SaladDay/cc-switch-cli/issues/526) | +| [@RGB-loop](https://github.com/RGB-loop) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521) | +| [@SaladDay](https://github.com/SaladDay) | [#528](https://github.com/SaladDay/cc-switch-cli/pull/528) | +| [@SanAntonio021](https://github.com/SanAntonio021) | [#523](https://github.com/SaladDay/cc-switch-cli/pull/523), [#524](https://github.com/SaladDay/cc-switch-cli/pull/524) | +| [@stone-w4tch3r](https://github.com/stone-w4tch3r) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481) | +| [@Xiao-Maker-droid](https://github.com/Xiao-Maker-droid) | [#527](https://github.com/SaladDay/cc-switch-cli/pull/527) | + +Thanks also to our sponsors, everyone supporting and using the project, and the CC-Switch maintainers and contributors whose shared migration implementations underpin this fix. + ## [5.11.2] - 2026-10-16 ### Added diff --git a/README.md b/README.md index b4ece61b..82b80904 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **Manage Claude Code, Codex, Gemini, OpenCode, Hermes, OpenClaw, and Pi from one interactive TUI or scriptable CLI.** -[![Version](https://img.shields.io/badge/version-5.11.2-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) +[![Version](https://img.shields.io/badge/version-5.11.3-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) @@ -74,6 +74,18 @@ This project is a **CLI fork** of [CC-Switch](https://github.com/farion1231/cc-s PackyCode provides special discounts for our software users: register via this link and use promo code cc-switch-cli when recharging to get 10% off. + + + + IPWO + + + + Thanks to IPWO for sponsoring this project! IPWO offers a free trial and access to over 90 million active residential IPs across 195+ countries and regions. It supports rotating, static, and unlimited-bandwidth residential proxies, with HTTP(S) and SOCKS5 compatibility.
+ From AI tool development and cross-region network access to API debugging and data collection, IPWO gives developers flexible residential proxy options for applications in different regions.
+ Visit IPWO and use promo code 0200 for 10% off. + + @@ -412,11 +424,14 @@ cc-switch config show # Display configuration cc-switch config path # Show config file paths cc-switch config validate # Validate config file -# Common snippet (shared settings across providers) -# Tries to refresh live config when applicable (`--apply` is kept only as a compatibility flag) +# Claude: open the TUI, edit a provider, then open its JSON editor +cc-switch --app claude +# Read a legacy common snippet to copy into the configuration editor if needed cc-switch --app claude config common show -cc-switch --app claude config common set --snippet '{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}' -cc-switch --app claude config common clear + +# Codex / Gemini still support common snippets +cc-switch --app codex config common set --snippet 'model_reasoning_effort = "high"' +cc-switch --app codex config common clear # Backup cc-switch config backup # Create backup (auto-named) diff --git a/README_JA.md b/README_JA.md index 9f5e6213..3ded4984 100644 --- a/README_JA.md +++ b/README_JA.md @@ -6,7 +6,7 @@ **対話型 TUI またはスクリプトで利用できる CLI から、Claude Code、Codex、Gemini、OpenCode、Hermes、OpenClaw、Pi を一元管理できます。** -[![バージョン](https://img.shields.io/badge/version-5.11.2-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) +[![バージョン](https://img.shields.io/badge/version-5.11.3-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![対応プラットフォーム](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Rust 製](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org/) [![ライセンス](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) @@ -77,6 +77,18 @@ CC-Switch CLI ユーザー向けの割引として、こちらのリンクから登録し、チャージ時にクーポンコード cc-switch-cli を入力すると、10% オフになります。 + + + + IPWO + + + + 本プロジェクトをご支援いただいている IPWO に感謝します!IPWO は無料でお試しいただける住宅用プロキシサービスで、195 以上の国と地域にわたる 9,000 万以上のアクティブな住宅用 IP を提供しています。ローテーション型、固定型、通信量無制限の住宅用プロキシに対応し、HTTP(S) と SOCKS5 を利用できます。
+ AI ツールの開発、地域をまたぐネットワークアクセス、API のデバッグ、データ収集など、さまざまな地域でのアプリケーション開発に向けて、柔軟な住宅用プロキシの選択肢を提供します。
+ こちらのリンクから IPWO にアクセスし、クーポンコード 0200 を入力すると、10% オフになります。 + + diff --git a/README_ZH.md b/README_ZH.md index 42b138b7..045fb331 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -6,7 +6,7 @@ **通过交互式 TUI 或脚本化 CLI,统一管理 Claude Code、Codex、Gemini、OpenCode、Hermes、OpenClaw 和 Pi。** -[![Version](https://img.shields.io/badge/version-5.11.2-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) +[![Version](https://img.shields.io/badge/version-5.11.3-blue.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/saladday/cc-switch-cli/releases) [![Built with Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org/) [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) @@ -72,6 +72,18 @@ 注册,并在充值时填写优惠码 cc-switch-cli,即可享受 9 折优惠。 + + + + IPWO + + + + 感谢 IPWO 赞助本项目!IPWO 可以免费测试,提供覆盖 195+ 个国家和地区的 9000 万+ 活跃住宅 IP 资源,支持动态住宅代理、静态住宅代理及不限量住宅代理,并兼容 HTTP(S) 和 SOCKS5 协议。
+ 无论是 AI 工具开发、跨区域网络访问,还是 API 调试与数据采集,IPWO 都能为开发者提供灵活的住宅代理选择,帮助拓展不同地区的网络应用场景。
+ 通过此链接访问 IPWO,使用优惠码 0200,即可减免 10%。 + + @@ -413,11 +425,14 @@ cc-switch config show # 显示配置 cc-switch config path # 显示配置文件路径 cc-switch config validate # 验证配置文件 -# 通用配置片段(跨所有供应商共享设置) -# 会在适用时尝试刷新 live config(`--apply` 仅保留为兼容参数) +# Claude:进入 TUI,编辑供应商,再打开 JSON 编辑器修改完整配置 +cc-switch --app claude +# 读取旧版通用片段,按需复制到配置编辑器 cc-switch --app claude config common show -cc-switch --app claude config common set --snippet '{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}' -cc-switch --app claude config common clear + +# Codex / Gemini 仍支持通用片段 +cc-switch --app codex config common set --snippet 'model_reasoning_effort = "high"' +cc-switch --app codex config common clear # 备份 cc-switch config backup # 创建备份(自动命名) diff --git a/assets/partners/logos/ipwo.png b/assets/partners/logos/ipwo.png new file mode 100644 index 00000000..e106671f Binary files /dev/null and b/assets/partners/logos/ipwo.png differ diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9c00e9c2..2619acff 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.11.3] - 2026-10-22 + +### Fixed + +- **Database / Compatibility**: Support database schemas 21 and 22, fixing startup failures when opening a database created by a newer CC Switch desktop version. Align the migration steps and MCP/Skills fields with the shared schema, preserving existing data and application enablement flags through upgrades, edits, and WebDAV sync. Includes [#528](https://github.com/SaladDay/cc-switch-cli/pull/528), fixing [#526](https://github.com/SaladDay/cc-switch-cli/issues/526). + +### Upgrade notes + +- If startup reports database version 21 or 22 as unsupported, update to v5.11.3. The existing pre-migration backup runs before upgrading older databases; databases newer than schema 22 are still rejected. +- This release adds database compatibility, not CLI management for Antigravity or Kimi Code. After migration to schema 22, older CLI versions cannot open the upgraded database. +- Run `cc-switch update`, or use `brew upgrade cc-switch-cli` for Homebrew installations once the formula is updated. + +### Thanks + +Thank you to everyone who reported problems, proposed changes, shared diagnostics, reviewed code, or joined the discussions. The list below covers issues and pull requests active since v5.11.2, including earlier participants in those discussions and contributions that have not been merged. Being listed here does not mean a proposed feature is included in this release. + +| Contributor | Issues and pull requests | +| --- | --- | +| [@AtaraxiaEpiphany](https://github.com/AtaraxiaEpiphany) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@claude](https://github.com/claude) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@hao-lin-lwh](https://github.com/hao-lin-lwh) | [#525](https://github.com/SaladDay/cc-switch-cli/issues/525) | +| [@kelvkhiu](https://github.com/kelvkhiu) | [#526](https://github.com/SaladDay/cc-switch-cli/issues/526) | +| [@RGB-loop](https://github.com/RGB-loop) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521) | +| [@SaladDay](https://github.com/SaladDay) | [#528](https://github.com/SaladDay/cc-switch-cli/pull/528) | +| [@SanAntonio021](https://github.com/SanAntonio021) | [#523](https://github.com/SaladDay/cc-switch-cli/pull/523), [#524](https://github.com/SaladDay/cc-switch-cli/pull/524) | +| [@stone-w4tch3r](https://github.com/stone-w4tch3r) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481) | +| [@Xiao-Maker-droid](https://github.com/Xiao-Maker-droid) | [#527](https://github.com/SaladDay/cc-switch-cli/pull/527) | + +Thanks also to our sponsors, everyone supporting and using the project, and the CC-Switch maintainers and contributors whose shared migration implementations underpin this fix. + ## [5.11.2] - 2026-10-16 ### Added diff --git a/docs/releases/v5.11.3.md b/docs/releases/v5.11.3.md new file mode 100644 index 00000000..92ec3997 --- /dev/null +++ b/docs/releases/v5.11.3.md @@ -0,0 +1,41 @@ +# CC Switch CLI v5.11.3 + +This patch release restores compatibility with database schemas 21 and 22 for local startup and WebDAV sync. + +## Fixed + +- **Database / Compatibility**: Support database schemas 21 and 22, fixing startup failures when opening a database created by a newer CC Switch desktop version. Align the migration steps and MCP/Skills fields with the shared schema, preserving existing data and application enablement flags through upgrades, edits, and WebDAV sync. Includes [#528](https://github.com/SaladDay/cc-switch-cli/pull/528), fixing [#526](https://github.com/SaladDay/cc-switch-cli/issues/526). + +## Upgrade notes + +- If startup reports database version 21 or 22 as unsupported, update to v5.11.3. The existing pre-migration backup runs before upgrading older databases; databases newer than schema 22 are still rejected. +- This release adds database compatibility, not CLI management for Antigravity or Kimi Code. After migration to schema 22, older CLI versions cannot open the upgraded database. +- Run `cc-switch update`, or use `brew upgrade cc-switch-cli` for Homebrew installations once the formula is updated. + +## Thanks + +Thank you to everyone who reported problems, proposed changes, shared diagnostics, reviewed code, or joined the discussions. The list below covers issues and pull requests active since v5.11.2, including earlier participants in those discussions and contributions that have not been merged. Being listed here does not mean a proposed feature is included in this release. + +| Contributor | Issues and pull requests | +| --- | --- | +| [@AtaraxiaEpiphany](https://github.com/AtaraxiaEpiphany) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@claude](https://github.com/claude) | [#517](https://github.com/SaladDay/cc-switch-cli/pull/517), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521), [#522](https://github.com/SaladDay/cc-switch-cli/pull/522) | +| [@hao-lin-lwh](https://github.com/hao-lin-lwh) | [#525](https://github.com/SaladDay/cc-switch-cli/issues/525) | +| [@kelvkhiu](https://github.com/kelvkhiu) | [#526](https://github.com/SaladDay/cc-switch-cli/issues/526) | +| [@RGB-loop](https://github.com/RGB-loop) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481), [#521](https://github.com/SaladDay/cc-switch-cli/pull/521) | +| [@SaladDay](https://github.com/SaladDay) | [#528](https://github.com/SaladDay/cc-switch-cli/pull/528) | +| [@SanAntonio021](https://github.com/SanAntonio021) | [#523](https://github.com/SaladDay/cc-switch-cli/pull/523), [#524](https://github.com/SaladDay/cc-switch-cli/pull/524) | +| [@stone-w4tch3r](https://github.com/stone-w4tch3r) | [#481](https://github.com/SaladDay/cc-switch-cli/issues/481) | +| [@Xiao-Maker-droid](https://github.com/Xiao-Maker-droid) | [#527](https://github.com/SaladDay/cc-switch-cli/pull/527) | + +Thanks also to our sponsors, everyone supporting and using the project, and the CC-Switch maintainers and contributors whose shared migration implementations underpin this fix. + +## Install or update + +Run `cc-switch update`, or use `brew upgrade cc-switch-cli` for Homebrew installations once the formula is updated. Platform packages and the installer are available below: + +```bash +curl -fsSL https://github.com/SaladDay/cc-switch-cli/releases/latest/download/install.sh | bash +``` + +[Full comparison](https://github.com/SaladDay/cc-switch-cli/compare/v5.11.2...v5.11.3) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d68bd85f..57b87437 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "5.11.2" +version = "5.11.3" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 16151a30..9943ae78 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "5.11.2" +version = "5.11.3" description = "All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode & OpenClaw" authors = ["Jason Young", "saladday"] license = "MIT" diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index dc0720ac..4e749d82 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -368,7 +368,7 @@ mod tests { use tempfile::TempDir; use crate::codex_config::{get_codex_config_dir, get_codex_config_path}; - use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; + use crate::config::{get_claude_settings_path, write_json_file}; use crate::provider::{Provider, ProviderMeta}; use crate::services::ProviderService; use crate::test_support::TestEnvGuard; @@ -448,78 +448,19 @@ mod tests { #[test] #[serial] - fn set_stores_claude_snippet_without_enabling_provider_live_config() { - let (_temp_home, _env) = seed_current_claude_provider(); - - set( - AppType::Claude, - Some(r#"{"alwaysThinkingEnabled":false}"#), - None, - false, - ) - .expect("set should succeed"); - - let state = AppState::try_new().expect("reload state"); - let stored = state - .config - .read() - .expect("read config") - .common_config_snippets - .claude - .clone() - .expect("stored claude snippet"); - let stored_json: serde_json::Value = - serde_json::from_str(&stored).expect("stored snippet should be valid JSON"); - assert_eq!(stored_json["alwaysThinkingEnabled"], false); - - let live: serde_json::Value = - read_json_file(&get_claude_settings_path()).expect("read live settings"); - assert!( - live.get("alwaysThinkingEnabled").is_none(), - "setting a new common snippet must not opt the current provider into common config" - ); - } - - #[test] - #[serial] - fn set_updates_live_config_for_common_config_enabled_claude_provider() { - let (_temp_home, _env) = - seed_current_claude_provider_with_meta(Some(common_config_meta(true))); - - set( + fn claude_common_mutations_direct_users_to_the_editor_without_writing() { + let (_home, _env) = seed_current_claude_provider(); + let before = std::fs::read(get_claude_settings_path()).unwrap(); + assert!(set( AppType::Claude, - Some(r#"{"alwaysThinkingEnabled":false}"#), + Some(r#"{"language":"chinese"}"#), None, - false, - ) - .expect("set should succeed"); - - let live: serde_json::Value = - read_json_file(&get_claude_settings_path()).expect("read live settings"); - assert_eq!(live["alwaysThinkingEnabled"], false); - } - - #[test] - #[serial] - fn clear_updates_live_config_even_without_apply_flag() { - let (_temp_home, _env) = seed_current_claude_provider(); - let state = AppState::try_new().expect("reload state"); - - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(r#"{"alwaysThinkingEnabled":false}"#.to_string()), + true ) - .expect("seed common snippet"); - - clear(AppType::Claude, false).expect("clear should succeed"); - - let live: serde_json::Value = - read_json_file(&get_claude_settings_path()).expect("read live settings"); - assert!( - live.get("alwaysThinkingEnabled").is_none(), - "clearing the common snippet should also clear it from live settings" - ); + .is_err()); + assert!(clear(AppType::Claude, true).is_err()); + assert_eq!(std::fs::read(get_claude_settings_path()).unwrap(), before); + assert!(show(AppType::Claude).is_ok()); } #[test] @@ -605,65 +546,6 @@ mod tests { ); } - #[test] - #[serial] - fn extract_from_settings_config_saves_common_snippet() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - - extract( - AppType::Claude, - None, - Some( - r#"{ - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example", - "ANTHROPIC_AUTH_TOKEN": "sk-test", - "CC_SWITCH_SHARED": "1" - } - }"#, - ), - None, - true, - ) - .expect("extract should succeed"); - - let state = AppState::try_new().expect("reload state"); - let stored = state - .config - .read() - .expect("read config") - .common_config_snippets - .claude - .clone() - .expect("stored claude snippet"); - let stored_json: serde_json::Value = - serde_json::from_str(&stored).expect("stored snippet should be valid JSON"); - assert_eq!(stored_json["env"]["CC_SWITCH_SHARED"], "1"); - assert!(stored_json["env"].get("ANTHROPIC_AUTH_TOKEN").is_none()); - } - - #[test] - #[serial] - fn extract_from_named_provider_saves_common_snippet() { - let (_temp_home, _env) = seed_current_claude_provider(); - - extract(AppType::Claude, Some("p1"), None, None, true).expect("extract should succeed"); - - let state = AppState::try_new().expect("reload state"); - let stored = state - .config - .read() - .expect("read config") - .common_config_snippets - .claude - .clone() - .expect("stored claude snippet"); - let stored_json: serde_json::Value = - serde_json::from_str(&stored).expect("stored snippet should be valid JSON"); - assert_eq!(stored_json, json!({})); - } - #[test] fn set_rejects_non_object_opencode_common_snippet() { let err = set(AppType::OpenCode, Some("[]"), None, false) diff --git a/src-tauri/src/cli/commands/provider.rs b/src-tauri/src/cli/commands/provider.rs index f61d0730..45b0593b 100644 --- a/src-tauri/src/cli/commands/provider.rs +++ b/src-tauri/src/cli/commands/provider.rs @@ -1716,7 +1716,7 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { let manager = config .get_manager(&app_type) .ok_or_else(|| AppError::Message(texts::app_config_not_found(app_type.as_str())))?; - let original = manager + let mut original = manager .providers .get(id) .ok_or_else(|| { @@ -1728,6 +1728,36 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { let common_snippet = config.common_config_snippets.get(&app_type).cloned(); drop(config); + let claude_editor = if matches!(app_type, AppType::Claude) { + let view = ProviderService::claude_editor_view(&state, &original.settings_config)?; + original.settings_config = view.settings.clone(); + if !view.inactive.is_empty() { + for field in &view.inactive { + println!("{}: {}", field.path.join("."), field.value); + } + if Confirm::new(crate::t!( + "Copy these inactive fields into the editor?", + "将这些未应用字段复制到编辑器?" + )) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(e.to_string()))? + { + crate::services::provider::claude_editor::copy_inactive_fields( + &mut original.settings_config, + &view.inactive, + ); + } + } + Some(crate::services::provider::claude_editor::EditorSave { + base: view.settings, + draft: None, + on_conflict: Default::default(), + }) + } else { + None + }; + // 2. 显示当前配置 println!("\n{}", highlight(texts::current_config_header())); display_provider_summary(&original, &app_type); @@ -1817,8 +1847,13 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { return Ok(()); } - // 8. 调用 Service 层(upstream parity:干净写入,无冲突提示) - ProviderService::update(&state, app_type.clone(), updated)?; + if let Some(editor) = claude_editor { + if !save_claude_editor_interactive(&state, &updated, editor, None)? { + return Ok(()); + } + } else { + ProviderService::update(&state, app_type.clone(), updated)?; + } // 9. 成功消息 println!( @@ -1832,6 +1867,56 @@ fn edit_provider(app_type: AppType, id: &str) -> Result<(), AppError> { Ok(()) } +fn save_claude_editor_interactive( + state: &AppState, + provider: &Provider, + mut editor: crate::services::provider::claude_editor::EditorSave, + copy_source: Option<&str>, +) -> Result { + loop { + match ProviderService::save_claude_from_editor( + state, + provider.clone(), + editor.clone(), + copy_source.is_some(), + copy_source, + ) { + Ok(_) => return Ok(true), + Err(error) + if crate::services::provider::claude_editor::conflict_keys(&error).is_some() => + { + let choices = vec![ + crate::t!("Cancel", "取消"), + crate::t!("Keep my changes", "保留我的修改"), + crate::t!("Keep external changes", "保留外部修改"), + ]; + let keys = crate::services::provider::claude_editor::conflict_keys(&error) + .unwrap_or_default() + .join(", "); + let prompt = format!( + "{}: {keys}", + crate::t!( + "Fields changed outside the editor", + "以下字段已被其他程序修改" + ) + ); + let choice = inquire::Select::new(&prompt, choices.clone()) + .prompt() + .map_err(|e| AppError::Message(e.to_string()))?; + use crate::services::provider::claude_editor::ConflictPolicy; + editor.on_conflict = if choice == choices[1] { + ConflictPolicy::KeepMine + } else if choice == choices[2] { + ConflictPolicy::KeepTheirs + } else { + return Ok(false); + }; + } + Err(error) => return Err(error), + } + } +} + fn provider_copy_id(original_id: &str, existing_ids: &[String]) -> String { let base_id = format!("{}-copy", original_id.trim()); if !existing_ids.iter().any(|id| id == &base_id) { @@ -1922,7 +2007,18 @@ fn duplicate_provider_interactive(app_type: AppType, id: &str) -> Result<(), App let common_snippet = config.common_config_snippets.get(&app_type).cloned(); drop(config); - let draft = provider_duplicate_draft(&source, &existing_ids); + let mut draft = provider_duplicate_draft(&source, &existing_ids); + let claude_editor = if matches!(app_type, AppType::Claude) { + let view = ProviderService::claude_editor_view(&state, &draft.settings_config)?; + draft.settings_config = view.settings; + Some(crate::services::provider::claude_editor::EditorSave { + base: view.base_for_new, + draft: None, + on_conflict: Default::default(), + }) + } else { + None + }; println!("\n{}", highlight(texts::current_config_header())); display_provider_summary(&draft, &app_type); @@ -1994,7 +2090,14 @@ fn duplicate_provider_interactive(app_type: AppType, id: &str) -> Result<(), App return Ok(()); } - let duplicate = ProviderService::duplicate(&state, app_type, id, Some(copied))?; + let duplicate = if let Some(editor) = claude_editor { + if !save_claude_editor_interactive(&state, &copied, editor, Some(id))? { + return Ok(()); + } + copied + } else { + ProviderService::duplicate(&state, app_type, id, Some(copied))? + }; println!( "{}", success(&texts::provider_duplicated_success(id, &duplicate.id)) @@ -2223,10 +2326,14 @@ mod tests { &mut prompt_result, ) .expect("build Codex settings"); + let default_model = format!( + "model = \"{}\"", + crate::provider_preset_models::CODEX_DEFAULT_MODEL + ); assert!(codex["config"] .as_str() .expect("Codex config should be TOML") - .contains("model = \"gpt-5.6-sol\"")); + .contains(&default_model)); let gemini = build_add_settings_config( &AppType::Gemini, diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index 29676189..4c98f55c 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -12,8 +12,9 @@ use crate::error::AppError; use crate::provider::{AuthBinding, AuthBindingSource, ClaudeApiKeyField, Provider, ProviderMeta}; use crate::provider_preset_builtin::{builtin_provider_preset_value, BuiltinProviderPresetId}; use crate::provider_preset_models::{ - codex_oauth_claude_env, sponsor_hermes_models, sponsor_model_family, sponsor_openclaw_models, - sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, GEMINI_DEFAULT_MODEL, + codex_oauth_claude_env, sponsor_codex_model, sponsor_hermes_models, sponsor_model_family, + sponsor_openclaw_models, sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, + GEMINI_DEFAULT_MODEL, }; use crate::provider_preset_sponsors::{ sponsor_provider_preset, sponsor_provider_presets_for_app, SponsorProviderPreset, @@ -126,7 +127,7 @@ impl SettingsConfigPromptResult { } pub fn supports_common_config(app_type: &AppType) -> bool { - matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) + matches!(app_type, AppType::Codex | AppType::Gemini) } pub fn common_snippet_has_effective_config( @@ -586,7 +587,7 @@ fn build_sponsor_template_settings_config( Ok(build_codex_settings_config( None, preset.codex_base_url, - CODEX_DEFAULT_MODEL, + sponsor_codex_model(preset.id), "responses", provider_name, )) @@ -628,7 +629,9 @@ fn build_sponsor_template_settings_config( SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "anthropic_messages" } - SponsorModelFamily::Gpt => crate::hermes_config::HERMES_DEFAULT_API_MODE, + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + crate::hermes_config::HERMES_DEFAULT_API_MODE + } }, preset.hermes_base_url, "", @@ -655,7 +658,7 @@ fn build_sponsor_template_settings_config( SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "anthropic-messages" } - SponsorModelFamily::Gpt => { + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { crate::openclaw_config::OPENCLAW_DEFAULT_API_PROTOCOL } }, @@ -924,7 +927,7 @@ env_key = "OLD_API_KEY" .get("config") .and_then(Value::as_str) .expect("config should be present"); - assert!(config.contains("model = \"gpt-5.6-sol\"")); + assert!(config.contains("model = \"gpt-6.1-sol\"")); assert!(config.contains("base_url = \"https://api.example.com/v1\"")); } @@ -1151,7 +1154,7 @@ requires_openai_auth = true #[test] fn common_config_helpers_detect_and_mark_supported_provider() { - assert!(common_snippet_has_effective_config( + assert!(!common_snippet_has_effective_config( &AppType::Claude, Some(r#"{"env":{"CC_SWITCH_SHARED":"1"}}"#) )); @@ -1384,19 +1387,19 @@ requires_openai_auth = true ); assert_eq!( provider.settings_config["env"]["ANTHROPIC_MODEL"], - "gpt-5.6-sol" + "gpt-6.1-sol" ); assert_eq!( provider.settings_config["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"], - "gpt-5.6-luna" + "gpt-6-luna" ); assert_eq!( provider.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], - "372000" + "272000" ); assert_eq!( provider.settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"], - "372000" + "272000" ); assert!( provider.settings_config["env"] @@ -1479,7 +1482,7 @@ requires_openai_auth = true let config = codex.settings_config["config"] .as_str() .expect("PatewayAI Codex config should be TOML"); - assert!(config.contains("model = \"gpt-5.6-sol\"")); + assert!(config.contains("model = \"gpt-6.1-sol\"")); assert!(config.contains("base_url = \"https://api.pateway.ai/v1\"")); assert!(config.contains("name = \"patewayai\"")); assert!(config.contains("wire_api = \"responses\"")); @@ -1878,7 +1881,7 @@ requires_openai_auth = true .and_then(Value::as_str) .expect("DeepSeek Codex config should be TOML string"); assert!(config.contains("model_provider = \"custom\"")); - assert!(config.contains("model = \"deepseek-v4-flash\"")); + assert!(config.contains("model = \"deepseek-flash\"")); assert!(config.contains("disable_response_storage = true")); assert!(config.contains("[model_providers.custom]")); assert!(config.contains("name = \"deepseek\"")); @@ -1895,15 +1898,10 @@ requires_openai_auth = true json!({ "models": [ { - "model": "deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "contextWindow": 1048576, - "reasoningLevels": ["low", "high", "max"], - }, - { - "model": "deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", + "model": "deepseek-flash", + "displayName": "DeepSeek V4.1 Flash", "contextWindow": 1048576, + "inputModalities": ["text", "image"], "reasoningLevels": ["low", "high", "max"], }, ], @@ -1927,7 +1925,7 @@ requires_openai_auth = true .expect("Codex sponsor config should be TOML string"); assert_eq!(codex.name, "PackyCode"); assert!(codex_config.contains("base_url = \"https://www.packyapi.ai/v1\"")); - assert!(codex_config.contains("model = \"gpt-5.6-sol\"")); + assert!(codex_config.contains("model = \"gpt-6.1-sol\"")); assert!(codex_config.contains("wire_api = \"responses\"")); assert_eq!( codex @@ -1960,8 +1958,8 @@ requires_openai_auth = true "https://api.aicodemirror.ai/api/claudecode" ); assert_eq!( - opencode.settings_config["models"]["claude-opus-5"]["name"], - "Claude Opus 5" + opencode.settings_config["models"]["claude-opus-5-5"]["name"], + "Claude Opus 5.5" ); assert_eq!( opencode @@ -1983,9 +1981,9 @@ requires_openai_auth = true assert_eq!( hermes.settings_config["models"], json!([ - { "id": "claude-opus-5", "name": "Claude Opus 5" }, - { "id": "claude-sonnet-5", "name": "Claude Sonnet 5" }, - { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5" }, + { "id": "claude-opus-5-5", "name": "Claude Opus 5.5" }, + { "id": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5" }, + { "id": "claude-haiku-5-5", "name": "Claude Haiku 5.5" }, ]) ); @@ -2001,16 +1999,22 @@ requires_openai_auth = true openclaw.settings_config["models"], json!([ { - "id": "claude-opus-5", - "name": "Claude Opus 5", + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 5, "output": 25 }, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }, { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 3, "output": 15 }, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }, ]) ); @@ -2073,7 +2077,7 @@ requires_openai_auth = true let codex_config = codex.settings_config["config"] .as_str() .expect("OpenModel Codex config should be TOML"); - assert!(codex_config.contains("model = \"gpt-5.6-sol\"")); + assert!(codex_config.contains("model = \"gpt-6.1-sol\"")); assert!(codex_config.contains("base_url = \"https://api.openmodel.ai/v1\"")); assert!(codex_config.contains("wire_api = \"responses\"")); assert!(codex_config.contains("requires_openai_auth = true")); @@ -2090,7 +2094,7 @@ requires_openai_auth = true json!({ "env": { "GOOGLE_GEMINI_BASE_URL": "https://api.openmodel.ai", - "GEMINI_MODEL": "gemini-3.6-flash", + "GEMINI_MODEL": "gemini-3.8-flash", }, }) ); @@ -2106,8 +2110,8 @@ requires_openai_auth = true assert_eq!( opencode.settings_config["models"], json!({ - "claude-sonnet-5": { "name": "Claude Sonnet 5" }, - "claude-opus-5": { "name": "Claude Opus 5" }, + "claude-sonnet-5-5": { "name": "Claude Sonnet 5.5" }, + "claude-opus-5-5": { "name": "Claude Opus 5.5" }, }) ); @@ -2122,9 +2126,9 @@ requires_openai_auth = true assert_eq!( hermes.settings_config["models"], json!([ - { "id": "claude-opus-5", "name": "Claude Opus 5" }, - { "id": "claude-sonnet-5", "name": "Claude Sonnet 5" }, - { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5" }, + { "id": "claude-opus-5-5", "name": "Claude Opus 5.5" }, + { "id": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5" }, + { "id": "claude-haiku-5-5", "name": "Claude Haiku 5.5" }, ]) ); @@ -2140,16 +2144,22 @@ requires_openai_auth = true openclaw.settings_config["models"], json!([ { - "id": "claude-opus-5", - "name": "Claude Opus 5", + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 5, "output": 25 }, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }, { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 3, "output": 15 }, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }, ]) ); @@ -2676,8 +2686,6 @@ pub(crate) fn build_codex_settings_config_from_prompt( base_url, model, "responses", - true, - "OPENAI_API_KEY", ); let mut settings_obj = current @@ -4329,7 +4337,7 @@ fn prompt_codex_config(current: Option<&Value>, provider_name: &str) -> Result String { } } -fn is_codex_oauth_provider(provider: &Provider) -> bool { - provider - .meta - .as_ref() - .and_then(|meta| meta.provider_type.as_deref()) - .is_some_and(|value| value == "codex_oauth") -} - #[cfg(test)] mod tests { use serde_json::{json, Value}; @@ -404,6 +395,40 @@ mod tests { )); } + #[test] + fn codex_oauth_quota_ignores_usage_templates_and_keeps_account_refresh() { + let mut keys = Vec::new(); + for account in ["acct-1", "acct-2"] { + for template in ["general", "balance", "official_subscription"] { + for enabled in [false, true] { + let mut provider = test_provider("oauth", "Unrelated name", json!({})); + set_usage_script(&mut provider, enabled, template); + let meta = provider.meta.as_mut().unwrap(); + meta.provider_type = Some("codex_oauth".into()); + meta.auth_binding = Some(AuthBinding { + source: AuthBindingSource::ManagedAccount, + auth_provider: Some("codex_oauth".into()), + account_id: Some(account.into()), + }); + meta.usage_script.as_mut().unwrap().auto_query_interval = Some(0); + let target = quota_target_for_provider(&AppType::Claude, "oauth", &provider) + .expect("native quota target"); + assert_eq!( + target.kind, + QuotaTargetKind::CodexOAuth { + account_id: Some(account.into()), + } + ); + assert_eq!(target.auto_query_interval_minutes, 5); + if template == "general" && enabled { + keys.push(target.cache_key()); + } + } + } + } + assert_ne!(keys[0], keys[1]); + } + #[test] fn usage_display_hides_default_plan_name() { let item = UsageData { diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 97c15f24..7698e080 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -489,6 +489,7 @@ impl ConfigItem { pub(crate) fn listed_in_config_menu(&self, app_type: &AppType) -> bool { self.visible_for_app(app_type) + && !(matches!(app_type, AppType::Claude) && matches!(self, ConfigItem::CommonSnippet)) && !matches!( self, ConfigItem::OpenClawWorkspace diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index 1f6c1532..c8143222 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -1529,6 +1529,16 @@ impl App { initial_override: Option, source: CommonSnippetViewSource, ) { + if matches!(app_type, AppType::Claude) { + self.push_toast( + crate::t!( + "Edit Claude settings in the provider configuration editor.", + "请在供应商配置编辑器中修改 Claude 设置。" + ), + ToastKind::Warning, + ); + return; + } let snippet = initial_override.unwrap_or_else(|| { let snippet = self.common_snippet_text_for(&app_type, data); if snippet.trim().is_empty() { @@ -1566,6 +1576,52 @@ impl App { }); } + fn initialize_claude_editor(&mut self, data: &UiData) { + if !matches!(self.app_type, AppType::Claude) { + return; + } + let result = (|| -> Result<(), crate::error::AppError> { + let Some(FormState::ProviderAdd(form)) = self.form.as_mut() else { + return Ok(()); + }; + let mut provider: crate::provider::Provider = + serde_json::from_value(form.to_provider_json_value()) + .map_err(|e| crate::error::AppError::Message(e.to_string()))?; + let current = data + .providers + .rows + .iter() + .find(|row| row.is_current) + .map(|row| &row.provider); + let view = crate::services::provider::claude_editor::view_for_current( + &provider.settings_config, + current, + )?; + provider.settings_config = view.settings.clone(); + form.apply_provider_json_value_to_fields( + serde_json::to_value(&provider) + .map_err(|e| crate::error::AppError::Message(e.to_string()))?, + ) + .map_err(crate::error::AppError::Message)?; + form.claude_inactive = view.inactive; + form.capture_initial_snapshot(); + form.claude_editor = Some(crate::services::provider::claude_editor::EditorSave { + base: if form.mode.is_edit() { + view.settings + } else { + view.base_for_new + }, + draft: None, + on_conflict: Default::default(), + }); + Ok(()) + })(); + if let Err(error) = result { + self.form = None; + self.push_toast(error.to_string(), ToastKind::Error); + } + } + pub(crate) fn open_provider_add_form(&mut self, data: &UiData) { self.filter.active = false; self.overlay = Overlay::None; @@ -1577,6 +1633,7 @@ impl App { &data.config.common_snippet, ), )); + self.initialize_claude_editor(data); self.maybe_show_common_config_notice(); } @@ -1596,6 +1653,7 @@ impl App { &data.config.common_snippet, ), )); + self.initialize_claude_editor(data); self.maybe_show_common_config_notice(); } @@ -1616,6 +1674,7 @@ impl App { &data.existing_provider_ids(), ), )); + self.initialize_claude_editor(data); self.maybe_show_common_config_notice(); } diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index 5178ec77..295c2603 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -660,6 +660,28 @@ impl App { } Action::None } + ProviderAddField::ClaudeInactiveFields => { + if matches!(key.code, KeyCode::Enter) { + if let Some(FormState::ProviderAdd(form)) = self.form.as_ref() { + let fields = form + .claude_inactive + .iter() + .map(|field| format!("{}: {}", field.path.join("."), field.value)) + .collect::>() + .join("\n"); + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: crate::t!( + "Copy inactive fields into editor?", + "将未应用字段复制到编辑器?" + ) + .to_string(), + message: fields, + action: ConfirmAction::ClaudeCopyInactive, + }); + } + } + Action::None + } ProviderAddField::CommonSnippet => { if matches!(key.code, KeyCode::Enter) { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index 3fe35901..02963897 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -77,10 +77,44 @@ impl App { return None; }; let confirm = confirm.clone(); + if let ConfirmAction::ClaudeEditorConflict { submit, content } = confirm.action { + use crate::services::provider::claude_editor::ConflictPolicy; + let policy = match key.code { + KeyCode::Char('m') | KeyCode::Char('M') => ConflictPolicy::KeepMine, + KeyCode::Char('t') | KeyCode::Char('T') => ConflictPolicy::KeepTheirs, + KeyCode::Esc => { + self.close_overlay(); + return Some(Action::None); + } + _ => return Some(Action::None), + }; + if let Some(FormState::ProviderAdd(form)) = self.form.as_mut() { + if let Some(editor) = form.claude_editor.as_mut() { + editor.on_conflict = policy; + } + } + self.close_overlay(); + return Some(Action::EditorSubmit { submit, content }); + } let action = match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { let action = match &confirm.action { + ConfirmAction::ClaudeEditorConflict { .. } => Action::None, + ConfirmAction::ClaudeCopyInactive => { + if let Some(FormState::ProviderAdd(form)) = self.form.as_mut() { + let mut value = form.to_provider_json_value(); + crate::services::provider::claude_editor::copy_inactive_fields( + &mut value["settingsConfig"], + &form.claude_inactive, + ); + if let Ok(provider) = serde_json::from_value(value) { + form.apply_provider_json_to_fields(&provider); + form.claude_inactive.clear(); + } + } + Action::None + } ConfirmAction::Quit => Action::Quit, ConfirmAction::ProviderDelete { id } => { Action::ProviderDelete { id: id.clone() } diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 64bc3bc0..3e0e00bd 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -1133,7 +1133,7 @@ mod tests { #[test] fn config_e_key_opens_common_snippet_editor_when_selected() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Config; app.focus = Focus::Content; app.config_idx = ConfigItem::ALL @@ -1148,7 +1148,7 @@ mod tests { Some(( EditorKind::Json, EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, + app_type: AppType::Gemini, source: CommonSnippetViewSource::Global } )) @@ -4771,7 +4771,7 @@ mod tests { #[test] fn config_common_snippet_opens_editor_directly() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Config; app.focus = Focus::Content; app.config_idx = ConfigItem::ALL @@ -4786,7 +4786,7 @@ mod tests { Some(( EditorKind::Json, EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, + app_type: AppType::Gemini, source: CommonSnippetViewSource::Global } )) @@ -4911,34 +4911,9 @@ mod tests { assert_eq!(section, super::super::form::CodexPreviewSection::Auth); } - #[test] - fn provider_add_form_common_snippet_row_opens_json_editor_claude() { - let mut app = App::new(Some(AppType::Claude)); - app.route = Route::Providers; - app.focus = Focus::Content; - - let data = data(); - app.on_key(key(KeyCode::Char('a')), &data); - apply_current_provider_template(&mut app, &data); // apply template -> fields - select_provider_common_snippet_row(&mut app); - - let action = app.on_key(key(KeyCode::Enter), &data); - assert!(matches!(action, Action::None)); - assert!(matches!( - app.editor.as_ref().map(|e| (&e.kind, &e.submit)), - Some(( - EditorKind::Json, - EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, - source: CommonSnippetViewSource::ProviderForm - } - )) - )); - } - #[test] fn common_snippet_editor_function_keys_trigger_format_and_extract() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; @@ -4951,24 +4926,24 @@ mod tests { assert!(matches!( app.on_key(key(KeyCode::F(2)), &data), Action::EditorFormatCommonSnippet { - app_type: AppType::Claude + app_type: AppType::Gemini } )); assert!(matches!( app.on_key(key(KeyCode::F(4)), &data), Action::EditorExtractCommonSnippet { - app_type: AppType::Claude + app_type: AppType::Gemini } )); } #[test] fn global_common_snippet_editor_only_formats_not_extracts() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Config; app.focus = Focus::Content; app.open_common_snippet_editor( - AppType::Claude, + AppType::Gemini, &data(), Some(r#"{"env":{"COMMON_FLAG":"1"}}"#.to_string()), CommonSnippetViewSource::Global, @@ -4977,7 +4952,7 @@ mod tests { assert!(matches!( app.on_key(key(KeyCode::F(2)), &data()), Action::EditorFormatCommonSnippet { - app_type: AppType::Claude + app_type: AppType::Gemini } )); @@ -5041,7 +5016,7 @@ mod tests { #[test] fn provider_add_form_first_open_shows_common_config_notice_for_supported_apps() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; app.common_config_notice_confirmed = false; @@ -5060,7 +5035,7 @@ mod tests { #[test] fn provider_add_form_skips_common_config_notice_after_confirmed() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; @@ -5153,12 +5128,20 @@ mod tests { json!({ "env": { "ANTHROPIC_BASE_URL": "https://example.com", - "ANTHROPIC_AUTH_TOKEN": "sk-demo" + "ANTHROPIC_AUTH_TOKEN": "sk-demo", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "120000", + "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "102000" } }), None, ); original.sort_index = Some(4); + manager.current = original.id.clone(); + crate::config::write_json_file( + &crate::config::get_claude_settings_path(), + &original.settings_config, + ) + .expect("write current provider live config"); manager.providers.insert(original.id.clone(), original); let mut existing_copy = Provider::with_id( @@ -5201,6 +5184,15 @@ mod tests { "sk-demo" ); + assert_eq!( + copied.provider.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], + "120000" + ); + assert_eq!( + copied.provider.settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"], + "102000" + ); + let shifted = refreshed .providers .rows @@ -5212,7 +5204,7 @@ mod tests { #[test] fn provider_copy_confirm_preserves_first_common_config_notice() { - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; app.common_config_notice_confirmed = false; @@ -13610,42 +13602,6 @@ mod tests { /// The common-snippet refresh moved into the picker's Enter arm; its /// failure path must still surface as a Warning toast. - #[test] - fn provider_template_picker_enter_warns_on_malformed_common_snippet() { - let mut form = ProviderAddFormState::new(AppType::Claude); - form.include_common_config = true; - - let mut app = App::new(Some(AppType::Claude)); - app.route = Route::Providers; - app.focus = Focus::Content; - app.form = Some(FormState::ProviderAdd(form)); - - let mut data = data(); - data.config.common_snippet = r#"{"env": {"#.to_string(); - app.on_key(key(KeyCode::Enter), &data); - assert!(matches!( - app.overlay, - Overlay::ProviderTemplatePicker { .. } - )); - - // Claude Official, so the apply path is a real template, not a reset. - app.on_key(key(KeyCode::Down), &data); - let action = app.on_key(key(KeyCode::Enter), &data); - assert!(matches!(action, Action::None)); - assert!(matches!(app.overlay, Overlay::None)); - assert_eq!(provider_form_focus(&app), FormFocus::Fields); - assert!( - matches!( - app.toast.as_ref(), - Some(Toast { - kind: ToastKind::Warning, - .. - }) - ), - "expected a Warning toast, got: {:?}", - app.toast - ); - } #[test] fn provider_template_picker_closes_when_form_is_gone() { @@ -16534,7 +16490,7 @@ mod tests { assert!(matches!( claude_app.form, Some(FormState::ProviderAdd(ref form)) - if !form.claude_teammates && !form.include_common_config + if form.claude_teammates && !form.include_common_config )); let codex_snippet = "[features]\ngoals = true\n"; @@ -16572,44 +16528,6 @@ mod tests { )); } - #[test] - fn provider_template_enter_preserves_effective_common_quick_config() { - let cases = [ - ( - AppType::Claude, - r#"{"env":{"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS":"1"}}"#, - ), - (AppType::Codex, "[features]\ngoals = true\n"), - ]; - - for (app_type, common_snippet) in cases { - let mut app = App::new(Some(app_type.clone())); - app.route = Route::Providers; - app.focus = Focus::Content; - app.form = Some(FormState::ProviderAdd( - ProviderAddFormState::new_with_common_snippet(app_type.clone(), common_snippet), - )); - let mut data = data(); - data.config.common_snippet = common_snippet.to_string(); - - // Go through the picker so the apply + common-snippet refresh path - // is genuinely exercised, not just the constructor's seed state. - apply_current_provider_template(&mut app, &data); - assert!(matches!(app.overlay, Overlay::None)); - - let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { - panic!("expected provider form for {app_type:?}"); - }; - assert_eq!(form.focus, FormFocus::Fields); - assert!(form.include_common_config); - match app_type { - AppType::Claude => assert!(form.claude_teammates), - AppType::Codex => assert!(form.codex_goal_mode), - _ => unreachable!(), - } - } - } - #[test] fn malformed_claude_common_snippet_allows_quick_edit_and_detaches_sharing() { let mut form = ProviderAddFormState::new(AppType::Claude); @@ -20939,3 +20857,114 @@ mod tests { )); } } + +#[test] +#[serial_test::serial] +fn claude_editor_open_projects_live_globals_and_inactive_fields() { + let home = tempfile::TempDir::new().unwrap(); + let _env = crate::test_support::TestEnvGuard::isolated(home.path()); + let live = serde_json::json!({"env":{"ANTHROPIC_AUTH_TOKEN":"current-key","GLOBAL_FLAG":"keep"},"language":"chinese"}); + crate::config::write_json_file(&crate::config::get_claude_settings_path(), &live).unwrap(); + let mut app = App::new(Some(AppType::Claude)); + let data = UiData::default(); + let row = super::data::ProviderRow { + id: "other".into(), + provider: crate::provider::Provider::with_id( + "other".into(), + "Other".into(), + serde_json::json!({"env":{"ANTHROPIC_AUTH_TOKEN":"other-key"},"language":"legacy","legacyOnly":true}), + None, + ), + api_url: None, + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: None, + default_model_id: None, + }; + app.open_provider_edit_form(&row, &data); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("form should open") + }; + assert_eq!( + form.claude_editor.as_ref().unwrap().base["language"], + "chinese" + ); + assert_eq!( + form.to_provider_json_value()["settingsConfig"]["env"]["ANTHROPIC_AUTH_TOKEN"], + "other-key" + ); + assert_eq!(form.claude_inactive.len(), 2); + assert!(form.to_provider_json_value()["settingsConfig"] + .get("legacyOnly") + .is_none()); + assert!(!form.has_unsaved_changes()); + assert!(!visible_config_items(&app.filter, &app.app_type).contains(&ConfigItem::CommonSnippet)); + let before = std::fs::read(crate::config::get_claude_settings_path()).unwrap(); + app.overlay = Overlay::Confirm(ConfirmOverlay { + title: String::new(), + message: String::new(), + action: ConfirmAction::ClaudeCopyInactive, + }); + app.on_key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ), + &data, + ); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("form should remain") + }; + assert_eq!( + form.to_provider_json_value()["settingsConfig"]["language"], + "legacy" + ); + assert_eq!( + form.claude_editor.as_ref().unwrap().base["language"], + "chinese" + ); + assert_eq!( + std::fs::read(crate::config::get_claude_settings_path()).unwrap(), + before + ); +} + +#[test] +fn claude_editor_conflict_choices_preserve_the_draft() { + use crate::services::provider::claude_editor::{ConflictPolicy, EditorSave}; + for (key, policy) in [ + ('m', ConflictPolicy::KeepMine), + ('t', ConflictPolicy::KeepTheirs), + ] { + let mut app = App::new(Some(AppType::Claude)); + let mut form = ProviderAddFormState::new(AppType::Claude); + form.claude_editor = Some(EditorSave { + base: serde_json::json!({}), + draft: None, + on_conflict: ConflictPolicy::Refuse, + }); + app.form = Some(FormState::ProviderAdd(form)); + app.overlay = Overlay::Confirm(ConfirmOverlay { + title: String::new(), + message: String::new(), + action: ConfirmAction::ClaudeEditorConflict { + submit: EditorSubmit::ProviderAdd, + content: "draft".into(), + }, + }); + let action = app.on_key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(key), + crossterm::event::KeyModifiers::NONE, + ), + &UiData::default(), + ); + assert!(matches!(action,Action::EditorSubmit{content,..} if content=="draft")); + let Some(FormState::ProviderAdd(form)) = app.form else { + panic!("form should remain") + }; + assert_eq!(form.claude_editor.unwrap().on_conflict, policy); + } +} diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index 846f434f..632327c2 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -4347,6 +4347,11 @@ impl Toast { #[derive(Debug, Clone)] pub enum ConfirmAction { + ClaudeCopyInactive, + ClaudeEditorConflict { + submit: EditorSubmit, + content: String, + }, Quit, ProviderDelete { id: String, diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index 1d51acac..1d20232f 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -318,6 +318,7 @@ pub enum ProviderAddField { HermesRateLimitDelay, CommonConfigDivider, CommonSnippet, + ClaudeInactiveFields, IncludeCommonConfig, UsageQueryDivider, UsageQuery, @@ -541,6 +542,8 @@ pub struct ProviderAddFormState { pub app_type: AppType, pub mode: FormMode, pub copy_source_id: Option, + pub(crate) claude_inactive: Vec, + pub(crate) claude_editor: Option, pub focus: FormFocus, pub page: ProviderFormPage, pub template_idx: usize, diff --git a/src-tauri/src/cli/tui/form/codex_config.rs b/src-tauri/src/cli/tui/form/codex_config.rs index ba6fe417..11c98c9b 100644 --- a/src-tauri/src/cli/tui/form/codex_config.rs +++ b/src-tauri/src/cli/tui/form/codex_config.rs @@ -61,17 +61,8 @@ pub(crate) fn update_codex_config_snippet( base_url: &str, model: &str, wire_api: CodexWireApi, - requires_openai_auth: bool, - env_key: &str, ) -> String { - crate::codex_config::update_codex_config_snippet( - original, - base_url, - model, - wire_api.as_str(), - requires_openai_auth, - env_key, - ) + crate::codex_config::update_codex_config_snippet(original, base_url, model, wire_api.as_str()) } pub(crate) fn build_codex_third_party_config_toml( @@ -88,6 +79,75 @@ pub(crate) fn build_codex_third_party_config_toml( ) } +// Match upstream useCodexConfigState: stored auth wins; bearer is a display fallback. +pub(super) fn codex_api_key(settings: &serde_json::Value) -> String { + settings + .get("auth") + .and_then(|auth| auth.get("OPENAI_API_KEY")) + .and_then(serde_json::Value::as_str) + .filter(|key| !key.is_empty()) + .map(str::to_string) + .or_else(|| { + let doc = settings + .get("config")? + .as_str()? + .parse::() + .ok()?; + let token = |item: &toml_edit::Item| { + item.as_str() + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string) + }; + doc.get("model_provider") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty() && !crate::live::project::codex::is_built_in_id(id)) + .and_then(|id| { + doc.get("model_providers")? + .get(id)? + .get("experimental_bearer_token") + }) + .and_then(token) + .or_else(|| doc.get("experimental_bearer_token").and_then(token)) + }) + .unwrap_or_default() +} + +// Match upstream updateCodexExperimentalBearerToken: update only an existing +// active-provider token, falling back to the root token. Never add a token. +pub(super) fn update_existing_codex_bearer(config: &str, key: &str) -> String { + let Ok(mut doc) = config.parse::() else { + return config.to_string(); + }; + let provider = doc + .get("model_provider") + .and_then(|item| item.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty() && !crate::live::project::codex::is_built_in_id(id)) + .filter(|id| { + doc.get("model_providers") + .and_then(|item| item.get(*id)) + .and_then(|item| item.get("experimental_bearer_token")) + .is_some() + }) + .map(str::to_string); + let table = match provider { + Some(id) => doc["model_providers"][&id].as_table_like_mut(), + None => Some(doc.as_table_mut() as &mut dyn toml_edit::TableLike), + }; + if let Some(table) = table { + if let Some(token) = table.get_mut("experimental_bearer_token") { + if key.trim().is_empty() { + table.remove("experimental_bearer_token"); + } else { + *token = toml_edit::value(key.trim()); + } + } + } + doc.to_string() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index de4dd5e9..a192a4df 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -56,26 +56,7 @@ pub(crate) fn normalize_gemini_common_config_for_form(snippet: &str) -> Result Value { - match value { - Value::Object(entries) => Value::Object( - entries - .into_iter() - .filter(|(key, _)| !CLAUDE_COMMON_CONFIG_FORBIDDEN_KEYS.contains(&key.as_str())) - .map(|(key, value)| (key, sanitize_claude_common_config_value_for_form(value))) - .collect(), - ), - Value::Array(items) => Value::Array( - items - .into_iter() - .map(sanitize_claude_common_config_value_for_form) - .collect(), - ), - value => value, - } -} +const COMMON_CONFIG_FORBIDDEN_KEYS: [&str; 3] = ["__proto__", "constructor", "prototype"]; fn pi_native_string_field_changed( original: Option<&serde_json::Map>, @@ -88,85 +69,12 @@ fn pi_native_string_field_changed( } } -pub(crate) fn sanitize_claude_common_config_for_form(snippet: &str) -> Result { - let value = serde_json::from_str::(snippet) - .map_err(|error| texts::common_config_snippet_invalid_json(&error.to_string()))?; - if !value.is_object() { - return Err(texts::common_config_snippet_not_object().to_string()); - } - Ok(sanitize_claude_common_config_value_for_form(value)) -} - -fn json_value_is_subset_for_form(target: &Value, source: &Value) -> bool { - match source { - Value::Object(source_map) => { - let Some(target_map) = target.as_object() else { - return false; - }; - source_map.iter().all(|(key, source_value)| { - target_map.get(key).is_some_and(|target_value| { - json_value_is_subset_for_form(target_value, source_value) - }) - }) - } - Value::Array(source_array) => { - let Some(target_array) = target.as_array() else { - return false; - }; - target_array.len() == source_array.len() - && target_array - .iter() - .zip(source_array) - .all(|(target_value, source_value)| { - json_value_is_subset_for_form(target_value, source_value) - }) - } - _ => target == source, - } -} - -fn json_deep_remove_for_form(target: &mut Value, source: &Value) { - let (Some(target_map), Some(source_map)) = (target.as_object_mut(), source.as_object()) else { - return; - }; - - for (key, source_value) in source_map { - let mut remove_key = false; - if let Some(target_value) = target_map.get_mut(key) { - if source_value.is_object() && target_value.is_object() { - json_deep_remove_for_form(target_value, source_value); - remove_key = target_value - .as_object() - .is_some_and(serde_json::Map::is_empty); - } else if json_value_is_subset_for_form(target_value, source_value) { - // Upstream's form utility treats arrays as atomic values: it - // removes the field only when length, order, and values all - // match, instead of deleting matching array members. - remove_key = true; - } - } - if remove_key { - target_map.remove(key); - } - } -} - -pub(crate) fn claude_settings_contain_common_config_for_form( - settings: &Value, - common_snippet: &str, -) -> bool { - sanitize_claude_common_config_for_form(common_snippet) - .ok() - .filter(|source| source.as_object().is_some_and(|object| !object.is_empty())) - .is_some_and(|source| json_value_is_subset_for_form(settings, &source)) -} - fn sanitize_toml_common_config_value_for_form(value: toml::Value) -> toml::Value { match value { toml::Value::Table(entries) => toml::Value::Table( entries .into_iter() - .filter(|(key, _)| !CLAUDE_COMMON_CONFIG_FORBIDDEN_KEYS.contains(&key.as_str())) + .filter(|(key, _)| !COMMON_CONFIG_FORBIDDEN_KEYS.contains(&key.as_str())) .map(|(key, value)| (key, sanitize_toml_common_config_value_for_form(value))) .collect(), ), @@ -238,6 +146,13 @@ pub(crate) fn codex_settings_contain_common_config_for_form( } impl ProviderAddFormState { + fn codex_api_key_changed(&self) -> bool { + self.codex_api_key.value + != super::codex_config::codex_api_key( + self.extra.get("settingsConfig").unwrap_or(&Value::Null), + ) + } + pub(crate) fn existing_codex_config_text(&self) -> &str { self.extra .get("settingsConfig") @@ -342,14 +257,14 @@ impl ProviderAddFormState { } else { existing_config.to_string() }; - let mut config = update_codex_config_snippet( - &base_config, - base_url, - model, - self.codex_wire_api, - self.codex_requires_openai_auth, - self.codex_env_key.value.trim(), - ); + let mut config = + update_codex_config_snippet(&base_config, base_url, model, self.codex_wire_api); + if self.codex_api_key_changed() { + config = super::codex_config::update_existing_codex_bearer( + &config, + &self.codex_api_key.value, + ); + } if self.codex_goal_mode_touched { config = crate::codex_config::set_codex_goal_mode(&config, self.codex_goal_mode); } @@ -505,30 +420,32 @@ impl ProviderAddFormState { settings_obj.remove("modelCatalog"); } - let api_key = self.codex_api_key.value.trim(); - if api_key.is_empty() { - if let Some(auth_obj) = settings_obj - .get_mut("auth") - .and_then(|value| value.as_object_mut()) - { - auth_obj.remove("OPENAI_API_KEY"); - if auth_obj.is_empty() { + if self.codex_api_key_changed() { + let api_key = self.codex_api_key.value.trim(); + if api_key.is_empty() { + if let Some(auth_obj) = settings_obj + .get_mut("auth") + .and_then(|value| value.as_object_mut()) + { + auth_obj.remove("OPENAI_API_KEY"); + if auth_obj.is_empty() { + settings_obj.remove("auth"); + } + } else { settings_obj.remove("auth"); } } else { - settings_obj.remove("auth"); - } - } else { - let auth_value = settings_obj - .entry("auth".to_string()) - .or_insert_with(|| json!({})); - if !auth_value.is_object() { - *auth_value = json!({}); + let auth_value = settings_obj + .entry("auth".to_string()) + .or_insert_with(|| json!({})); + if !auth_value.is_object() { + *auth_value = json!({}); + } + let auth_obj = auth_value + .as_object_mut() + .expect("auth must be a JSON object"); + auth_obj.insert("OPENAI_API_KEY".to_string(), json!(api_key)); } - let auth_obj = auth_value - .as_object_mut() - .expect("auth must be a JSON object"); - auth_obj.insert("OPENAI_API_KEY".to_string(), json!(api_key)); } } } @@ -899,15 +816,6 @@ impl ProviderAddFormState { ProviderService::validate_common_config_snippet_for_preview(&self.app_type, snippet) .map_err(|e| e.to_string())?; - let normalized_claude_snippet = if matches!(self.app_type, AppType::Claude) { - Some( - serde_json::to_string(&sanitize_claude_common_config_for_form(snippet)?) - .map_err(|error| texts::failed_to_serialize_json(&error.to_string()))?, - ) - } else { - None - }; - let snippet = normalized_claude_snippet.as_deref().unwrap_or(snippet); let settings = provider_value .get("settingsConfig") .cloned() @@ -995,7 +903,19 @@ impl ProviderAddFormState { if should_write_common_config_meta { meta_obj.insert( "commonConfigEnabled".to_string(), - json!(self.include_common_config), + if matches!(self.app_type, AppType::Claude) { + if self.mode.is_edit() { + self.extra + .pointer("/meta/commonConfigEnabled") + .or_else(|| self.extra.pointer("/meta/applyCommonConfig")) + .cloned() + .unwrap_or(json!(true)) + } else { + json!(true) + } + } else { + json!(self.include_common_config) + }, ); } else { meta_obj.remove("commonConfigEnabled"); @@ -1265,7 +1185,8 @@ impl ProviderAddFormState { } fn should_write_common_config_meta(&self) -> bool { - ProviderAddFormState::supports_common_config(&self.app_type) + (ProviderAddFormState::supports_common_config(&self.app_type) + || matches!(self.app_type, AppType::Claude)) && (!self.mode.is_edit() || self.include_common_config_touched || self.has_common_config_meta()) @@ -1491,10 +1412,6 @@ pub(crate) fn strip_common_config_from_settings( } match app_type { - AppType::Claude => { - let source = sanitize_claude_common_config_for_form(snippet)?; - json_deep_remove_for_form(settings_value, &source); - } AppType::Gemini => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, @@ -1503,7 +1420,8 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => {} + AppType::Claude | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { + } AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 62f5220f..481d7f27 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -11,9 +11,8 @@ use super::super::text_edit::{ }; use super::provider_json::{ - claude_settings_contain_common_config_for_form, codex_settings_contain_common_config_for_form, - merge_json_values, normalize_gemini_common_config_for_form, - sanitize_claude_common_config_for_form, should_hide_provider_field, + codex_settings_contain_common_config_for_form, merge_json_values, + normalize_gemini_common_config_for_form, should_hide_provider_field, strip_common_config_from_settings, }; use super::provider_state_loading::populate_form_from_provider; @@ -108,16 +107,8 @@ impl ProviderAddFormState { } pub fn new_with_common_snippet(app_type: AppType, common_snippet: &str) -> Self { - let include_common_config = if matches!(app_type, AppType::Claude) { - // The upstream new-provider effect checks the raw object's key - // count before its merge helper skips prototype-pollution keys. - serde_json::from_str::(common_snippet.trim()) - .ok() - .and_then(|value| value.as_object().cloned()) - .is_some_and(|object| !object.is_empty()) - } else { - Self::snippet_has_effective_common_config(&app_type, common_snippet) - }; + let include_common_config = + Self::snippet_has_effective_common_config(&app_type, common_snippet); let is_codex = matches!(app_type, AppType::Codex); let is_gemini = matches!(app_type, AppType::Gemini); let openclaw_api_default = match app_type { @@ -135,6 +126,8 @@ impl ProviderAddFormState { app_type, mode: FormMode::Add, copy_source_id: None, + claude_inactive: Vec::new(), + claude_editor: None, // The template is a field row now, and `fields()` puts it first in // Add mode, so the form opens with it selected. focus: FormFocus::Fields, @@ -350,7 +343,7 @@ impl ProviderAddFormState { } pub fn supports_common_config(app_type: &AppType) -> bool { - matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) + matches!(app_type, AppType::Codex | AppType::Gemini) } pub fn snippet_has_effective_common_config(app_type: &AppType, common_snippet: &str) -> bool { @@ -368,15 +361,15 @@ impl ProviderAddFormState { .parse::() .ok() .is_some_and(|doc| doc.as_table().iter().next().is_some()), - AppType::Claude => sanitize_claude_common_config_for_form(snippet) - .ok() - .and_then(|value| value.as_object().cloned()) - .is_some_and(|obj| !obj.is_empty()), AppType::Gemini => normalize_gemini_common_config_for_form(snippet) .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|env| !env.is_empty()), - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::Claude + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => false, } } @@ -412,9 +405,6 @@ impl ProviderAddFormState { } match app_type { - AppType::Claude => { - claude_settings_contain_common_config_for_form(settings, common_snippet) - } AppType::Codex => { codex_settings_contain_common_config_for_form(settings, common_snippet) } @@ -432,7 +422,11 @@ impl ProviderAddFormState { app_type, settings, &snippet, ) } - AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => false, + AppType::Claude + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => false, } } @@ -448,7 +442,7 @@ impl ProviderAddFormState { } } - fn capture_initial_snapshot(&mut self) { + pub(crate) fn capture_initial_snapshot(&mut self) { self.initial_snapshot = self.to_provider_json_value(); } @@ -584,6 +578,9 @@ impl ProviderAddFormState { } } + if !self.claude_inactive.is_empty() { + fields.push(ProviderAddField::ClaudeInactiveFields); + } if Self::supports_common_config(&self.app_type) { fields.push(ProviderAddField::CommonConfigDivider); fields.push(ProviderAddField::CommonSnippet); @@ -598,8 +595,12 @@ impl ProviderAddFormState { AppType::Codex => fields.push(ProviderAddField::CodexQuickConfig), _ => {} } - fields.push(ProviderAddField::UsageQueryDivider); - fields.push(ProviderAddField::UsageQuery); + // Managed Codex OAuth uses the bound account's native quota, like the + // desktop provider card; generic usage templates do not apply. + if !self.is_claude_codex_oauth_provider() { + fields.push(ProviderAddField::UsageQueryDivider); + fields.push(ProviderAddField::UsageQuery); + } // Add mode leads with the template picker row plus its divider, so the // template reads as its own group above the real fields. Inserted last @@ -695,7 +696,8 @@ impl ProviderAddFormState { } pub fn usage_query_script_validation_error(&self) -> Option<&'static str> { - if !self.usage_query_enabled + if self.is_claude_codex_oauth_provider() + || !self.usage_query_enabled || matches!( self.usage_query_template, UsageQueryTemplate::GitHubCopilot @@ -776,6 +778,7 @@ impl ProviderAddFormState { | ProviderAddField::HermesModels | ProviderAddField::HermesAdvancedDivider | ProviderAddField::CommonConfigDivider + | ProviderAddField::ClaudeInactiveFields | ProviderAddField::CommonSnippet | ProviderAddField::IncludeCommonConfig | ProviderAddField::UsageQueryDivider @@ -847,6 +850,7 @@ impl ProviderAddFormState { | ProviderAddField::HermesModels | ProviderAddField::HermesAdvancedDivider | ProviderAddField::CommonConfigDivider + | ProviderAddField::ClaudeInactiveFields | ProviderAddField::CommonSnippet | ProviderAddField::IncludeCommonConfig | ProviderAddField::UsageQueryDivider @@ -1168,7 +1172,7 @@ impl ProviderAddFormState { &mut self, common_snippet: &str, ) -> Result<(), String> { - if !self.include_common_config { + if !Self::supports_common_config(&self.app_type) || !self.include_common_config { return Ok(()); } @@ -1536,6 +1540,9 @@ impl ProviderAddFormState { } pub fn open_usage_query_page(&mut self) { + if self.is_claude_codex_oauth_provider() { + return; + } self.refresh_usage_query_provider_kind(); self.refresh_default_usage_query_template(); self.page = ProviderFormPage::UsageQuery; @@ -2716,6 +2723,12 @@ impl ProviderAddFormState { next.include_common_config_touched = previous_include_common_config_touched; } + if !Self::supports_common_config(&self.app_type) { + next.include_common_config = false; + next.include_common_config_touched = false; + } + next.claude_inactive = self.claude_inactive.clone(); + next.claude_editor = self.claude_editor.clone(); next.mode = previous_mode.clone(); next.copy_source_id = previous_copy_source_id; next.focus = previous_focus; @@ -2825,6 +2838,12 @@ impl ProviderAddFormState { next.include_common_config_touched = previous_include_common_config_touched; } + if !Self::supports_common_config(&self.app_type) { + next.include_common_config = false; + next.include_common_config_touched = false; + } + next.claude_inactive = self.claude_inactive.clone(); + next.claude_editor = self.claude_editor.clone(); next.mode = previous_mode.clone(); next.copy_source_id = previous_copy_source_id; next.focus = previous_focus; @@ -2882,6 +2901,9 @@ impl ProviderAddFormState { } pub fn toggle_include_common_config(&mut self, common_snippet: &str) -> Result<(), String> { + if !Self::supports_common_config(&self.app_type) { + return Ok(()); + } let next_enabled = !self.include_common_config; if next_enabled { Self::validate_common_config_snippet_for_form(&self.app_type, common_snippet)?; @@ -2943,6 +2965,9 @@ impl ProviderAddFormState { previous_snippet: &str, next_snippet: &str, ) -> Result<(), String> { + if !Self::supports_common_config(&self.app_type) { + return Ok(()); + } if !self.include_common_config { if !next_snippet.trim().is_empty() { Self::validate_common_config_snippet_for_form(&self.app_type, next_snippet)?; @@ -2970,34 +2995,17 @@ impl ProviderAddFormState { Self::validate_common_config_snippet_for_form(&self.app_type, next_snippet)?; } - if let Err(previous_error) = - Self::validate_common_config_snippet_for_form(&self.app_type, previous_snippet) + if Self::validate_common_config_snippet_for_form(&self.app_type, previous_snippet).is_err() { - if matches!(self.app_type, AppType::Codex | AppType::Gemini) { - // Upstream skips removal when the previous Codex/Gemini - // snippet cannot be parsed, then applies the valid replacement - // (or simply disables sharing when the snippet was cleared). - let mut next = self.clone(); - next.include_common_config = false; - next.include_common_config_touched = true; - if !next_snippet.trim().is_empty() { - next.include_common_config = true; - next.refresh_quick_config_from_common_snippet(next_snippet)?; - } - *self = next; - return Ok(()); + let mut next = self.clone(); + next.include_common_config = false; + next.include_common_config_touched = true; + if !next_snippet.trim().is_empty() { + next.include_common_config = true; + next.refresh_quick_config_from_common_snippet(next_snippet)?; } - - // Claude ignores the malformed-old removal error when clearing, - // but a non-empty replacement reports it and its membership effect - // turns sharing off against the newly saved snippet. - self.include_common_config = false; - self.include_common_config_touched = true; - return if next_snippet.trim().is_empty() { - Ok(()) - } else { - Err(previous_error) - }; + *self = next; + return Ok(()); } let previous = self.clone(); diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index aa74b454..0a1376d4 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -251,15 +251,9 @@ fn populate_codex_form(form: &mut ProviderAddFormState, provider: &Provider) { form.codex_remote_compaction = crate::codex_config::is_codex_remote_compaction_enabled(config); } - if let Some(auth) = provider - .settings_config - .get("auth") - .and_then(|value| value.as_object()) - { - if let Some(key) = auth.get("OPENAI_API_KEY").and_then(|value| value.as_str()) { - form.codex_api_key.set(key); - } - } + form.codex_api_key.set(super::codex_config::codex_api_key( + &provider.settings_config, + )); form.claude_api_format = parse_codex_api_format(provider); form.claude_api_key_field = provider .meta diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 3e00f482..842b3aaf 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -4,9 +4,9 @@ use crate::provider_preset_builtin::{ builtin_provider_preset_value, with_opencode_go_usage_script, BuiltinProviderPresetId, }; use crate::provider_preset_models::{ - codex_oauth_claude_env, sponsor_hermes_models, sponsor_model_family, sponsor_openclaw_models, - sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, CODEX_OAUTH_FAST_MODEL, - GEMINI_DEFAULT_MODEL, + codex_oauth_claude_env, sponsor_codex_model, sponsor_hermes_models, sponsor_model_family, + sponsor_openclaw_models, sponsor_opencode_settings, SponsorModelFamily, CODEX_DEFAULT_MODEL, + CODEX_OAUTH_FAST_MODEL, GEMINI_DEFAULT_MODEL, }; use crate::provider_preset_pi::{ PiProviderPreset, PI_BUILTIN_PROVIDER_PRESETS, PI_SPONSOR_PROVIDER_PRESETS, @@ -471,6 +471,34 @@ impl ProviderAddFormState { } pub fn apply_template(&mut self, idx: usize, existing_ids: &[String]) { + let before = self.claude_editor.as_ref().map(|_| self.clone()); + self.apply_template_fields(idx, existing_ids); + if let Some(before) = before { + use crate::live::project::claude::{project_onto, ClaudeProjection}; + let previous = before.to_provider_json_value()["settingsConfig"].clone(); + let mut value = self.to_provider_json_value(); + let result = project_onto( + &crate::config::get_claude_settings_path(), + &previous, + Some(&ClaudeProjection::of(&previous)), + &ClaudeProjection::of(&value["settingsConfig"]), + ) + .map_err(|error| error.to_string()) + .and_then(|settings| { + value["settingsConfig"] = settings; + self.apply_provider_json_value_to_fields(value) + }); + if let Err(message) = result { + *self = before; + self.field_errors.push(super::InlineFieldError { + field: super::ProviderAddField::Template, + message, + }); + } + } + } + + fn apply_template_fields(&mut self, idx: usize, existing_ids: &[String]) { let builtin_defs = provider_builtin_template_defs(&self.app_type); let sponsor_presets = provider_sponsor_presets(&self.app_type); let after_sponsor_defs = provider_after_sponsor_template_defs(&self.app_type); @@ -770,6 +798,7 @@ impl ProviderAddFormState { AppType::Codex => { self.reset_codex_template_state(); self.codex_base_url.set(preset.codex_base_url); + self.codex_model.set(sponsor_codex_model(preset.id)); if preset.id == "patewayai" { self.extra["settingsConfig"] = json!({ "config": crate::codex_config::build_codex_third_party_config_toml( @@ -799,7 +828,9 @@ impl ProviderAddFormState { SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "@ai-sdk/anthropic" } - SponsorModelFamily::Gpt => "@ai-sdk/openai-compatible", + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + "@ai-sdk/openai-compatible" + } }); self.opencode_api_key.set(""); self.opencode_base_url.set(preset.opencode_base_url); @@ -829,7 +860,9 @@ impl ProviderAddFormState { SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "anthropic_messages" } - SponsorModelFamily::Gpt => HERMES_DEFAULT_API_MODE, + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + HERMES_DEFAULT_API_MODE + } } .to_string(); self.hermes_models = sponsor_hermes_models(family); @@ -850,7 +883,9 @@ impl ProviderAddFormState { SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "anthropic-messages" } - SponsorModelFamily::Gpt => OPENCLAW_DEFAULT_API_PROTOCOL, + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + OPENCLAW_DEFAULT_API_PROTOCOL + } }); self.openclaw_user_agent = false; self.openclaw_models = sponsor_openclaw_models(family); diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 645cb5d1..4aa35f65 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -43,9 +43,8 @@ fn normalize_template_provider_json(mut value: serde_json::Value) -> serde_json: if let Some(obj) = value.as_object_mut() { obj.remove("inFailoverQueue"); if let Some(meta) = obj.get_mut("meta").and_then(|value| value.as_object_mut()) { - if meta.get("commonConfigEnabled") == Some(&json!(false)) { - meta.remove("commonConfigEnabled"); - } + // Compatibility metadata is assigned on save, not part of a template. + meta.remove("commonConfigEnabled"); if meta.is_empty() { obj.remove("meta"); } @@ -366,82 +365,82 @@ fn provider_add_form_claude_builtin_presets_match_upstream_fields() { ( "DeepSeek", "https://api.deepseek.com/anthropic", - "deepseek-v4-pro", - "deepseek-v4-flash", - "deepseek-v4-pro", - "deepseek-v4-pro", + "deepseek-flash[1M]", + "deepseek-flash[1M]", + "deepseek-flash[1M]", + "deepseek-flash[1M]", ClaudeApiKeyField::AuthToken, ), ( "Zhipu GLM", "https://open.bigmodel.cn/api/anthropic", - "glm-5.1", - "glm-5.1", - "glm-5.1", - "glm-5.1", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", ClaudeApiKeyField::AuthToken, ), ( "Zhipu GLM en", "https://api.z.ai/api/anthropic", - "glm-5.1", - "glm-5.1", - "glm-5.1", - "glm-5.1", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", ClaudeApiKeyField::AuthToken, ), ( "ModelScope", "https://api-inference.modelscope.cn", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", ClaudeApiKeyField::AuthToken, ), ( "MiniMax", "https://api.minimaxi.com/anthropic", - "MiniMax-M2.7", - "MiniMax-M2.7", - "MiniMax-M2.7", - "MiniMax-M2.7", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3[1M]", ClaudeApiKeyField::AuthToken, ), ( "Xiaomi MiMo", "https://api.xiaomimimo.com/anthropic", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", ClaudeApiKeyField::AuthToken, ), ( "Xiaomi MiMo Token Plan (China)", "https://token-plan-cn.xiaomimimo.com/anthropic", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", ClaudeApiKeyField::AuthToken, ), ( "OpenCode Go", "https://opencode.ai/zen/go", - "deepseek-v4-flash", - "deepseek-v4-flash", - "deepseek-v4-flash", - "deepseek-v4-flash", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", ClaudeApiKeyField::ApiKey, ), ( "OpenRouter", "https://openrouter.ai/api", - "anthropic/claude-sonnet-5", - "anthropic/claude-haiku-4.5", - "anthropic/claude-sonnet-5", - "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-haiku-5.5", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-opus-5.5", ClaudeApiKeyField::AuthToken, ), ]; @@ -486,15 +485,15 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { "DeepSeek", "deepseek", "https://api.deepseek.com", - "deepseek-v4-flash", + "deepseek-flash", "openai_responses", - 2, + 1, ), ( "Zhipu GLM", "zhipu_glm", "https://open.bigmodel.cn/api/coding/paas/v4", - "glm-5.2", + "glm-5.3", "openai_chat", 1, ), @@ -502,7 +501,7 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { "Zhipu GLM en", "zhipu_glm_en", "https://api.z.ai/api/coding/paas/v4", - "glm-5.2", + "glm-5.3", "openai_chat", 1, ), @@ -518,23 +517,23 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { "MiniMax", "minimax", "https://api.minimaxi.com/v1", - "MiniMax-M3", + "MiniMax-M3.1-Flash", "openai_responses", - 1, + 2, ), ( "Xiaomi MiMo", "xiaomi_mimo", "https://api.xiaomimimo.com/v1", - "mimo-v2.5-pro", + "mimo-v2.6-pro", "openai_responses", - 2, + 3, ), ( "Xiaomi MiMo Token Plan (China)", "xiaomi_mimo_token_plan", "https://token-plan-cn.xiaomimimo.com/v1", - "mimo-v2.5-pro", + "mimo-v2.6-pro", "openai_responses", 2, ), @@ -542,15 +541,15 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { "OpenCode Go", "opencode_go", "https://opencode.ai/zen/go/v1", - "glm-5.2", + "glm-5.3", "openai_chat", - 6, + 5, ), ( "OpenRouter", "openrouter", "https://openrouter.ai/api/v1", - "gpt-5.6-sol", + "openai/gpt-6.1-sol", "openai_responses", 0, ), @@ -608,7 +607,7 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { "zen" ); assert_eq!( - opencode["settingsConfig"]["modelCatalog"]["models"][4]["reasoningLevels"], + opencode["settingsConfig"]["modelCatalog"]["models"][3]["reasoningLevels"], json!(["low", "high", "max"]) ); @@ -620,8 +619,48 @@ fn provider_add_form_codex_builtin_presets_match_upstream_fields() { assert_eq!(minimax_model["inputModalities"], json!(["text", "image"])); assert_eq!( minimax_model["baseInstructions"], - "You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals." + "You are Codex, a coding agent based on MiniMax-M3.1-Flash. You and the user share the same workspace and collaborate to achieve the user's goals." ); + let m3 = &minimax["settingsConfig"]["modelCatalog"]["models"][1]; + assert_eq!(m3["model"], "MiniMax-M3"); + assert_eq!(m3["reasoningLevels"], json!(["none", "high"])); + assert_eq!(m3["contextWindow"], 1_000_000); + assert_eq!(m3["inputModalities"], json!(["text", "image"])); + assert_eq!(m3["supportsParallelToolCalls"], true); +} + +#[test] +fn qiniu_and_fenno_templates_keep_distinct_models_in_cli_and_tui() { + for (label, template, model) in [ + ("Qiniu", ProviderAddTemplate::Qiniu, "gpt-6-astra"), + ("FennoAI", ProviderAddTemplate::Fenno, "gpt-6.1-sol"), + ] { + for app in [ + AppType::Codex, + AppType::OpenCode, + AppType::Hermes, + AppType::OpenClaw, + ] { + let cli = build_provider_template_seed(&app, template, &[]).expect("CLI template"); + let mut form = ProviderAddFormState::new(app.clone()); + form.apply_template(template_index_by_label(app.clone(), label), &[]); + let tui = form.to_provider_json_value(); + for settings in [&cli.settings_config, &tui["settingsConfig"]] { + match app { + AppType::Codex => { + let config: toml::Value = + settings["config"].as_str().unwrap().parse().unwrap(); + assert_eq!(config["model"].as_str(), Some(model), "{label}"); + } + AppType::OpenCode => { + assert!(settings["models"].get(model).is_some(), "{label}"); + assert_eq!(settings["models"].as_object().unwrap().len(), 1); + } + _ => assert_eq!(settings["models"][0]["id"], model, "{label}"), + } + } + } + } } #[test] @@ -730,7 +769,7 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { assert_eq!(form.name.value, "DeepSeek"); assert_eq!(form.website_url.value, "https://platform.deepseek.com"); assert_eq!(form.codex_base_url.value, "https://api.deepseek.com"); - assert_eq!(form.codex_model.value, "deepseek-v4-flash"); + assert_eq!(form.codex_model.value, "deepseek-flash"); assert_eq!(form.codex_wire_api, CodexWireApi::Responses); assert!(form.codex_requires_openai_auth); @@ -759,7 +798,7 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { .as_str() .expect("settingsConfig.config should be TOML string"); assert!(cfg.contains("model_provider = \"custom\"")); - assert!(cfg.contains("model = \"deepseek-v4-flash\"")); + assert!(cfg.contains("model = \"deepseek-flash\"")); assert!(cfg.contains("disable_response_storage = true")); assert!(cfg.contains("[model_providers.custom]")); assert!(cfg.contains("name = \"deepseek\"")); @@ -775,15 +814,10 @@ fn provider_add_form_codex_deepseek_template_matches_upstream_preset_values() { json!({ "models": [ { - "model": "deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "contextWindow": 1048576, - "reasoningLevels": ["low", "high", "max"], - }, - { - "model": "deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", + "model": "deepseek-flash", + "displayName": "DeepSeek V4.1 Flash", "contextWindow": 1048576, + "inputModalities": ["text", "image"], "reasoningLevels": ["low", "high", "max"], }, ], @@ -810,10 +844,10 @@ fn provider_add_form_codex_oauth_template_matches_upstream_contract() { form.claude_api_format, crate::cli::tui::form::ClaudeApiFormat::OpenAiResponses ); - assert_eq!(form.claude_model.value, "gpt-5.6-sol"); - assert_eq!(form.claude_haiku_model.value, "gpt-5.6-luna"); - assert_eq!(form.claude_sonnet_model.value, "gpt-5.6-sol"); - assert_eq!(form.claude_opus_model.value, "gpt-5.6-sol"); + assert_eq!(form.claude_model.value, "gpt-6.1-sol"); + assert_eq!(form.claude_haiku_model.value, "gpt-6-luna"); + assert_eq!(form.claude_sonnet_model.value, "gpt-6.1-sol"); + assert_eq!(form.claude_opus_model.value, "gpt-6.1-sol"); assert!(!form.codex_fast_mode); assert!(form.claude_hide_attribution); @@ -846,11 +880,11 @@ fn provider_add_form_codex_oauth_template_matches_upstream_contract() { ); assert_eq!( provider["settingsConfig"]["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], - "372000" + "272000" ); assert_eq!( provider["settingsConfig"]["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"], - "372000" + "272000" ); assert!( provider["settingsConfig"]["env"] @@ -1088,7 +1122,7 @@ fn provider_add_form_dds_template_codex_sets_base_url_and_partner_meta() { .as_str() .expect("settingsConfig.config should be string"); assert!(cfg.contains("base_url = \"https://www.ddshub.cc\"")); - assert!(cfg.contains("model = \"gpt-5.6-sol\"")); + assert!(cfg.contains("model = \"gpt-6.1-sol\"")); assert!(cfg.contains("wire_api = \"responses\"")); assert!(cfg.contains("requires_openai_auth = true")); let meta = provider["meta"] @@ -1184,7 +1218,7 @@ fn provider_add_form_cubence_template_gemini_sets_base_url_and_partner_meta() { ); assert_eq!( provider["settingsConfig"]["env"]["GEMINI_MODEL"], - "gemini-3.6-flash" + "gemini-3.8-flash" ); assert_eq!(provider["meta"]["isPartner"], true); assert_eq!(provider["meta"]["partnerPromotionKey"], "cubence"); @@ -1205,12 +1239,12 @@ fn provider_add_form_cubence_template_opencode_sets_base_url_and_partner_meta() "https://api.cubence.com/v1" ); assert_eq!( - provider["settingsConfig"]["models"]["claude-sonnet-5"]["name"], - "Claude Sonnet 5" + provider["settingsConfig"]["models"]["claude-sonnet-5-5"]["name"], + "Claude Sonnet 5.5" ); assert_eq!( - provider["settingsConfig"]["models"]["claude-opus-5"]["name"], - "Claude Opus 5" + provider["settingsConfig"]["models"]["claude-opus-5-5"]["name"], + "Claude Opus 5.5" ); assert_eq!(provider["meta"]["isPartner"], true); assert_eq!(provider["meta"]["partnerPromotionKey"], "cubence"); @@ -1234,11 +1268,11 @@ fn provider_add_form_cubence_template_hermes_sets_base_url_and_partner_meta() { assert_eq!( settings.get("models"), Some(&json!([ - { "id": "claude-opus-5", "name": "Claude Opus 5" }, - { "id": "claude-sonnet-5", "name": "Claude Sonnet 5" }, + { "id": "claude-opus-5-5", "name": "Claude Opus 5.5" }, + { "id": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5" }, { - "id": "claude-haiku-4-5-20251001", - "name": "Claude Haiku 4.5" + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5" }, ])) ); @@ -1264,16 +1298,22 @@ fn provider_add_form_cubence_template_openclaw_sets_base_url_and_partner_meta() provider["settingsConfig"]["models"], json!([ { - "id": "claude-opus-5", - "name": "Claude Opus 5", + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 5, "output": 25 }, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }, { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { "input": 3, "output": 15 }, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }, ]) ); @@ -1349,7 +1389,7 @@ fn provider_add_form_runapi_template_codex_sets_v1_base_url() { assert!(cfg.contains("[model_providers.custom]")); assert!(cfg.contains("name = \"RunAPI\"")); assert!(cfg.contains("base_url = \"https://runapi.co/v1\"")); - assert!(cfg.contains("model = \"gpt-5.6-sol\"")); + assert!(cfg.contains("model = \"gpt-6.1-sol\"")); assert!(cfg.contains("wire_api = \"responses\"")); assert_eq!(provider["meta"]["partnerPromotionKey"], "runapi"); } @@ -1374,13 +1414,16 @@ fn provider_add_form_runapi_template_opencode_matches_upstream_anthropic_shape() "blank OpenCode API keys should be omitted on save" ); assert_eq!( - settings["models"]["claude-sonnet-5"]["name"], - "Claude Sonnet 5" + settings["models"]["claude-sonnet-5-5"]["name"], + "Claude Sonnet 5.5" + ); + assert_eq!( + settings["models"]["claude-opus-5-5"]["name"], + "Claude Opus 5.5" ); - assert_eq!(settings["models"]["claude-opus-5"]["name"], "Claude Opus 5"); assert_eq!( - settings["models"]["claude-haiku-4-5"]["name"], - "Claude Haiku 4.5" + settings["models"]["claude-haiku-5-5"]["name"], + "Claude Haiku 5.5" ); assert_eq!(provider["meta"]["partnerPromotionKey"], "runapi"); } @@ -1406,9 +1449,9 @@ fn provider_add_form_runapi_template_hermes_matches_upstream_anthropic_shape() { assert_eq!( settings["models"], json!([ - { "id": "claude-sonnet-5", "name": "Claude Sonnet 5" }, - { "id": "claude-opus-5", "name": "Claude Opus 5" }, - { "id": "claude-haiku-4-5", "name": "Claude Haiku 4.5" }, + { "id": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5" }, + { "id": "claude-opus-5-5", "name": "Claude Opus 5.5" }, + { "id": "claude-haiku-5-5", "name": "Claude Haiku 5.5" }, ]) ); assert_eq!(provider["meta"]["partnerPromotionKey"], "runapi"); @@ -1435,19 +1478,31 @@ fn provider_add_form_runapi_template_openclaw_matches_upstream_anthropic_shape() settings["models"], json!([ { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }, { - "id": "claude-opus-5", - "name": "Claude Opus 5", + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }, { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5", - "contextWindow": 200000, + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 0.1, "output": 0.5, "cacheRead": 0.01, "cacheWrite": 0.125 }, }, ]) ); @@ -1925,7 +1980,7 @@ fn provider_add_form_claude_fields_include_model_config_entry() { } #[test] -fn provider_add_form_claude_places_quick_config_menu_below_common_config() { +fn provider_add_form_claude_places_quick_config_menu_after_model_config() { let form = ProviderAddFormState::new(AppType::Claude); let fields = form.fields(); let pos = |field: ProviderAddField| { @@ -1936,7 +1991,7 @@ fn provider_add_form_claude_places_quick_config_menu_below_common_config() { }; let advanced_divider_idx = pos(ProviderAddField::ClaudeAdvancedDivider); let model_cfg_idx = pos(ProviderAddField::ClaudeModelConfig); - let include_common_idx = pos(ProviderAddField::IncludeCommonConfig); + assert!(!fields.contains(&ProviderAddField::IncludeCommonConfig)); let quick_config_idx = pos(ProviderAddField::ClaudeQuickConfig); let usage_divider_idx = pos(ProviderAddField::UsageQueryDivider); @@ -1945,8 +2000,8 @@ fn provider_add_form_claude_places_quick_config_menu_below_common_config() { "model mapping should sit in the advanced section after the divider" ); assert!( - quick_config_idx == include_common_idx + 1, - "the quick-config menu should sit directly below the add-common-config toggle" + quick_config_idx > model_cfg_idx, + "the quick-config menu follows the model configuration" ); assert!( quick_config_idx < usage_divider_idx, @@ -2008,204 +2063,6 @@ fn provider_add_form_claude_quick_config_menu_opens_and_toggles() { assert!(matches!(form.page, super::ProviderFormPage::Main)); } -#[test] -fn provider_add_form_claude_quick_config_reflects_effective_common_config() { - use crate::provider::ProviderMeta; - - let common_snippet = r#"{ - "attribution": { "commit": "", "pr": "" }, - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - "ENABLE_TOOL_SEARCH": "true", - "CLAUDE_CODE_EFFORT_LEVEL": "max", - "DISABLE_AUTOUPDATER": "1" - } - }"#; - - let new_form = ProviderAddFormState::new_with_common_snippet(AppType::Claude, common_snippet); - assert!(new_form.include_common_config); - assert_eq!(new_form.claude_quick_config_enabled_count(), 5); - assert!(new_form.claude_effort_max); - assert!(!new_form.has_unsaved_changes()); - - let mut enabled_later = ProviderAddFormState::new(AppType::Claude); - enabled_later - .toggle_include_common_config(common_snippet) - .expect("common config should enable"); - assert_eq!(enabled_later.claude_quick_config_enabled_count(), 5); - - let mut provider = Provider::with_id( - "provider-1".to_string(), - "Provider One".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "sk-provider", - "ANTHROPIC_BASE_URL": "https://provider.example" - } - }), - None, - ); - provider.meta = Some(ProviderMeta { - apply_common_config: Some(true), - ..Default::default() - }); - - let form = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &provider, - common_snippet, - ); - assert_eq!(form.claude_quick_config_enabled_count(), 5); - assert!(!form.has_unsaved_changes()); - - let raw = form.to_provider_json_value(); - assert!(raw["settingsConfig"].get("attribution").is_none()); - assert!(raw["settingsConfig"]["env"] - .get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") - .is_none()); - assert!(raw["settingsConfig"]["env"] - .get("CLAUDE_CODE_EFFORT_LEVEL") - .is_none()); -} - -#[test] -fn provider_edit_form_claude_can_disable_inherited_effort_max() { - use crate::provider::ProviderMeta; - - let common_snippet = r#"{"env":{"CLAUDE_CODE_EFFORT_LEVEL":"max"}}"#; - let mut provider = Provider::with_id( - "provider-1".to_string(), - "Provider One".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "sk-provider", - "ANTHROPIC_BASE_URL": "https://provider.example" - } - }), - None, - ); - provider.meta = Some(ProviderMeta { - apply_common_config: Some(true), - ..Default::default() - }); - - let mut form = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &provider, - common_snippet, - ); - assert!(form.claude_effort_max); - assert_eq!(form.claude_quick_config_enabled_count(), 1); - - form.toggle_claude_quick_config_field(ProviderAddField::ClaudeEffortMax, common_snippet) - .expect("inherited max effort should be editable"); - - assert!(!form.include_common_config); - assert!(!form.claude_effort_max); - let raw = form.to_provider_json_value(); - assert_eq!(raw["meta"]["commonConfigEnabled"], false); - assert!(raw["settingsConfig"]["env"] - .get("CLAUDE_CODE_EFFORT_LEVEL") - .is_none()); -} - -#[test] -fn provider_edit_form_claude_can_disable_inherited_quick_config() { - use crate::provider::ProviderMeta; - - let common_snippet = r#"{ - "attribution": { "commit": "", "pr": "" }, - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - "ENABLE_TOOL_SEARCH": "true", - "DISABLE_AUTOUPDATER": "1", - "SHARED_FLAG": "keep-me" - } - }"#; - let mut provider = Provider::with_id( - "provider-1".to_string(), - "Provider One".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "sk-provider", - "ANTHROPIC_BASE_URL": "https://provider.example" - } - }), - None, - ); - provider.meta = Some(ProviderMeta { - apply_common_config: Some(true), - ..Default::default() - }); - - let mut form = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &provider, - common_snippet, - ); - form.set_main_field_error(ProviderAddField::ClaudeBaseUrl, "invalid base URL"); - form.set_usage_query_field_error(UsageQueryField::ApiKey, "missing query key"); - form.toggle_claude_quick_config_field(ProviderAddField::ClaudeTeammates, common_snippet) - .expect("inherited quick config should be editable"); - - assert!(!form.include_common_config); - assert_eq!(form.claude_quick_config_enabled_count(), 3); - assert!(form.has_unsaved_changes()); - assert_eq!( - form.main_field_error(ProviderAddField::ClaudeBaseUrl), - Some("invalid base URL") - ); - assert_eq!( - form.usage_query_field_error(UsageQueryField::ApiKey), - Some("missing query key") - ); - - let saved = form.to_provider_json_value(); - assert_eq!(saved["meta"]["commonConfigEnabled"], false); - assert!(saved["settingsConfig"]["env"] - .get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") - .is_none()); - assert_eq!(saved["settingsConfig"]["env"]["ENABLE_TOOL_SEARCH"], "true"); - assert_eq!(saved["settingsConfig"]["env"]["SHARED_FLAG"], "keep-me"); - assert_eq!( - saved["settingsConfig"]["attribution"], - json!({ "commit": "", "pr": "" }) - ); - - let saved_provider: Provider = - serde_json::from_value(saved).expect("saved provider should deserialize"); - let reopened = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &saved_provider, - common_snippet, - ); - assert!(!reopened.include_common_config); - assert!(!reopened.claude_teammates); - assert!(reopened.claude_hide_attribution); - assert!(reopened.claude_tool_search); - assert!(reopened.claude_disable_auto_upgrade); -} - -#[test] -fn provider_add_form_claude_can_override_conflicting_common_quick_config() { - let common_snippet = r#"{ - "env": { "ENABLE_TOOL_SEARCH": "false", "SHARED_FLAG": "keep-me" } - }"#; - let mut form = ProviderAddFormState::new_with_common_snippet(AppType::Claude, common_snippet); - assert!(form.include_common_config); - assert!(!form.claude_tool_search); - - form.toggle_claude_quick_config_field(ProviderAddField::ClaudeToolSearch, common_snippet) - .expect("conflicting common quick config should be overridable"); - - assert!(!form.include_common_config); - assert!(form.claude_tool_search); - let saved = form.to_provider_json_value(); - assert_eq!(saved["meta"]["commonConfigEnabled"], false); - assert_eq!(saved["settingsConfig"]["env"]["ENABLE_TOOL_SEARCH"], "true"); - assert_eq!(saved["settingsConfig"]["env"]["SHARED_FLAG"], "keep-me"); -} - #[test] fn provider_add_form_codex_collapses_quick_toggles_into_menu() { let mut form = ProviderAddFormState::new(AppType::Codex); @@ -3008,7 +2865,7 @@ fn provider_add_form_aicodemirror_template_codex_preserves_third_party_auth_beha .as_str() .expect("settingsConfig.config should be string"); assert!(cfg.contains("base_url = \"https://api.aicodemirror.ai/api/codex/backend-api/codex\"")); - assert!(cfg.contains("model = \"gpt-5.6-sol\"")); + assert!(cfg.contains("model = \"gpt-6.1-sol\"")); assert!(cfg.contains("wire_api = \"responses\"")); assert!(cfg.contains("requires_openai_auth = true")); assert!(form.codex_api_key.value.is_empty()); @@ -3033,7 +2890,7 @@ fn provider_add_form_codex_custom_defaults_to_upstream_model() { let form = ProviderAddFormState::new(AppType::Codex); assert_eq!(form.codex_base_url.value, ""); - assert_eq!(form.codex_model.value, "gpt-5.6-sol"); + assert_eq!(form.codex_model.value, "gpt-6.1-sol"); } #[test] @@ -5223,55 +5080,6 @@ fn provider_add_form_has_unsaved_changes_roundtrip_edit_returns_clean() { assert!(!form.has_unsaved_changes()); } -#[test] -fn provider_add_form_common_config_json_merges_into_preview_but_not_raw_submit_payload() { - let mut form = ProviderAddFormState::new(AppType::Claude); - form.id.set("p1"); - form.name.set("Provider One"); - form.include_common_config = true; - form.claude_base_url.set("https://provider.example"); - form.claude_api_key.set("sk-provider"); - - let raw = form.to_provider_json_value(); - let raw_settings = raw - .get("settingsConfig") - .expect("settingsConfig should exist"); - - assert!( - raw_settings.get("alwaysThinkingEnabled").is_none(), - "raw submit payload should not include common snippet scalar keys" - ); - assert_eq!( - raw_settings["env"]["ANTHROPIC_BASE_URL"], "https://provider.example", - "raw submit payload should still include provider-specific fields" - ); - assert_eq!(raw_settings["env"]["ANTHROPIC_AUTH_TOKEN"], "sk-provider"); - - let merged = form - .to_provider_json_value_with_common_config( - r#"{ - "alwaysThinkingEnabled": false, - "env": { - "ANTHROPIC_BASE_URL": "https://common.example", - "COMMON_FLAG": "1" - } - }"#, - ) - .expect("common config should merge"); - let settings = merged - .get("settingsConfig") - .expect("settingsConfig should exist"); - - assert_eq!(settings["alwaysThinkingEnabled"], false); - assert_eq!(settings["env"]["COMMON_FLAG"], "1"); - assert_eq!( - settings["env"]["ANTHROPIC_BASE_URL"], "https://common.example", - "common config should follow backend merge precedence" - ); - assert_eq!(settings["env"]["ANTHROPIC_AUTH_TOKEN"], "sk-provider"); - assert_eq!(merged["meta"]["commonConfigEnabled"], true); -} - #[test] fn provider_add_form_removes_legacy_apply_common_config_alias_from_meta() { let mut form = ProviderAddFormState::new(AppType::Claude); @@ -5376,8 +5184,8 @@ fn provider_add_form_defaults_common_config_from_effective_snippet_only() { r#"{"permissions":{"allow":["Bash"]}}"#, ); assert!( - with_snippet.include_common_config, - "new provider should attach common config when a usable snippet exists" + !with_snippet.include_common_config, + "Claude no longer attaches legacy common snippets" ); let blank_gemini = @@ -5401,7 +5209,7 @@ fn provider_add_form_empty_common_config_toggle_matches_upstream_apps() { claude .toggle_include_common_config("") .expect("upstream Claude permits explicitly enabling an empty snippet"); - assert!(claude.include_common_config); + assert!(!claude.include_common_config); for app_type in [AppType::Codex, AppType::Gemini] { let mut form = ProviderAddFormState::new(app_type); @@ -5489,7 +5297,6 @@ fn gemini_empty_effective_snippet_matches_upstream_ui_state_transitions() { #[test] fn provider_form_disabling_malformed_common_config_matches_upstream_apps() { for (app_type, malformed) in [ - (AppType::Claude, r#"{"env":{"#), (AppType::Codex, "[features\ngoals = true"), (AppType::Gemini, r#"{"BROKEN":"#), ] { @@ -5511,7 +5318,7 @@ fn provider_add_form_empty_content_snippet_replacement_keeps_upstream_enabled_st claude .replace_common_config_snippet(claude_previous, "{}") .expect("valid Claude replacement should succeed"); - assert!(claude.include_common_config); + assert!(!claude.include_common_config); assert!(!claude.claude_tool_search); let codex_previous = "[features]\ngoals = true\n"; @@ -5564,7 +5371,7 @@ fn provider_form_membership_rejects_valid_snippets_without_effective_content() { fn provider_form_array_membership_matches_upstream_ui_exactness() { let claude_snippet = r#"{"permissions":{"allow":["Bash"]}}"#; assert!( - ProviderAddFormState::settings_contain_common_config_for_form( + !ProviderAddFormState::settings_contain_common_config_for_form( &AppType::Claude, &json!({ "permissions": { "allow": ["Bash"] } }), claude_snippet, @@ -5616,89 +5423,6 @@ fn provider_form_array_membership_matches_upstream_ui_exactness() { ); } -#[test] -fn claude_form_common_config_removal_treats_arrays_as_atomic() { - let snippet = r#"{"permissions":{"allow":["Bash"]}}"#; - let mut provider_owned = json!({ - "permissions": { "allow": ["Read", "Bash"] } - }); - strip_common_config_from_settings(&AppType::Claude, &mut provider_owned, snippet) - .expect("valid snippet should be removable"); - assert_eq!( - provider_owned, - json!({ "permissions": { "allow": ["Read", "Bash"] } }) - ); - - let mut exact = json!({ - "permissions": { "allow": ["Bash"] }, - "env": { "PROVIDER_ONLY": "keep" } - }); - strip_common_config_from_settings(&AppType::Claude, &mut exact, snippet) - .expect("exact array should be removable"); - assert_eq!(exact, json!({ "env": { "PROVIDER_ONLY": "keep" } })); -} - -#[test] -fn claude_form_common_config_recursively_ignores_upstream_forbidden_keys() { - let mixed_snippet = r#"{ - "__proto__": {"COMMON": "drop"}, - "env": { - "SAFE": "1", - "constructor": {"COMMON": "drop"}, - "NESTED": [{"prototype": {"COMMON": "drop"}, "keep": "yes"}] - } - }"#; - let settings = json!({ - "__proto__": {"PROVIDER_ONLY": "keep"}, - "env": { - "SAFE": "1", - "constructor": {"PROVIDER_ONLY": "keep"}, - "NESTED": [{"keep": "yes"}] - } - }); - assert!( - ProviderAddFormState::settings_contain_common_config_for_form( - &AppType::Claude, - &settings, - mixed_snippet, - ) - ); - - let mut stripped = settings.clone(); - strip_common_config_from_settings(&AppType::Claude, &mut stripped, mixed_snippet) - .expect("valid sanitized snippet should be removable"); - assert_eq!( - stripped, - json!({ - "__proto__": {"PROVIDER_ONLY": "keep"}, - "env": {"constructor": {"PROVIDER_ONLY": "keep"}} - }) - ); - - let forbidden_only = r#"{"__proto__":{},"constructor":{},"prototype":{}}"#; - assert!( - !ProviderAddFormState::settings_contain_common_config_for_form( - &AppType::Claude, - &json!({}), - forbidden_only, - ) - ); - let mut new_form = - ProviderAddFormState::new_with_common_snippet(AppType::Claude, forbidden_only); - assert!( - new_form.include_common_config, - "upstream's new-form effect checks the raw object before merge filtering" - ); - let effective = new_form - .to_provider_json_value_with_common_config(forbidden_only) - .expect("forbidden-only snippet should be a no-op"); - assert!(effective["settingsConfig"].get("__proto__").is_none()); - new_form - .toggle_claude_quick_config_field(ProviderAddField::ClaudeToolSearch, forbidden_only) - .expect("a later settings edit should succeed"); - assert!(!new_form.include_common_config); -} - #[test] fn provider_edit_form_malformed_snippet_state_matches_upstream_apps() { let mut provider = Provider::with_id( @@ -5713,7 +5437,6 @@ fn provider_edit_form_malformed_snippet_state_matches_upstream_apps() { }); for (app_type, snippet) in [ - (AppType::Claude, r#"{"env":{"#), (AppType::Codex, "[features\ngoals = true"), (AppType::Gemini, r#"{"BROKEN":"#), ] { @@ -5738,7 +5461,7 @@ fn provider_edit_form_valid_empty_snippet_matches_upstream_effect_order() { let claude = ProviderAddFormState::from_provider_with_common_snippet(AppType::Claude, &provider, "{}"); - assert!(claude.include_common_config); + assert!(!claude.include_common_config); let codex = ProviderAddFormState::from_provider_with_common_snippet( AppType::Codex, @@ -5786,11 +5509,6 @@ fn codex_edit_form_preserves_text_fallback_membership_for_malformed_config() { #[test] fn disabled_form_reinfers_membership_after_common_snippet_replacement() { for (app_type, settings, next_snippet) in [ - ( - AppType::Claude, - json!({"env": {"SHARED": "claude"}}), - r#"{"env":{"SHARED":"claude"}}"#, - ), ( AppType::Codex, json!({"config": "[features]\ngoals = true\n"}), @@ -5838,7 +5556,6 @@ fn provider_form_malformed_snippet_replacement_matches_upstream_apps() { assert_eq!(effective["settingsConfig"]["env"]["SHARED"], "next"); for (app_type, malformed) in [ - (AppType::Claude, r#"{"env":{"#), (AppType::Codex, "[features\ngoals = true"), (AppType::Gemini, r#"{"BROKEN":"#), ] { @@ -5851,17 +5568,9 @@ fn provider_form_malformed_snippet_replacement_matches_upstream_apps() { "clearing should disable sharing for {app_type:?}" ); } - - let mut claude = ProviderAddFormState::new(AppType::Claude); - claude.include_common_config = true; - assert!(claude - .replace_common_config_snippet(r#"{"env":{"#, r#"{"env":{"ENABLE_TOOL_SEARCH":"true"}}"#,) - .is_err()); - assert!(!claude.include_common_config); } - #[test] -fn provider_edit_form_preserves_missing_common_config_meta_until_toggle() { +fn provider_edit_form_preserves_missing_common_config_meta_for_legacy_claude() { let provider = Provider::with_id( "legacy-provider".to_string(), "Legacy Provider".to_string(), @@ -5897,71 +5606,7 @@ fn provider_edit_form_preserves_missing_common_config_meta_until_toggle() { ) .expect("toggle should succeed"); let toggled = form.to_provider_json_value(); - assert_eq!(toggled["meta"]["commonConfigEnabled"], true); -} - -#[test] -fn provider_edit_form_missing_meta_inferrs_common_config_from_subset() { - let provider = Provider::with_id( - "legacy-provider".to_string(), - "Legacy Provider".to_string(), - json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example", - "CC_SWITCH_SHARED": "1" - } - }), - None, - ); - - let form = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &provider, - r#"{"env":{"CC_SWITCH_SHARED":"1"}}"#, - ); - - assert!( - form.include_common_config, - "missing-meta edit should infer common config usage when provider settings contain the snippet" - ); - let raw = form.to_provider_json_value(); - assert!( - raw.get("meta") - .and_then(|meta| meta.get("commonConfigEnabled")) - .is_none(), - "inferred missing-meta usage should not force explicit meta until the user toggles" - ); -} - -#[test] -fn provider_edit_form_quick_edit_persists_inferred_common_config_opt_in() { - let common_snippet = r#"{"env":{"CC_SWITCH_SHARED":"1"}}"#; - let provider = Provider::with_id( - "legacy-provider".to_string(), - "Legacy Provider".to_string(), - json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example", - "CC_SWITCH_SHARED": "1" - } - }), - None, - ); - - let mut form = ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &provider, - common_snippet, - ); - assert!(form.include_common_config); - - form.toggle_claude_quick_config_field(ProviderAddField::ClaudeToolSearch, common_snippet) - .expect("provider-only quick edit should succeed"); - - let saved = form.to_provider_json_value(); - assert!(form.include_common_config); - assert!(form.claude_tool_search); - assert_eq!(saved["meta"]["commonConfigEnabled"], true); + assert!(toggled["meta"].get("commonConfigEnabled").is_none()); } #[test] @@ -6023,102 +5668,6 @@ fn provider_edit_form_apply_provider_json_keeps_locked_id() { assert_eq!(form.claude_base_url.value, "https://after.example"); } -#[test] -fn provider_add_form_disabling_common_config_strips_common_fields_from_json() { - let mut form = ProviderAddFormState::new(AppType::Claude); - form.id.set("p1"); - form.name.set("Provider One"); - form.include_common_config = true; - - let parsed = Provider::with_id( - "p1".to_string(), - "Provider One".to_string(), - json!({ - "alwaysThinkingEnabled": false, - "statusLine": { - "type": "command", - "command": "~/.claude/statusline.sh", - "padding": 0 - }, - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example" - } - }), - None, - ); - form.apply_provider_json_to_fields(&parsed); - - let common = r#"{ - "alwaysThinkingEnabled": false, - "statusLine": { - "type": "command", - "command": "~/.claude/statusline.sh", - "padding": 0 - } - }"#; - form.toggle_include_common_config(common) - .expect("toggle should succeed"); - - assert!( - !form.include_common_config, - "toggle should disable include_common_config" - ); - let provider = form.to_provider_json_value(); - let settings = provider - .get("settingsConfig") - .expect("settingsConfig should exist"); - assert!( - settings.get("alwaysThinkingEnabled").is_none(), - "common scalar field should be removed after disabling common config" - ); - assert!( - settings.get("statusLine").is_none(), - "common nested field should be removed after disabling common config" - ); -} - -#[test] -fn provider_add_form_disabling_common_config_preserves_provider_specific_env_keys() { - let mut form = ProviderAddFormState::new(AppType::Claude); - form.id.set("p1"); - form.name.set("Provider One"); - form.include_common_config = true; - - let parsed = Provider::with_id( - "p1".to_string(), - "Provider One".to_string(), - json!({ - "env": { - "ANTHROPIC_BASE_URL": "https://common.example", - "ANTHROPIC_AUTH_TOKEN": "sk-provider" - } - }), - None, - ); - form.apply_provider_json_to_fields(&parsed); - - form.toggle_include_common_config(r#"{"env":{"ANTHROPIC_BASE_URL":"https://common.example"}}"#) - .expect("toggle should succeed"); - - let provider = form.to_provider_json_value(); - let env = provider - .get("settingsConfig") - .and_then(|settings| settings.get("env")) - .and_then(|value| value.as_object()) - .expect("env should exist"); - - assert!( - env.get("ANTHROPIC_BASE_URL").is_none(), - "common env keys should be removed" - ); - assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN") - .and_then(|value| value.as_str()), - Some("sk-provider"), - "provider-specific env keys should be preserved" - ); -} - #[test] fn provider_add_form_opencode_exposes_supported_sponsor_presets() { let form = ProviderAddFormState::new(AppType::OpenCode); @@ -6337,14 +5886,14 @@ fn provider_add_form_aicodemirror_template_opencode_matches_serializer_and_loade .keys() .map(String::as_str) .collect::>(); - assert_eq!(model_ids, ["claude-sonnet-5", "claude-opus-5"]); + assert_eq!(model_ids, ["claude-sonnet-5-5", "claude-opus-5-5"]); assert_eq!( - provider["settingsConfig"]["models"]["claude-sonnet-5"]["name"], - "Claude Sonnet 5" + provider["settingsConfig"]["models"]["claude-sonnet-5-5"]["name"], + "Claude Sonnet 5.5" ); assert_eq!( - provider["settingsConfig"]["models"]["claude-opus-5"]["name"], - "Claude Opus 5" + provider["settingsConfig"]["models"]["claude-opus-5-5"]["name"], + "Claude Opus 5.5" ); let mut parsed = Provider::with_id( @@ -6369,8 +5918,8 @@ fn provider_add_form_aicodemirror_template_opencode_matches_serializer_and_loade roundtrip_form.opencode_base_url.value, "https://api.aicodemirror.ai/api/claudecode" ); - assert_eq!(roundtrip_form.opencode_model_id.value, "claude-opus-5"); - assert_eq!(roundtrip_form.opencode_model_name.value, "Claude Opus 5"); + assert_eq!(roundtrip_form.opencode_model_id.value, "claude-opus-5-5"); + assert_eq!(roundtrip_form.opencode_model_name.value, "Claude Opus 5.5"); let roundtrip = roundtrip_form.to_provider_json_value(); assert!( @@ -6380,12 +5929,12 @@ fn provider_add_form_aicodemirror_template_opencode_matches_serializer_and_loade "OpenCode roundtrip should still omit blank API keys" ); assert_eq!( - roundtrip["settingsConfig"]["models"]["claude-sonnet-5"]["name"], - "Claude Sonnet 5" + roundtrip["settingsConfig"]["models"]["claude-sonnet-5-5"]["name"], + "Claude Sonnet 5.5" ); assert_eq!( - roundtrip["settingsConfig"]["models"]["claude-opus-5"]["name"], - "Claude Opus 5" + roundtrip["settingsConfig"]["models"]["claude-opus-5-5"]["name"], + "Claude Opus 5.5" ); } @@ -6692,21 +6241,31 @@ fn provider_add_form_aicodemirror_template_openclaw_matches_serializer_and_loade provider["settingsConfig"]["models"], json!([ { - "id": "claude-opus-5", - "name": "Claude Opus 5", + "id": "claude-opus-5-5", + "name": "Claude Opus 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, "cost": { - "input": 5, - "output": 25 + "input": 4, + "output": 20, + "cacheRead": 0.2, + "cacheWrite": 5 } }, { - "id": "claude-sonnet-5", - "name": "Claude Sonnet 5", + "id": "claude-sonnet-5-5", + "name": "Claude Sonnet 5.5", + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, "cost": { - "input": 3, - "output": 15 + "input": 2, + "output": 10, + "cacheRead": 0.1, + "cacheWrite": 2.5 } } ]) @@ -6734,8 +6293,8 @@ fn provider_add_form_aicodemirror_template_openclaw_matches_serializer_and_loade roundtrip_form.opencode_base_url.value, "https://api.aicodemirror.ai/api/claudecode" ); - assert_eq!(roundtrip_form.opencode_model_id.value, "claude-opus-5"); - assert_eq!(roundtrip_form.opencode_model_name.value, "Claude Opus 5"); + assert_eq!(roundtrip_form.opencode_model_id.value, "claude-opus-5-5"); + assert_eq!(roundtrip_form.opencode_model_name.value, "Claude Opus 5.5"); assert_eq!(roundtrip_form.opencode_model_context_limit.value, "1000000"); let roundtrip = roundtrip_form.to_provider_json_value(); @@ -8303,3 +7862,290 @@ fn claude_fallback_one_m_is_adjacent_and_resets_with_template() { assert!(!form.claude_fallback_one_m); assert!(form.claude_model.value.is_empty()); } + +#[test] +fn codex_oauth_hides_usage_query_and_preserves_binding_and_saved_script() { + for script in [ + serde_json::Value::Null, + json!({ + "enabled": true, "language": "javascript", "code": "invalid legacy script", + "templateType": "general", "autoQueryInterval": 0 + }), + ] { + let mut value = json!({ + "id": "oauth", "name": "Account", "settingsConfig": {}, + "meta": {"providerType": "codex_oauth", "authBinding": { + "source": "managed_account", "authProvider": "codex_oauth", "accountId": "acct-1" + }} + }); + if !script.is_null() { + value["meta"]["usage_script"] = script; + } + let provider: Provider = serde_json::from_value(value.clone()).unwrap(); + let mut form = ProviderAddFormState::from_provider(AppType::Claude, &provider); + assert!(!form.fields().contains(&ProviderAddField::UsageQuery)); + assert!(!form.fields().contains(&ProviderAddField::UsageQueryDivider)); + form.open_usage_query_page(); + assert!(matches!(form.page, ProviderFormPage::Main)); + assert!(form.usage_query_script_validation_error().is_none()); + form.name.set("Renamed account"); + let saved = form.to_provider_json_value(); + assert_eq!(saved["meta"]["authBinding"], value["meta"]["authBinding"]); + assert_eq!(saved["meta"]["usage_script"], value["meta"]["usage_script"]); + let reopened = ProviderAddFormState::from_provider( + AppType::Claude, + &serde_json::from_value(saved).unwrap(), + ); + assert!(!reopened.fields().contains(&ProviderAddField::UsageQuery)); + } + let provider: Provider = serde_json::from_value(json!({ + "id": "custom", "name": "Codex OAuth", "settingsConfig": {} + })) + .unwrap(); + let form = ProviderAddFormState::from_provider(AppType::Claude, &provider); + assert!(form.fields().contains(&ProviderAddField::UsageQuery)); +} + +#[test] +fn codex_auth_form_preserves_explicit_auth_and_bearer_on_unrelated_save() { + for auth in [ + json!({"tokens": {"account_id": "account"}}), + json!({"OPENAI_API_KEY": "sk-stored"}), + ] { + for directive in [ + "", + "requires_openai_auth = false", + "requires_openai_auth = true", + "requires_openai_auth = true\nenv_key = \"CUSTOM_KEY\"", + ] { + let config = format!("model_provider = \"custom\"\n[model_providers.custom]\nbase_url = \"https://example.com/v1\"\nexperimental_bearer_token = \"sk-bearer\"\n{directive}\n[model_providers.other]\nexperimental_bearer_token = \"sk-other\"\n"); + let provider = Provider::with_id( + "custom".into(), + "Custom".into(), + json!({"auth": auth, "config": config}), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Codex, &provider); + assert_eq!( + form.codex_api_key.value, + auth.get("OPENAI_API_KEY") + .and_then(Value::as_str) + .unwrap_or("sk-bearer") + ); + form.name.set("Renamed"); + let saved = form.to_provider_json_value(); + assert_eq!(saved["settingsConfig"]["auth"], auth); + let before: toml::Table = toml::from_str(&config).unwrap(); + let after: toml::Table = + toml::from_str(saved["settingsConfig"]["config"].as_str().unwrap()).unwrap(); + for key in [ + "requires_openai_auth", + "env_key", + "experimental_bearer_token", + ] { + assert_eq!( + after["model_providers"]["custom"].get(key), + before["model_providers"]["custom"].get(key), + "{directive}: {key}" + ); + } + assert_eq!( + after["model_providers"]["other"], + before["model_providers"]["other"] + ); + } + } +} + +#[test] +fn codex_auth_form_key_edits_update_or_clear_existing_bearer() { + for location in ["provider", "root", "absent"] { + let root = if location == "root" { + "experimental_bearer_token = \"sk-old\"\n" + } else { + "" + }; + let token = if location == "provider" { + "experimental_bearer_token = \"sk-old\"\n" + } else { + "" + }; + let config = format!("{root}model_provider = \"custom\"\n[model_providers.custom]\nbase_url = \"https://example.com/v1\"\nrequires_openai_auth = false\n{token}[model_providers.other]\nexperimental_bearer_token = \"sk-other\"\n"); + let provider = Provider::with_id( + "custom".into(), + "Custom".into(), + json!({"auth": {"OPENAI_API_KEY": "sk-old", "tokens": {"account_id": "account"}}, "config": config}), + None, + ); + for key in ["sk-new", ""] { + let mut form = ProviderAddFormState::from_provider(AppType::Codex, &provider); + form.codex_api_key.set(key); + let saved = form.to_provider_json_value(); + assert_eq!( + saved["settingsConfig"]["auth"]["tokens"]["account_id"], + "account" + ); + assert_eq!( + saved["settingsConfig"]["auth"] + .get("OPENAI_API_KEY") + .and_then(Value::as_str), + (!key.is_empty()).then_some(key) + ); + let after: toml::Table = + toml::from_str(saved["settingsConfig"]["config"].as_str().unwrap()).unwrap(); + let table = if location == "root" { + &after + } else { + after["model_providers"]["custom"].as_table().unwrap() + }; + assert_eq!( + table + .get("experimental_bearer_token") + .and_then(toml::Value::as_str), + (location != "absent" && !key.is_empty()).then_some(key) + ); + assert!(after["model_providers"]["custom"].get("env_key").is_none()); + assert_eq!( + after["model_providers"]["other"]["experimental_bearer_token"].as_str(), + Some("sk-other") + ); + let reopened: Provider = serde_json::from_value(saved).unwrap(); + assert_eq!( + ProviderAddFormState::from_provider(AppType::Codex, &reopened) + .codex_api_key + .value, + key + ); + } + } +} + +#[test] +fn codex_auth_form_bearer_fallback_accepts_inline_tables_and_empty_provider_tokens() { + for (config, expected) in [ + ( + r#"model_provider = "custom" +experimental_bearer_token = "sk-root" +[model_providers.custom] +base_url = "https://example.com/v1" +experimental_bearer_token = " " +"#, + "sk-root", + ), + ( + r#"model_provider = "custom" +model_providers = { custom = { base_url = "https://example.com/v1", experimental_bearer_token = "sk-inline" } } +"#, + "sk-inline", + ), + ] { + let provider = Provider::with_id( + "custom".into(), + "Custom".into(), + json!({"auth": {"tokens": {"account_id": "account"}}, "config": config}), + None, + ); + let mut form = ProviderAddFormState::from_provider(AppType::Codex, &provider); + assert_eq!(form.codex_api_key.value, expected); + form.name.set("Renamed"); + let saved = form.to_provider_json_value(); + assert_eq!( + saved["settingsConfig"]["auth"], + provider.settings_config["auth"] + ); + let reopened: Provider = serde_json::from_value(saved).unwrap(); + assert_eq!( + ProviderAddFormState::from_provider(AppType::Codex, &reopened) + .codex_api_key + .value, + expected + ); + } +} + +#[test] +fn codex_auth_form_uses_case_sensitive_current_builtin_ids() { + for (id, custom) in [ + ("OpenAI", true), + ("oss", true), + ("ollama-chat", true), + ("openai", false), + ("amazon-bedrock-runtime", false), + ] { + let config = format!("model_provider = \"{id}\"\nexperimental_bearer_token = \"sk-root\"\n[model_providers.{id}]\nbase_url = \"https://example.com/v1\"\nexperimental_bearer_token = \"sk-provider\"\n"); + let provider = Provider::with_id( + "custom".into(), + "Custom".into(), + json!({"config": config}), + None, + ); + let form = ProviderAddFormState::from_provider(AppType::Codex, &provider); + assert_eq!( + form.codex_api_key.value, + if custom { "sk-provider" } else { "sk-root" }, + "{id}" + ); + for key in ["sk-new", ""] { + let mut form = form.clone(); + form.codex_api_key.set(key); + let saved = form.to_provider_json_value(); + let after: toml::Table = + toml::from_str(saved["settingsConfig"]["config"].as_str().unwrap()).unwrap(); + let changed = if custom { + after["model_providers"][id].as_table().unwrap() + } else { + &after + }; + assert_eq!( + changed + .get("experimental_bearer_token") + .and_then(toml::Value::as_str), + (!key.is_empty()).then_some(key), + "{id}" + ); + let untouched = if custom { + &after + } else { + after["model_providers"][id].as_table().unwrap() + }; + assert_eq!( + untouched["experimental_bearer_token"].as_str(), + Some(if custom { "sk-root" } else { "sk-provider" }), + "{id}" + ); + } + } +} + +#[test] +fn claude_editor_context_and_global_values_survive_json_and_template_changes() { + let base = json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key","ANTHROPIC_BASE_URL":"https://old.example","GLOBAL_ENV":"keep"},"language":"chinese","permissions":{"allow":["Read"]}}); + let provider = Provider::with_id("old".into(), "Old".into(), base.clone(), None); + let mut form = ProviderAddFormState::from_provider(AppType::Claude, &provider); + form.claude_editor = Some(crate::services::provider::claude_editor::EditorSave { + base: base.clone(), + draft: None, + on_conflict: Default::default(), + }); + let mut value = form.to_provider_json_value(); + value["settingsConfig"]["language"] = json!("english"); + value["settingsConfig"] + .as_object_mut() + .unwrap() + .remove("permissions"); + form.apply_provider_json_value_to_fields(value).unwrap(); + assert_eq!(form.claude_editor.as_ref().unwrap().base, base); + assert!(form.to_provider_json_value()["settingsConfig"] + .get("permissions") + .is_none()); + form.apply_template(0, &[]); + let settings = &form.to_provider_json_value()["settingsConfig"]; + assert_eq!(settings["language"], "english"); + assert_eq!(settings["env"]["GLOBAL_ENV"], "keep"); + assert!(settings.get("permissions").is_none()); + assert_eq!(form.claude_editor.as_ref().unwrap().base, base); + assert!(!form + .fields() + .contains(&ProviderAddField::IncludeCommonConfig)); + assert!(!form.fields().contains(&ProviderAddField::CommonSnippet)); +} diff --git a/src-tauri/src/cli/tui/help.rs b/src-tauri/src/cli/tui/help.rs index 4c707af2..10cf71cb 100644 --- a/src-tauri/src/cli/tui/help.rs +++ b/src-tauri/src/cli/tui/help.rs @@ -831,8 +831,8 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten ProviderAddField::CodexOAuthAccount => HelpContent::new( texts::tui_label_chatgpt_account(), help_lines( - "绑定 ChatGPT/Codex 官方登录账号。官方账号可用于 Codex 插件、移动端远程操作等官方能力。", - "Binds an official ChatGPT/Codex account. Official login can keep Codex plugins, mobile remote control, and other official app features available.", + "绑定 ChatGPT/Codex 官方登录账号。官方账号可用于 Codex 插件、移动端远程操作等官方能力。订阅额度通过绑定账号的内置查询显示,无需余额模板;可在供应商列表按 r 刷新。", + "Binds an official ChatGPT/Codex account. Official login can keep Codex plugins, mobile remote control, and other official app features available. Subscription quota uses the bound account's built-in query without a balance template; press r in the provider list to refresh.", ), ), ProviderAddField::CodexFastMode => HelpContent::new( @@ -842,6 +842,7 @@ fn provider_field_help(app_type: AppType, field: ProviderAddField) -> HelpConten "Enables the priority service tier for Codex OAuth requests. It only affects accounts or services that support it.", ), ), + ProviderAddField::ClaudeInactiveFields => HelpContent::new(crate::t!("Inactive fields", "未应用字段"), help_lines("这些旧配置保存在供应商记录中,但不会随切换生效。可以复制到当前编辑内容,检查后保存。", "These legacy values remain in the provider record but are not applied when switching. Copy them into the editor, review, then save.")), ProviderAddField::CommonSnippet => HelpContent::new( texts::tui_config_item_common_snippet(), help_lines( @@ -1065,8 +1066,8 @@ fn provider_preview_help(app_type: AppType, section: Option (AppType::Codex, Some(CodexPreviewSection::Auth)) => HelpContent::new( texts::tui_codex_auth_json_title(), help_lines( - "这里预览将保存的 Codex 认证配置。代理接管时,上游会强调这里是存储配置,不是运行中的 auth.json。", - "This previews the Codex auth config to be saved. During proxy takeover, upstream treats this as stored config, not the live auth.json.", + "这里预览保存在 CC Switch 中的认证配置,不是运行中的 auth.json。第三方 API Key 在切换时写入 config.toml;auth.json 用于官方登录。", + "This previews the auth config stored in CC Switch, not the live auth.json. Third-party API keys are written to config.toml on switch; auth.json is used for official login.", ), ), (AppType::Codex, Some(CodexPreviewSection::Config)) => HelpContent::new( @@ -1079,8 +1080,8 @@ fn provider_preview_help(app_type: AppType, section: Option (AppType::Claude, _) => HelpContent::new( texts::tui_provider_config_title(), help_lines( - "这里预览将保存的供应商配置。Proxy 开启时,Claude 实际读取的 settings.json 会由代理临时接管,因此会显示本地代理地址、占位凭据和模型映射,而不是这里的真实上游配置。按 Enter 可打开编辑器进行高级修改。", - "This previews the stored provider config. While Proxy is on, Claude's live settings.json is temporarily managed by the proxy, so it contains the local proxy URL, placeholder credentials, and model mappings instead of the real upstream config shown here. Press Enter to open the advanced editor.", + "这里展示切到此供应商后的 settings.json。密钥、模型和上下文限制随供应商保存;语言、权限等全局设置按修改直接写盘,编辑其他供应商时也会生效。外部修改发生冲突时会提示选择。代理开启时仍显示真实供应商配置,保存不会覆盖代理地址和占位凭据。按 Enter 编辑。旧通用片段仅保留兼容,可用 config common show 查看后按需复制。", + "This shows settings.json after switching to this provider. Keys, models and context limits belong to the provider; changed global settings such as language and permissions are written immediately, even when editing an inactive provider. Conflicting external edits require a choice. While proxying, the editor shows real provider settings and saving preserves the proxy route. Press Enter to edit. Legacy common snippets remain available through config common show for manual copying.", ), ), _ => HelpContent::new( diff --git a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs index f9c76869..7ce4c351 100644 --- a/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs +++ b/src-tauri/src/cli/tui/runtime_actions/claude_temp_launch.rs @@ -380,13 +380,9 @@ mod tests { .expect("create ~/.claude (initialized)"); let state = crate::store::AppState::try_new().expect("create state"); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some( - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# - .to_string(), - ), + state.db.set_config_snippet( + "claude", + Some(r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"#.to_string()), ) .expect("set common config snippet"); diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 188481d5..3eff838e 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -992,6 +992,31 @@ fn submit_provider_form_apply_codex_config_toml( Ok(()) } +fn show_claude_editor_conflict( + ctx: &mut RuntimeActionContext<'_>, + error: &AppError, + submit: EditorSubmit, + content: String, +) -> bool { + let Some(keys) = crate::services::provider::claude_editor::conflict_keys(error) else { + return false; + }; + ctx.app.overlay = + crate::cli::tui::app::Overlay::Confirm(crate::cli::tui::app::ConfirmOverlay { + title: crate::t!("Configuration changed", "配置已被修改").to_string(), + message: format!( + "{}\n{}", + crate::t!( + "These fields were changed outside the editor:", + "编辑期间,以下字段被其他程序修改:" + ), + keys.join(", ") + ), + action: crate::cli::tui::app::ConfirmAction::ClaudeEditorConflict { submit, content }, + }); + true +} + fn submit_provider_add( ctx: &mut RuntimeActionContext<'_>, content: String, @@ -1038,7 +1063,23 @@ fn submit_provider_add( }; provider.id = provider_id; - let result = if let Some(copy_source_id) = copy_source_id { + let editor = match ctx.app.form.as_mut() { + Some(FormState::ProviderAdd(form)) => form.claude_editor.as_mut().map(|editor| { + let save = editor.clone(); + editor.on_conflict = Default::default(); + save + }), + _ => None, + }; + let result = if let Some(editor) = editor { + ProviderService::save_claude_from_editor( + &state, + provider, + editor, + true, + copy_source_id.as_deref(), + ) + } else if let Some(copy_source_id) = copy_source_id { ProviderService::duplicate( &state, ctx.app.app_type.clone(), @@ -1063,7 +1104,9 @@ fn submit_provider_add( .push_toast(texts::tui_toast_provider_add_failed(), ToastKind::Error); } Err(err) => { - ctx.app.push_toast(err.to_string(), ToastKind::Error); + if !show_claude_editor_conflict(ctx, &err, EditorSubmit::ProviderAdd, content) { + ctx.app.push_toast(err.to_string(), ToastKind::Error); + } } } @@ -1099,10 +1142,32 @@ fn submit_provider_edit( } let state = load_state()?; - let result = ProviderService::update(&state, ctx.app.app_type.clone(), provider); + let editor = match ctx.app.form.as_mut() { + Some(FormState::ProviderAdd(form)) => form.claude_editor.as_mut().map(|editor| { + let save = editor.clone(); + editor.on_conflict = Default::default(); + save + }), + _ => None, + }; + let result = if let Some(editor) = editor { + ProviderService::save_claude_from_editor(&state, provider, editor, false, None) + } else { + ProviderService::update(&state, ctx.app.app_type.clone(), provider) + }; if let Err(err) = result { - ctx.app.push_toast(err.to_string(), ToastKind::Error); + if !show_claude_editor_conflict( + ctx, + &err, + EditorSubmit::ProviderEdit { + id, + expected_pi_settings_config, + }, + content, + ) { + ctx.app.push_toast(err.to_string(), ToastKind::Error); + } return Ok(()); } @@ -1652,45 +1717,6 @@ mod tests { .is_some_and(|message| message.contains("return"))); } - #[test] - #[serial(home_settings)] - fn provider_json_apply_refreshes_effective_common_quick_config() { - let common_snippet = r#"{"env":{"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS":"1"}}"#; - let mut fixture = runtime_ctx(AppType::Claude); - fixture.data.config.common_snippet = common_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new_with_common_snippet( - AppType::Claude, - common_snippet, - ), - )); - - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{ - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - "PROVIDER_ONLY": "keep" - } - }"# - .to_string(), - ) - .expect("effective JSON should apply"); - - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected provider form"); - }; - assert!(form.include_common_config); - assert!(form.claude_teammates); - let raw = form.to_provider_json_value(); - assert!(raw["settingsConfig"]["env"] - .get("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS") - .is_none()); - assert_eq!(raw["settingsConfig"]["env"]["PROVIDER_ONLY"], "keep"); - } - #[test] #[serial(home_settings)] fn gemini_form_effective_preview_does_not_read_live_settings() { @@ -1802,214 +1828,6 @@ mod tests { assert_eq!(raw["meta"]["commonConfigEnabled"], true); } - #[test] - #[serial(home_settings)] - fn provider_settings_edit_disables_sharing_when_common_values_are_removed() { - { - let claude_snippet = r#"{"env":{"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS":"1"}}"#; - let mut fixture = runtime_ctx(AppType::Claude); - fixture.data.config.common_snippet = claude_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new_with_common_snippet( - AppType::Claude, - claude_snippet, - ), - )); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{"env":{"PROVIDER_ONLY":"keep"}}"#.to_string(), - ) - .expect("Claude JSON should apply"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Claude provider form"); - }; - assert!(!form.include_common_config); - assert!(!form.claude_teammates); - assert_eq!( - form.to_provider_json_value()["meta"]["commonConfigEnabled"], - false - ); - } - - { - let codex_snippet = "[features]\ngoals = true\n"; - let mut fixture = runtime_ctx(AppType::Codex); - fixture.data.config.common_snippet = codex_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new_with_common_snippet( - AppType::Codex, - codex_snippet, - ), - )); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyCodexConfigToml, - "model_provider = \"myco\"\n\n[features]\ngoals = false\n\n[model_providers.myco]\nname = \"My Codex\"\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\n" - .to_string(), - ) - .expect("Codex TOML should apply"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Codex provider form"); - }; - assert!(!form.include_common_config); - assert!(!form.codex_goal_mode); - assert_eq!( - form.to_provider_json_value()["meta"]["commonConfigEnabled"], - false - ); - } - } - - #[test] - #[serial(home_settings)] - fn provider_settings_edit_reenables_sharing_when_common_values_are_added() { - { - let common_snippet = r#"{"env":{"SHARED":"claude"}}"#; - let mut fixture = runtime_ctx(AppType::Claude); - fixture.data.config.common_snippet = common_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude), - )); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{"env":{"SHARED":"claude","PROVIDER_ONLY":"keep"}}"#.to_string(), - ) - .expect("Claude settings should apply"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Claude provider form"); - }; - assert!(form.include_common_config); - let raw = form.to_provider_json_value(); - assert!(raw["settingsConfig"]["env"].get("SHARED").is_none()); - assert_eq!(raw["settingsConfig"]["env"]["PROVIDER_ONLY"], "keep"); - } - - { - let common_snippet = "[features]\ngoals = true\n"; - let mut fixture = runtime_ctx(AppType::Codex); - fixture.data.config.common_snippet = common_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new(AppType::Codex), - )); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyCodexConfigToml, - "model = \"gpt-5.4\"\n\n[features]\ngoals = true\n".to_string(), - ) - .expect("Codex config should apply"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Codex provider form"); - }; - assert!(form.include_common_config); - let raw = form.to_provider_json_value(); - let raw_config = raw["settingsConfig"]["config"] - .as_str() - .expect("raw Codex config"); - assert!(!crate::codex_config::is_codex_goal_mode_enabled(raw_config)); - } - - { - let common_snippet = r#"{"SHARED":"gemini"}"#; - let mut fixture = runtime_ctx(AppType::Gemini); - fixture.data.config.common_snippet = common_snippet.to_string(); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new(AppType::Gemini), - )); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{"env":{"SHARED":"gemini","PROVIDER_ONLY":"keep"},"config":{}}"#.to_string(), - ) - .expect("Gemini settings should apply"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Gemini provider form"); - }; - assert!(form.include_common_config); - let raw = form.to_provider_json_value(); - assert!(raw["settingsConfig"]["env"].get("SHARED").is_none()); - assert_eq!(raw["settingsConfig"]["env"]["PROVIDER_ONLY"], "keep"); - } - } - - #[test] - #[serial(home_settings)] - fn provider_settings_edit_with_malformed_snippet_matches_upstream_disable_behavior() { - { - let mut fixture = runtime_ctx(AppType::Claude); - fixture.data.config.common_snippet = r#"{"env":{"#.to_string(); - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude); - form.include_common_config = true; - fixture.app.form = Some(FormState::ProviderAdd(form)); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{"env":{"PROVIDER_ONLY":"keep"}}"#.to_string(), - ) - .expect("Claude settings edit should proceed"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Claude provider form"); - }; - assert!(!form.include_common_config); - assert_eq!( - form.to_provider_json_value()["settingsConfig"]["env"]["PROVIDER_ONLY"], - "keep" - ); - } - - { - let mut fixture = runtime_ctx(AppType::Codex); - fixture.data.config.common_snippet = "[features\ngoals = true".to_string(); - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Codex); - form.include_common_config = true; - fixture.app.form = Some(FormState::ProviderAdd(form)); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyCodexConfigToml, - "model = \"gpt-5.4\"\n".to_string(), - ) - .expect("Codex config edit should proceed"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Codex provider form"); - }; - assert!(!form.include_common_config); - assert!(form.to_provider_json_value()["settingsConfig"]["config"] - .as_str() - .is_some_and(|config| config.contains("model = \"gpt-5.4\""))); - } - - { - let mut fixture = runtime_ctx(AppType::Gemini); - fixture.data.config.common_snippet = r#"{"BROKEN":"#.to_string(); - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Gemini); - form.include_common_config = true; - fixture.app.form = Some(FormState::ProviderAdd(form)); - let mut ctx = runtime_action_ctx(&mut fixture); - super::submit( - &mut ctx, - EditorSubmit::ProviderFormApplyJson, - r#"{"env":{"PROVIDER_ONLY":"keep"}}"#.to_string(), - ) - .expect("Gemini settings edit should proceed"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected Gemini provider form"); - }; - assert!(!form.include_common_config); - assert_eq!( - form.to_provider_json_value()["settingsConfig"]["env"]["PROVIDER_ONLY"], - "keep" - ); - } - } - #[test] #[serial(home_settings)] fn submit_local_proxy_headers_keeps_editor_and_previous_state_on_invalid_input() { @@ -2148,75 +1966,6 @@ mod tests { } } - #[test] - #[serial(home_settings)] - fn submit_config_common_snippet_returns_to_form_without_view_overlay() { - let mut fixture = runtime_ctx(AppType::Claude); - let initial_snippet = r#"{"env":{"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS":"1"}}"#; - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new_with_common_snippet( - AppType::Claude, - initial_snippet, - ), - )); - - let mut ctx = RuntimeActionContext { - terminal: &mut fixture.terminal, - app: &mut fixture.app, - data: &mut fixture.data, - speedtest_req_tx: None, - stream_check_req_tx: None, - skills_req_tx: None, - proxy_req_tx: None, - proxy_loading: &mut fixture.proxy_loading, - local_env_req_tx: None, - session_req_tx: None, - webdav_req_tx: None, - webdav_loading: &mut fixture.webdav_loading, - update_req_tx: None, - update_check: &mut fixture.update_check, - model_fetch_req_tx: None, - managed_auth_req_tx: None, - }; - - super::submit( - &mut ctx, - EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, - source: crate::cli::tui::app::CommonSnippetViewSource::ProviderForm, - }, - r#"{"env":{"ENABLE_TOOL_SEARCH":"true"}}"#.to_string(), - ) - .expect("common snippet submit should succeed"); - - assert!(ctx.app.editor.is_none()); - assert!(matches!( - ctx.app.overlay, - crate::cli::tui::app::Overlay::None - )); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected provider form"); - }; - assert!(!form.claude_teammates); - assert!(form.claude_tool_search); - - super::submit( - &mut ctx, - EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, - source: crate::cli::tui::app::CommonSnippetViewSource::ProviderForm, - }, - String::new(), - ) - .expect("clearing common snippet should succeed"); - let Some(FormState::ProviderAdd(form)) = ctx.app.form.as_ref() else { - panic!("expected provider form"); - }; - assert!(!form.include_common_config); - assert!(!form.claude_teammates); - assert!(!form.claude_tool_search); - } - #[test] #[serial(home_settings)] fn submit_common_snippet_reconciles_malformed_old_codex_form_to_saved_replacement() { @@ -2357,67 +2106,6 @@ mod tests { ); } - #[test] - #[serial(home_settings)] - fn extract_common_snippet_updates_editor_buffer_without_saving() { - let mut fixture = runtime_ctx(AppType::Claude); - - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude); - form.name.set("Provider One"); - form.claude_base_url.set("https://provider.example"); - form.claude_api_key.set("sk-provider"); - form.extra = json!({ - "settingsConfig": { - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example", - "ANTHROPIC_AUTH_TOKEN": "sk-provider", - "COMMON_FLAG": "1" - } - } - }); - fixture.app.form = Some(FormState::ProviderAdd(form)); - fixture.app.open_editor( - "Common Snippet", - crate::cli::tui::app::EditorKind::Json, - "{}", - EditorSubmit::ConfigCommonSnippet { - app_type: AppType::Claude, - source: crate::cli::tui::app::CommonSnippetViewSource::ProviderForm, - }, - ); - - let mut ctx = RuntimeActionContext { - terminal: &mut fixture.terminal, - app: &mut fixture.app, - data: &mut fixture.data, - speedtest_req_tx: None, - stream_check_req_tx: None, - skills_req_tx: None, - proxy_req_tx: None, - proxy_loading: &mut fixture.proxy_loading, - local_env_req_tx: None, - session_req_tx: None, - webdav_req_tx: None, - webdav_loading: &mut fixture.webdav_loading, - update_req_tx: None, - update_check: &mut fixture.update_check, - model_fetch_req_tx: None, - managed_auth_req_tx: None, - }; - - super::extract_common_snippet_into_editor(&mut ctx, AppType::Claude) - .expect("extract common snippet into editor"); - - let content = ctx.app.editor.as_ref().expect("editor remains open").text(); - assert!(content.contains("COMMON_FLAG")); - assert!(!content.contains("ANTHROPIC_BASE_URL")); - assert!(!content.contains("ANTHROPIC_AUTH_TOKEN")); - assert!( - ctx.data.config.common_snippet.trim().is_empty(), - "extracting into the editor should not persist before Ctrl+S" - ); - } - #[test] #[serial(home_settings)] fn submit_prompt_create_persists_prompt_and_refreshes_selection() { @@ -3947,177 +3635,4 @@ mod tests { Some(&Value::String("./workspace-next".to_string())) ); } - - #[test] - #[serial(home_settings)] - fn submit_provider_form_apply_json_keeps_common_snippet_out_of_raw_submit_payload() { - let mut fixture = runtime_ctx(AppType::Claude); - - fixture.data.config.common_snippet = r#"{ - "alwaysThinkingEnabled": false, - "env": { - "COMMON_FLAG": "1" - } - }"# - .to_string(); - - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude); - form.id.set("p1"); - form.name.set("Provider One"); - form.include_common_config = true; - form.claude_base_url.set("https://provider.example"); - fixture.app.form = Some(FormState::ProviderAdd(form)); - - let mut ctx = RuntimeActionContext { - terminal: &mut fixture.terminal, - app: &mut fixture.app, - data: &mut fixture.data, - speedtest_req_tx: None, - stream_check_req_tx: None, - skills_req_tx: None, - proxy_req_tx: None, - proxy_loading: &mut fixture.proxy_loading, - local_env_req_tx: None, - session_req_tx: None, - webdav_req_tx: None, - webdav_loading: &mut fixture.webdav_loading, - update_req_tx: None, - update_check: &mut fixture.update_check, - model_fetch_req_tx: None, - managed_auth_req_tx: None, - }; - - submit_provider_form_apply_json( - &mut ctx, - r#"{ - "alwaysThinkingEnabled": false, - "env": { - "ANTHROPIC_BASE_URL": "https://edited.example", - "COMMON_FLAG": "1", - "EXTRA_FIELD": "kept" - } - }"# - .to_string(), - ) - .expect("apply should succeed"); - - let FormState::ProviderAdd(form) = ctx - .app - .form - .as_ref() - .expect("provider form should remain open") - else { - panic!("expected provider form"); - }; - let settings = form - .to_provider_json_value() - .get("settingsConfig") - .cloned() - .expect("settingsConfig should exist"); - - assert!( - settings.get("alwaysThinkingEnabled").is_none(), - "applying preview JSON should not persist top-level common snippet keys into raw form payload" - ); - assert!( - settings["env"].get("COMMON_FLAG").is_none(), - "applying preview JSON should not persist nested common snippet keys into raw form payload" - ); - assert_eq!( - settings["env"]["ANTHROPIC_BASE_URL"], "https://edited.example", - "provider-specific edits from the preview editor should still be preserved" - ); - assert_eq!( - settings["env"]["EXTRA_FIELD"], "kept", - "non-common keys introduced in the preview editor should still be preserved" - ); - } - - #[test] - #[serial(home_settings)] - fn submit_provider_form_apply_json_materializes_inferred_common_config_state() { - let mut fixture = runtime_ctx(AppType::Claude); - - fixture.data.config.common_snippet = r#"{ - "alwaysThinkingEnabled": false, - "env": { - "COMMON_FLAG": "1" - } - }"# - .to_string(); - - let provider = Provider::with_id( - "legacy-provider".to_string(), - "Legacy Provider".to_string(), - json!({ - "alwaysThinkingEnabled": false, - "env": { - "ANTHROPIC_BASE_URL": "https://provider.example", - "COMMON_FLAG": "1" - } - }), - None, - ); - fixture.app.form = Some(FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::from_provider(AppType::Claude, &provider), - )); - - let mut ctx = RuntimeActionContext { - terminal: &mut fixture.terminal, - app: &mut fixture.app, - data: &mut fixture.data, - speedtest_req_tx: None, - stream_check_req_tx: None, - skills_req_tx: None, - proxy_req_tx: None, - proxy_loading: &mut fixture.proxy_loading, - local_env_req_tx: None, - session_req_tx: None, - webdav_req_tx: None, - webdav_loading: &mut fixture.webdav_loading, - update_req_tx: None, - update_check: &mut fixture.update_check, - model_fetch_req_tx: None, - managed_auth_req_tx: None, - }; - - submit_provider_form_apply_json( - &mut ctx, - r#"{ - "alwaysThinkingEnabled": false, - "env": { - "ANTHROPIC_BASE_URL": "https://edited.example", - "COMMON_FLAG": "1" - } - }"# - .to_string(), - ) - .expect("apply should succeed"); - - let FormState::ProviderAdd(form) = ctx - .app - .form - .as_ref() - .expect("provider form should remain open") - else { - panic!("expected provider form"); - }; - let raw = form.to_provider_json_value(); - assert_eq!( - raw["meta"]["commonConfigEnabled"], true, - "upstream materializes the inferred common config state when the provider form is edited" - ); - assert!( - raw["settingsConfig"].get("alwaysThinkingEnabled").is_none(), - "materialized sharing keeps common top-level values out of raw provider settings" - ); - assert!( - raw["settingsConfig"]["env"].get("COMMON_FLAG").is_none(), - "materialized sharing keeps common environment values out of raw provider settings" - ); - assert_eq!( - raw["settingsConfig"]["env"]["ANTHROPIC_BASE_URL"], "https://edited.example", - "provider-specific edits from the settings JSON editor should still be preserved" - ); - } } diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index dcfdb223..ce848c59 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -232,13 +232,15 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option } match app_type { - AppType::Claude => serde_json::from_str::(common_snippet).ok(), AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw | AppType::Pi => { - None - } + AppType::Claude + | AppType::Codex + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw + | AppType::Pi => None, } .filter(Value::is_object) } @@ -2017,6 +2019,9 @@ pub(crate) fn provider_field_label_and_value( ProviderAddField::CodexAdvancedDivider => "- - - - - - - - -".to_string(), ProviderAddField::HermesAdvancedDivider => "- - - - - - - - -".to_string(), ProviderAddField::CommonConfigDivider => "- - - - - - - - -".to_string(), + ProviderAddField::ClaudeInactiveFields => { + crate::t!("Inactive fields", "未应用字段").to_string() + } ProviderAddField::CommonSnippet => texts::tui_config_item_common_snippet().to_string(), ProviderAddField::IncludeCommonConfig => texts::tui_form_attach_common_config().to_string(), ProviderAddField::UsageQueryDivider => "- - - - - - - - -".to_string(), @@ -2168,7 +2173,9 @@ pub(crate) fn provider_field_label_and_value( } ProviderAddField::HermesAdvancedDivider => "- - - - - - - - - -".to_string(), ProviderAddField::CommonConfigDivider => "- - - - - - - - - -".to_string(), - ProviderAddField::CommonSnippet => format!("{} ›", texts::tui_key_open()), + ProviderAddField::ClaudeInactiveFields | ProviderAddField::CommonSnippet => { + format!("{} ›", texts::tui_key_open()) + } ProviderAddField::UsageQueryDivider => String::new(), ProviderAddField::TemplateDivider => String::new(), // Plain-text fallback; the row itself renders chip-styled spans and diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index 398d2e3c..91e96694 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -44,6 +44,7 @@ pub(crate) fn add_form_key_items( | ProviderAddField::CodexOAuthAccount | ProviderAddField::CodexLocalRouting | ProviderAddField::LocalProxySettings + | ProviderAddField::ClaudeInactiveFields | ProviderAddField::CommonSnippet | ProviderAddField::UsageQuery | ProviderAddField::OpenClawModels diff --git a/src-tauri/src/cli/tui/ui/overlay/basic.rs b/src-tauri/src/cli/tui/ui/overlay/basic.rs index f6f486b9..8286f240 100644 --- a/src-tauri/src/cli/tui/ui/overlay/basic.rs +++ b/src-tauri/src/cli/tui/ui/overlay/basic.rs @@ -45,6 +45,11 @@ pub(super) fn render_confirm_overlay( confirm: &crate::cli::tui::app::ConfirmOverlay, ) { let keys: &[(&str, &str)] = match &confirm.action { + ConfirmAction::ClaudeEditorConflict { .. } => &[ + ("M", crate::t!("Keep mine", "保留我的修改")), + ("T", crate::t!("Keep external", "保留外部修改")), + ("Esc", texts::tui_key_cancel()), + ], ConfirmAction::EditorSaveBeforeClose | ConfirmAction::FormSaveBeforeClose => &[ ("Enter", texts::tui_key_save_and_exit()), ("N", texts::tui_key_exit_without_save()), diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index b34d5419..fe77a81e 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -3220,18 +3220,21 @@ fn codex_provider_form_renders_toml_values_in_plaintext() { let mut app = App::new(Some(AppType::Codex)); app.route = Route::Providers; app.focus = Focus::Content; - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Codex); - form.focus = FormFocus::JsonPreview; - form.codex_preview_section = crate::cli::tui::form::CodexPreviewSection::Config; - form.extra = json!({ - "settingsConfig": { + let provider = crate::provider::Provider::with_id( + "custom".into(), + "Custom".into(), + json!({ "auth": {}, "config": format!( "experimental_bearer_token = \"{secret}\"\nmodel = \"gpt-5.4\"\n\n[cookies]\nsession = \"{plural_container_secret}\"\n" ) - } - }); - form.codex_model.set("gpt-5.4"); + }), + None, + ); + let mut form = + crate::cli::tui::form::ProviderAddFormState::from_provider(AppType::Codex, &provider); + form.focus = FormFocus::JsonPreview; + form.codex_preview_section = crate::cli::tui::form::CodexPreviewSection::Config; app.form = Some(FormState::ProviderAdd(form)); let all = all_text(&render_with_size( @@ -4402,11 +4405,11 @@ fn provider_form_fields_show_dashed_divider_before_common_snippet() { let _lock = lock_env(); let _no_color = EnvGuard::remove("NO_COLOR"); - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; app.form = Some(crate::cli::tui::form::FormState::ProviderAdd( - crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude), + crate::cli::tui::form::ProviderAddFormState::new(AppType::Gemini), )); let data = minimal_data(&app.app_type); @@ -4504,20 +4507,18 @@ fn provider_form_json_preview_highlights_common_config_lines() { let _lock = lock_env(); let _no_color = EnvGuard::remove("NO_COLOR"); - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Providers; app.focus = Focus::Content; - let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Claude); + let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Gemini); form.focus = FormFocus::JsonPreview; form.include_common_config = true; - form.claude_api_key.set("sk-provider"); + form.gemini_api_key.set("sk-provider"); app.form = Some(FormState::ProviderAdd(form)); let mut data = minimal_data(&app.app_type); data.config.common_snippet = r#"{ - "env": { - "COMMON_FLAG": "1" - } + "COMMON_FLAG": "1" }"# .to_string(); @@ -4532,7 +4533,7 @@ fn provider_form_json_preview_highlights_common_config_lines() { common_bg = Some(buf[(x, y)].bg); common_indent_bg = x.checked_sub(1).map(|indent_x| buf[(indent_x, y)].bg); } - if let Some(x) = cell_column_of(&buf, y, "\"ANTHROPIC_AUTH_TOKEN\"") { + if let Some(x) = cell_column_of(&buf, y, "\"GEMINI_API_KEY\"") { provider_key_bg = Some(buf[(x, y)].bg); } } @@ -4927,7 +4928,7 @@ fn configuration_page_groups_actions_with_three_unlabeled_dividers() { let _lang = use_test_language(Language::English); let _no_color = EnvGuard::remove("NO_COLOR"); - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Config; app.focus = Focus::Content; @@ -4962,7 +4963,7 @@ fn configuration_page_hides_dividers_while_filtering() { let _lang = use_test_language(Language::English); let _no_color = EnvGuard::remove("NO_COLOR"); - let mut app = App::new(Some(AppType::Claude)); + let mut app = App::new(Some(AppType::Gemini)); app.route = Route::Config; app.focus = Focus::Content; app.filter.input.set("config".to_string()); diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index e4dbcb87..a405585f 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -1728,8 +1728,6 @@ pub fn update_codex_config_snippet( base_url: &str, model: &str, wire_api: &str, - requires_openai_auth: bool, - env_key: &str, ) -> String { let mut doc = match original.trim().parse::() { Ok(doc) => doc, @@ -1778,17 +1776,6 @@ pub fn update_codex_config_snippet( } section.insert("wire_api", toml_edit::value(wire_api)); - section.insert( - "requires_openai_auth", - toml_edit::value(requires_openai_auth), - ); - - if requires_openai_auth { - section.remove("env_key"); - } else { - let env_key = non_empty(env_key).unwrap_or("OPENAI_API_KEY"); - section.insert("env_key", toml_edit::value(env_key)); - } } else { if let Some(base_url) = non_empty(base_url) { doc["base_url"] = toml_edit::value(base_url); @@ -1797,13 +1784,6 @@ pub fn update_codex_config_snippet( } doc["wire_api"] = toml_edit::value(wire_api); - doc["requires_openai_auth"] = toml_edit::value(requires_openai_auth); - - if requires_openai_auth { - doc.remove("env_key"); - } else { - doc["env_key"] = toml_edit::value(non_empty(env_key).unwrap_or("OPENAI_API_KEY")); - } } let result = doc.to_string(); @@ -2331,8 +2311,6 @@ env_key = "OLD_API_KEY" "https://new.example.com/v1", "gpt-new", "responses", - true, - "OPENAI_API_KEY", ); let table = toml::from_str::(&updated).expect("parse updated config"); @@ -2352,9 +2330,9 @@ env_key = "OLD_API_KEY" table .get("requires_openai_auth") .and_then(|value| value.as_bool()), - Some(true) + Some(false) ); - assert!(table.get("env_key").is_none()); + assert_eq!(table["env_key"].as_str(), Some("OLD_API_KEY")); assert!(table.get("model_provider").is_none()); assert!(table.get("model_providers").is_none()); } diff --git a/src-tauri/src/database/fixtures/upstream_v20_mcp_skills.sql b/src-tauri/src/database/fixtures/upstream_v22_mcp_skills.sql similarity index 85% rename from src-tauri/src/database/fixtures/upstream_v20_mcp_skills.sql rename to src-tauri/src/database/fixtures/upstream_v22_mcp_skills.sql index 888dc542..21d93d5f 100644 --- a/src-tauri/src/database/fixtures/upstream_v20_mcp_skills.sql +++ b/src-tauri/src/database/fixtures/upstream_v22_mcp_skills.sql @@ -1,5 +1,5 @@ -- MCP/Skills DDL from farion1231/cc-switch at --- d35726e28695844deaf0098450b34911f5be7b78 (schema 20). +-- e4873bc8aa2555225d4867ec2f04b1290f1d21d9 (schema 22). CREATE TABLE IF NOT EXISTS mcp_servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]', @@ -7,6 +7,8 @@ CREATE TABLE IF NOT EXISTS mcp_servers ( enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, + enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, enabled_pi BOOLEAN NOT NULL DEFAULT 0 ); @@ -26,6 +28,8 @@ CREATE TABLE IF NOT EXISTS skills ( enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, + enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 6ee2f8cf..a02ba53b 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -60,7 +60,7 @@ const USAGE_MAINTENANCE_INTERVAL_SECS: u64 = 24 * 60 * 60; /// 注意:本库 schema 与上游项目同步(WebDAV 亦会整库同步),本仓库不得自行 /// 加表/加列或提升版本号;本地新增的持久化需求一律放独立 sidecar 存储 /// (如 session_manager::scan_cache_store)。 -pub(crate) const SCHEMA_VERSION: i32 = 20; +pub(crate) const SCHEMA_VERSION: i32 = 22; fn database_open_flags() -> OpenFlags { OpenFlags::SQLITE_OPEN_READ_WRITE diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 1b85f0e2..56cce7b3 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -61,6 +61,8 @@ impl Database { enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, + enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, enabled_pi BOOLEAN NOT NULL DEFAULT 0 )", @@ -92,6 +94,8 @@ impl Database { enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0, enabled_mcode BOOLEAN NOT NULL DEFAULT 0, + enabled_agy BOOLEAN NOT NULL DEFAULT 0, + enabled_kimi BOOLEAN NOT NULL DEFAULT 0, enabled_hermes BOOLEAN NOT NULL DEFAULT 0, installed_at INTEGER NOT NULL DEFAULT 0, content_hash TEXT, @@ -558,6 +562,32 @@ impl Database { } Self::set_user_version(conn, 20)?; } + 20 => { + for table in ["mcp_servers", "skills"] { + if Self::table_exists(conn, table)? { + Self::add_column_if_missing( + conn, + table, + "enabled_agy", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + } + Self::set_user_version(conn, 21)?; + } + 21 => { + for table in ["mcp_servers", "skills"] { + if Self::table_exists(conn, table)? { + Self::add_column_if_missing( + conn, + table, + "enabled_kimi", + "BOOLEAN NOT NULL DEFAULT 0", + )?; + } + } + Self::set_user_version(conn, 22)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -1704,6 +1734,14 @@ impl Database { /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_data = [ + ( + "claude-fable-5-1", + "Claude Fable 5.1", + "10", + "50", + "0.25", + "12.50", + ), // Claude Fable 5(Opus 之上的新档) ( "claude-fable-5", @@ -1721,8 +1759,19 @@ impl Database { "1.00", "12.50", ), + // Claude Opus 5.5(2026-09-23 发布;缓存读为 0.05x = $0.20,非常规 0.1x) + ("claude-opus-5-5", "Claude Opus 5.5", "4", "20", "0.20", "5"), // Claude Opus 5(与 Opus 4.8 同价位;fast mode $10/$50 不入表) ("claude-opus-5", "Claude Opus 5", "5", "25", "0.50", "6.25"), + // Claude Sonnet 5.5(缓存读 0.05x = $0.10,非 Sonnet 5 的 $0.20) + ( + "claude-sonnet-5-5", + "Claude Sonnet 5.5", + "2", + "10", + "0.10", + "2.50", + ), // Claude 4.8 系列 ( "claude-opus-4-8", @@ -1808,6 +1857,16 @@ impl Database { "0.10", "1.25", ), + // Claude Haiku 5.5(2026-10-07 发布;按提示词长度分档,表内只存 + // 10 万 token 以内的标准档) + ( + "claude-haiku-5-5", + "Claude Haiku 5.5", + "0.10", + "0.50", + "0.01", + "0.125", + ), // Claude 4 系列 (Legacy Models) ( "claude-opus-4-20250514", @@ -1853,9 +1912,20 @@ impl Database { // GPT-6 Astra 标准价(USD / 1M tokens),与上游一致;不含长上下文和服务档位倍率。 // https://developers.openai.com/api/docs/models/gpt-6-astra ("gpt-6-astra", "GPT-6 Astra", "10", "50", "1", "12.5"), + // GPT-6.1 Sol(2026-09 发布):Standard 短上下文价,cached input 0.05×。 + // https://developers.openai.com/api/docs/models/gpt-6.1-sol + ("gpt-6.1-sol", "GPT-6.1 Sol", "2", "10", "0.10", "2.50"), + // GPT-6.1 Terra(2026-10-26 发布):Standard 短上下文价,cache write 取 + // 30 分钟档(12 小时档为 2 倍)。 + // https://developers.openai.com/api/docs/models/gpt-6.1-terra + ("gpt-6.1-terra", "GPT-6.1 Terra", "1", "5", "0.05", "1.25"), + ("gpt-6-sol", "GPT-6 Sol", "2", "10", "0.20", "2.50"), + ("gpt-6-luna", "GPT-6 Luna", "0.10", "0.50", "0.01", "0.125"), // GPT-5.6 系列(Sol / Terra / Luna,2026-06 发布) // 5.6 家族起 cache write 收 1.25× 输入价(此前 GPT 模型写缓存免费,勿回填旧系列) - ("gpt-5.6-sol", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + // 2026-09-06 审计:Sol 改促销价 4/20/0.40/5(OpenAI 价页原文"至少持续到 + // 2026-11-21"),挂牌价 5/30/0.50/6.25。 + ("gpt-5.6-sol", "GPT-5.6 Sol", "4", "20", "0.40", "5"), // 2026-07-30 OpenAI 降价:luna -80%、terra -20%,sol 不变。 ("gpt-5.6-terra", "GPT-5.6 Terra", "2", "12", "0.20", "2.50"), ( @@ -1866,13 +1936,14 @@ impl Database { "0.02", "0.25", ), - // 裸名 gpt-5.6 是 Sol 的官方别名;effort 后缀与现有 GPT 系列保持一致。 - ("gpt-5.6", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), - ("gpt-5.6-low", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), - ("gpt-5.6-medium", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), - ("gpt-5.6-high", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), - ("gpt-5.6-xhigh", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), - ("gpt-5.6-minimal", "GPT-5.6 Sol", "5", "30", "0.50", "6.25"), + // 裸名 gpt-5.6 是 Sol 的官方别名;effort 后缀与现有 GPT 系列保持一致, + // 必须与 sol 同步改价,否则旧价会压过基础行。 + ("gpt-5.6", "GPT-5.6 Sol", "4", "20", "0.40", "5"), + ("gpt-5.6-low", "GPT-5.6 Sol", "4", "20", "0.40", "5"), + ("gpt-5.6-medium", "GPT-5.6 Sol", "4", "20", "0.40", "5"), + ("gpt-5.6-high", "GPT-5.6 Sol", "4", "20", "0.40", "5"), + ("gpt-5.6-xhigh", "GPT-5.6 Sol", "4", "20", "0.40", "5"), + ("gpt-5.6-minimal", "GPT-5.6 Sol", "4", "20", "0.40", "5"), // GPT-5.5 系列 ("gpt-5.5", "GPT-5.5", "5", "30", "0.50", "0"), ("gpt-5.5-low", "GPT-5.5", "5", "30", "0.50", "0"), @@ -2059,6 +2130,16 @@ impl Database { ("gpt-4.1", "GPT-4.1", "2", "8", "0.50", "0"), ("gpt-4.1-mini", "GPT-4.1 Mini", "0.40", "1.60", "0.10", "0"), ("gpt-4.1-nano", "GPT-4.1 Nano", "0.10", "0.40", "0.025", "0"), + // Gemini 3.8 Flash introduction pricing, valid through 2026-12-31; + // list price from 2027-01-01 is 1.50/7.50/0.15. + ( + "gemini-3.8-flash", + "Gemini 3.8 Flash", + "0.75", + "3.75", + "0.075", + "0", + ), // Gemini 3.7 Flash introduction pricing, valid through 2026-12-31. ( "gemini-3.7-flash", @@ -2265,6 +2346,22 @@ impl Database { "0.0056", "0", ), + ( + "deepseek-flash", + "DeepSeek V4.1 Flash", + "0.3", + "1.2", + "0.006", + "0", + ), + ( + "deepseek-v4.1-flash", + "DeepSeek V4.1 Flash", + "0.3", + "1.2", + "0.006", + "0", + ), // DeepSeek 系列 ( "deepseek-v3.2", @@ -2362,6 +2459,14 @@ impl Database { "0", ), ("kimi-k3", "Kimi K3", "3.00", "15.00", "0.30", "0"), + ( + "kimi-k3.1-pro", + "Kimi K3.1 Pro", + "2.00", + "10.00", + "0.20", + "2.00", + ), // Kimi For Coding 套餐里 K3 的裸名(无 kimi- 前缀),同标准 list 价 ("k3", "Kimi K3", "3.00", "15.00", "0.30", "0"), // 腾讯混元 (Tencent Hunyuan)(官方 CNY 1/4/0.25 按 1 USD ≈ 7.14 折算;Hy3 阶梯计价取最低档) @@ -2405,6 +2510,14 @@ impl Database { ), // MiniMax 官方 PAYG 标准档(≤512K);长上下文阶梯价格暂不由静态目录表达。 ("minimax-m3", "MiniMax M3", "0.30", "1.20", "0.06", "0"), + ( + "minimax-m3.1-flash", + "MiniMax M3.1 Flash", + "0.15", + "0.60", + "0.015", + "0", + ), // GLM (智谱) ("glm-4.7", "GLM-4.7", "0.6", "2.2", "0.11", "0"), ("glm-4.6", "GLM-4.6", "0.6", "2.2", "0.11", "0"), @@ -2413,6 +2526,15 @@ impl Database { ("glm-5.2", "GLM-5.2", "1.4", "4.4", "0.26", "0"), ("glm-5-turbo", "GLM-5-Turbo", "1.2", "4", "0.24", "0"), ("glm-5v-turbo", "GLM-5V-Turbo", "1.2", "4", "0.24", "0"), + ("glm-5.3", "GLM-5.3", "1.4", "4.4", "0.26", "0"), + ( + "glm-5.3-flash", + "GLM-5.3-Flash", + "0.15", + "0.50", + "0.03", + "0", + ), // MiMo (小米) ( "mimo-v2-flash", @@ -2432,6 +2554,30 @@ impl Database { "0.0036", "0", ), + ( + "mimo-v2.6-pro", + "MiMo V2.6 Pro", + "0.435", + "0.87", + "0.0036", + "0", + ), + ( + "mimo-v2.6-flash", + "MiMo V2.6 Flash", + "0.14", + "0.28", + "0.0028", + "0", + ), + ( + "mimo-v2.6-pro-ultraspeed", + "MiMo V2.6 Pro UltraSpeed", + "4.35", + "8.7", + "0.036", + "0", + ), // Qwen 系列 (阿里巴巴) ("qwen3.8-max", "Qwen3.8 Max", "2", "6", "0.25", "2.50"), ("qwen3.7-max", "Qwen3.7 Max", "2.50", "7.50", "0.25", "0"), @@ -2750,6 +2896,92 @@ impl Database { "0.50", "0", ), + // GPT-5.6 Sol 促销价 4/20/0.40/5(至少到 2026-11-21);裸名与 effort + // 后缀行同步,否则旧价会压过基础行。 + ( + "gpt-5.6-sol", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6-low", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6-medium", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6-high", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6-xhigh", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), + ( + "gpt-5.6-minimal", + "GPT-5.6 Sol", + "4", + "20", + "0.40", + "5", + "5", + "30", + "0.50", + "6.25", + ), ( "gpt-5.6-terra", "GPT-5.6 Terra", diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 36fdb814..c9c557f8 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -314,8 +314,16 @@ fn schema_migration_sets_user_version_when_missing() { fn seed_upstream_mcp_skills(conn: &Connection, version: i32) { conn.execute_batch("DROP TABLE IF EXISTS mcp_servers; DROP TABLE IF EXISTS skills;") .expect("remove CLI tables"); - conn.execute_batch(include_str!("fixtures/upstream_v20_mcp_skills.sql")) + conn.execute_batch(include_str!("fixtures/upstream_v22_mcp_skills.sql")) .expect("create upstream tables"); + for (introduced, column) in [(21, "enabled_agy"), (22, "enabled_kimi")] { + if version < introduced { + for table in ["mcp_servers", "skills"] { + conn.execute_batch(&format!("ALTER TABLE {table} DROP COLUMN {column};")) + .expect("restore older upstream schema"); + } + } + } if version < 20 { conn.execute_batch("ALTER TABLE mcp_servers DROP COLUMN enabled_pi;") .expect("restore v19 MCP schema"); @@ -345,6 +353,14 @@ fn seed_upstream_mcp_skills(conn: &Connection, version: i32) { conn.execute_batch("UPDATE mcp_servers SET enabled_pi = 1;") .expect("enable Pi"); } + for (introduced, column) in [(21, "enabled_agy"), (22, "enabled_kimi")] { + if version >= introduced { + for table in ["mcp_servers", "skills"] { + conn.execute_batch(&format!("UPDATE {table} SET {column} = 1;")) + .expect("enable upstream app"); + } + } + } Database::set_user_version(conn, version).expect("set upstream version"); } @@ -354,6 +370,10 @@ fn assert_upstream_mcp_skills(conn: &Connection, original_version: i32) { ("mcp_servers", "enabled_mcode"), ("skills", "enabled_mcode"), ("mcp_servers", "enabled_pi"), + ("mcp_servers", "enabled_agy"), + ("skills", "enabled_agy"), + ("mcp_servers", "enabled_kimi"), + ("skills", "enabled_kimi"), ] { let info = get_column_info(conn, table, column); assert_eq!(info.r#type, "BOOLEAN"); @@ -366,14 +386,19 @@ fn assert_upstream_mcp_skills(conn: &Connection, original_version: i32) { |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("read upstream flags"); - let introduced = if column == "enabled_pi" { 20 } else { 19 }; + let introduced = match column { + "enabled_pi" => 20, + "enabled_agy" => 21, + "enabled_kimi" => 22, + _ => 19, + }; assert_eq!(flags, (1, i64::from(original_version >= introduced))); } } #[test] -fn schema_migration_upstream_v18_v19_v20_preserves_data_and_is_idempotent() { - for version in [18, 19, 20] { +fn schema_migration_upstream_v18_through_v22_preserves_data_and_is_idempotent() { + for version in 18..=22 { let conn = Connection::open_in_memory().unwrap(); seed_upstream_mcp_skills(&conn, version); Database::apply_schema_migrations_on_conn(&conn).expect("migrate upstream schema"); @@ -385,20 +410,24 @@ fn schema_migration_upstream_v18_v19_v20_preserves_data_and_is_idempotent() { let conn = Connection::open_in_memory().unwrap(); Database::set_user_version(&conn, 18).unwrap(); Database::apply_schema_migrations_on_conn(&conn).expect("migrate absent tables"); - seed_upstream_mcp_skills(&conn, 20); + seed_upstream_mcp_skills(&conn, 22); Database::set_user_version(&conn, 18).unwrap(); Database::apply_schema_migrations_on_conn(&conn).expect("migrate existing columns"); - assert_upstream_mcp_skills(&conn, 20); + assert_upstream_mcp_skills(&conn, 22); } #[test] -fn schema_create_tables_matches_upstream_v20_flags() { +fn schema_create_tables_matches_upstream_v22_flags() { let conn = Connection::open_in_memory().unwrap(); Database::create_tables_on_conn(&conn).unwrap(); for (table, column) in [ ("mcp_servers", "enabled_mcode"), ("skills", "enabled_mcode"), ("mcp_servers", "enabled_pi"), + ("mcp_servers", "enabled_agy"), + ("skills", "enabled_agy"), + ("mcp_servers", "enabled_kimi"), + ("skills", "enabled_kimi"), ] { let info = get_column_info(&conn, table, column); assert_eq!(info.r#type, "BOOLEAN"); @@ -409,12 +438,12 @@ fn schema_create_tables_matches_upstream_v20_flags() { } #[test] -fn init_and_sync_import_accept_upstream_v18_v19_v20() { +fn init_and_sync_import_accept_upstream_v18_through_v22() { let home = tempfile::tempdir().unwrap(); let _env = crate::test_support::TestEnvGuard::isolated(home.path()); let config_dir = home.path().join(".cc-switch"); std::fs::create_dir_all(&config_dir).unwrap(); - for version in [18, 19, 20] { + for version in 18..=22 { let conn = Connection::open(config_dir.join("cc-switch.db")).unwrap(); Database::create_tables_on_conn(&conn).unwrap(); seed_upstream_mcp_skills(&conn, version); @@ -2318,8 +2347,8 @@ fn mcp_dao_roundtrip_preserves_upstream_enablement() { "INSERT INTO mcp_servers ( id, name, server_config, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, - enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi - ) VALUES (?1, ?2, ?3, ?4, 0, 0, 0, 1, 0, 1, 1, 1)", + enabled_opencode, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi + ) VALUES (?1, ?2, ?3, ?4, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1)", params![ "remote-hermes", "Remote Hermes", @@ -2346,18 +2375,18 @@ fn mcp_dao_roundtrip_preserves_upstream_enablement() { server.description = Some("updated".to_string()); db.save_mcp_server(&server).expect("save mcp server"); - let hidden_enablement: (i64, i64, i64, i64) = { + let hidden_enablement: (i64, i64, i64, i64, i64, i64) = { let conn = db.conn.lock().expect("lock conn"); conn.query_row( - "SELECT enabled_grokbuild, enabled_hermes, enabled_mcode, enabled_pi FROM mcp_servers WHERE id = 'remote-hermes'", + "SELECT enabled_grokbuild, enabled_hermes, enabled_mcode, enabled_pi, enabled_agy, enabled_kimi FROM mcp_servers WHERE id = 'remote-hermes'", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)), ) .expect("read hidden enablement after save") }; assert_eq!( hidden_enablement, - (1, 1, 1, 1), + (1, 1, 1, 1, 1, 1), "save should preserve hidden flags" ); } @@ -2369,8 +2398,8 @@ fn skill_dao_save_preserves_upstream_enablement() { let conn = db.conn.lock().expect("lock conn"); conn.execute( "INSERT INTO skills ( - id, name, directory, enabled_grokbuild, enabled_hermes, enabled_mcode, installed_at - ) VALUES ('remote-skill', 'Remote Skill', 'remote-skill', 1, 1, 1, 42)", + id, name, directory, enabled_grokbuild, enabled_hermes, enabled_mcode, enabled_agy, enabled_kimi, installed_at + ) VALUES ('remote-skill', 'Remote Skill', 'remote-skill', 1, 1, 1, 1, 1, 42)", [], ) .expect("seed grokbuild-enabled Skill"); @@ -2383,18 +2412,18 @@ fn skill_dao_save_preserves_upstream_enablement() { skill.description = Some("updated".to_string()); db.save_skill(&skill).expect("save Skill"); - let hidden_enablement: (i64, i64, i64) = { + let hidden_enablement: (i64, i64, i64, i64, i64) = { let conn = db.conn.lock().expect("lock conn"); conn.query_row( - "SELECT enabled_grokbuild, enabled_hermes, enabled_mcode FROM skills WHERE id = 'remote-skill'", + "SELECT enabled_grokbuild, enabled_hermes, enabled_mcode, enabled_agy, enabled_kimi FROM skills WHERE id = 'remote-skill'", [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)), ) .expect("read hidden enablement after save") }; assert_eq!( hidden_enablement, - (1, 1, 1), + (1, 1, 1, 1, 1), "save should preserve hidden flags" ); } @@ -3453,11 +3482,11 @@ fn model_pricing_seeds_gpt_5_6_family_and_aliases() { let conn = db.conn.lock().expect("lock conn"); let expected = [ - ("gpt-5.6-sol", "5", "30", "0.50", "6.25"), + ("gpt-5.6-sol", "4", "20", "0.40", "5"), ("gpt-5.6-terra", "2", "12", "0.20", "2.50"), ("gpt-5.6-luna", "0.20", "1.20", "0.02", "0.25"), - ("gpt-5.6", "5", "30", "0.50", "6.25"), - ("gpt-5.6-high", "5", "30", "0.50", "6.25"), + ("gpt-5.6", "4", "20", "0.40", "5"), + ("gpt-5.6-high", "4", "20", "0.40", "5"), ]; for (model_id, input, output, cache_read, cache_write) in expected { @@ -3490,6 +3519,24 @@ fn model_pricing_seeds_synced_upstream_catalog() { let expected = [ ("gpt-6-astra", "10", "50", "1", "12.5"), + ("gpt-6.1-sol", "2", "10", "0.10", "2.50"), + ("gpt-6.1-terra", "1", "5", "0.05", "1.25"), + ("gpt-6-sol", "2", "10", "0.20", "2.50"), + ("gpt-6-luna", "0.10", "0.50", "0.01", "0.125"), + ("claude-opus-5-5", "4", "20", "0.20", "5"), + ("claude-sonnet-5-5", "2", "10", "0.10", "2.50"), + ("claude-haiku-5-5", "0.10", "0.50", "0.01", "0.125"), + ("gemini-3.8-flash", "0.75", "3.75", "0.075", "0"), + ("claude-fable-5-1", "10", "50", "0.25", "12.50"), + ("deepseek-flash", "0.3", "1.2", "0.006", "0"), + ("deepseek-v4.1-flash", "0.3", "1.2", "0.006", "0"), + ("kimi-k3.1-pro", "2.00", "10.00", "0.20", "2.00"), + ("minimax-m3.1-flash", "0.15", "0.60", "0.015", "0"), + ("glm-5.3", "1.4", "4.4", "0.26", "0"), + ("glm-5.3-flash", "0.15", "0.50", "0.03", "0"), + ("mimo-v2.6-pro", "0.435", "0.87", "0.0036", "0"), + ("mimo-v2.6-flash", "0.14", "0.28", "0.0028", "0"), + ("mimo-v2.6-pro-ultraspeed", "4.35", "8.7", "0.036", "0"), ("claude-fable-5", "10", "50", "1.00", "12.50"), ("claude-opus-4-6", "5", "25", "0.50", "6.25"), ("claude-sonnet-4-6", "3", "15", "0.30", "3.75"), @@ -3541,6 +3588,59 @@ fn model_pricing_seeds_synced_upstream_catalog() { } } +#[test] +fn model_pricing_refreshed_presets_resolve_and_preserve_custom_prices() { + use crate::proxy::usage::calculator::lookup_model_pricing; + + let db = Database::memory().expect("create memory db"); + for model in [ + "claude-fable-5-1", + "deepseek-flash", + "deepseek-v4.1-flash", + "kimi-k3.1-pro", + "MiniMax-M3.1-Flash", + "glm-5.3", + "glm-5.3-flash", + "mimo-v2.6-pro", + "mimo-v2.6-flash", + "mimo-v2.6-pro-ultraspeed", + ] { + let decorated = format!("vendor/{model}[1M]"); + let pricing = lookup_model_pricing(&db, &decorated).expect(model); + assert!(pricing.input_cost_per_million > rust_decimal::Decimal::ZERO); + assert!(pricing.output_cost_per_million > rust_decimal::Decimal::ZERO); + + let custom = ModelPricingUpdate::new( + &model.to_ascii_lowercase(), + "Custom", + "123", + "456", + "7", + "8", + ) + .unwrap(); + db.upsert_model_pricing(&custom).unwrap(); + db.ensure_model_pricing_seeded().unwrap(); + let pricing = lookup_model_pricing(&db, &decorated).unwrap(); + assert_eq!( + pricing.input_cost_per_million, + rust_decimal::Decimal::from(123) + ); + assert_eq!( + pricing.output_cost_per_million, + rust_decimal::Decimal::from(456) + ); + assert_eq!( + pricing.cache_read_cost_per_million, + rust_decimal::Decimal::from(7) + ); + assert_eq!( + pricing.cache_creation_cost_per_million, + rust_decimal::Decimal::from(8) + ); + } +} + #[test] fn model_pricing_repairs_synced_values_without_overwriting_custom_prices() { let db = Database::memory().expect("create memory db"); @@ -3680,7 +3780,11 @@ fn model_pricing_repairs_only_untouched_upstream_gpt_5_6_seeds() { { let conn = db.conn.lock().expect("lock conn"); conn.execute( - "UPDATE model_pricing SET cache_creation_cost_per_million = '0' + "UPDATE model_pricing + SET input_cost_per_million = '5', + output_cost_per_million = '30', + cache_read_cost_per_million = '0.50', + cache_creation_cost_per_million = '0' WHERE model_id = 'gpt-5.6-sol'", [], ) @@ -3711,15 +3815,24 @@ fn model_pricing_repairs_only_untouched_upstream_gpt_5_6_seeds() { .expect("ensure pricing seeded"); let conn = db.conn.lock().expect("lock conn"); - let sol_cache_write: String = conn + let sol: (String, String, String, String) = conn .query_row( - "SELECT cache_creation_cost_per_million FROM model_pricing - WHERE model_id = 'gpt-5.6-sol'", + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing WHERE model_id = 'gpt-5.6-sol'", [], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .expect("query repaired Sol pricing"); - assert_eq!(sol_cache_write, "6.25"); + assert_eq!( + sol, + ( + "4".to_string(), + "20".to_string(), + "0.40".to_string(), + "5".to_string() + ) + ); let terra: (String, String, String, String) = conn .query_row( diff --git a/src-tauri/src/live/project/codex.rs b/src-tauri/src/live/project/codex.rs index 7e729597..f95278e7 100644 --- a/src-tauri/src/live/project/codex.rs +++ b/src-tauri/src/live/project/codex.rs @@ -58,7 +58,7 @@ const ROW_TOP_FIELDS: &[&str] = &[ "disable_response_storage", ]; -fn is_built_in_id(id: &str) -> bool { +pub(crate) fn is_built_in_id(id: &str) -> bool { BUILT_IN_IDS.contains(&id) } diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 83329577..2b05c183 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -1105,7 +1105,8 @@ fn json_value_to_toml_item(value: &Value, field_name: &str) -> Option Result { @@ -1113,7 +1114,11 @@ fn json_server_to_toml_table(spec: &Value) -> Result let mut t = Table::new(); let typ = spec.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); - t["type"] = toml_edit::value(typ); + // Codex 的 `[mcp_servers.]` 不认识 `type` 字段:普通模式下会告警 + // "is ignored",`--strict-config` 下直接校验失败。传输方式由 Codex 根据 + // `command`(stdio)或 `url`(streamable HTTP)自行推断,导入侧 + // (`import_from_codex`)在缺少 `type` 时也按同样规则推断,因此这里不写出 + // `type`,只用它来决定写哪些核心字段。 // 定义核心字段(已在下方处理,跳过通用转换) let core_fields = match typ { @@ -1813,6 +1818,38 @@ mod codex_mcp_tests { assert_eq!(doc.to_string(), "mcp_servers = 42\n"); } + #[test] + fn codex_toml_omits_unsupported_type_field() { + // Codex rejects `mcp_servers..type` under `--strict-config` and warns + // about it otherwise; the transport is inferred from `command` / `url`. + for (spec, expect_command, expect_url) in [ + ( + json!({ "type": "stdio", "command": "npx", "args": ["-y", "x"] }), + true, + false, + ), + (json!({ "command": "npx" }), true, false), + ( + json!({ "type": "http", "url": "https://mcp.example.com/mcp" }), + false, + true, + ), + ( + json!({ "type": "sse", "url": "https://mcp.example.com/sse" }), + false, + true, + ), + ] { + let table = json_server_to_toml_table(&spec).expect("server table"); + assert!( + table.get("type").is_none(), + "`type` must not be written to Codex TOML for {spec}" + ); + assert_eq!(table.contains_key("command"), expect_command, "{spec}"); + assert_eq!(table.contains_key("url"), expect_url, "{spec}"); + } + } + #[test] fn http_headers_are_only_written_to_codex_http_headers() { let table = json_server_to_toml_table(&json!({ diff --git a/src-tauri/src/model_capabilities.rs b/src-tauri/src/model_capabilities.rs index 437bdf84..9b97a265 100644 --- a/src-tauri/src/model_capabilities.rs +++ b/src-tauri/src/model_capabilities.rs @@ -75,12 +75,16 @@ pub(crate) fn is_confirmed_text_only_model(model: &str) -> bool { "ark-code-latest", "deepseek-chat", "deepseek-reasoner", + // `deepseek-flash` and its `deepseek-v4.1-flash` alias are deliberately + // absent: the vendor's current catalog serves them with image input. "deepseek-v4-flash", "deepseek-v4-pro", "glm-5.1", // Exact rather than prefix matching: GLM visual models use a `v` // suffix (for example glm-5.2v), which must remain image-capable. + // GLM-5.3 is text-only; GLM-5.3-Flash is multimodal and stays out. "glm-5.2", + "glm-5.3", "kat-coder", "kat-coder-pro", "kat-coder-pro v1", @@ -216,7 +220,12 @@ mod tests { #[test] fn confirmed_text_only_registry_normalizes_namespaces_and_context_markers() { assert!(is_confirmed_text_only_model("deepseek/deepseek-v4-pro")); + assert!(!is_confirmed_text_only_model("deepseek-flash")); + assert!(!is_confirmed_text_only_model( + "opencode/deepseek-v4.1-flash" + )); assert!(is_confirmed_text_only_model("GLM-5.2[1M]")); + assert!(is_confirmed_text_only_model("z-ai/glm-5.3[1M]")); assert!(is_confirmed_text_only_model("qwen/qwen3-coder-plus")); assert!(is_confirmed_text_only_model( "Qwen/Qwen3-Coder-480B-A35B-Instruct" @@ -224,6 +233,9 @@ mod tests { assert!(is_confirmed_text_only_model("MiniMax-M2.7-Highspeed")); assert!(is_confirmed_text_only_model("step-3.5-flash-2603")); assert!(!is_confirmed_text_only_model("glm-5.2v")); + assert!(!is_confirmed_text_only_model("glm-5.3-flash")); + assert!(!is_confirmed_text_only_model("mimo-v2.6-pro")); + assert!(!is_confirmed_text_only_model("MiniMax-M3.1-Flash")); } #[test] diff --git a/src-tauri/src/presets/88api.json b/src-tauri/src/presets/88api.json index 1c568b6e..bfaecc61 100644 --- a/src-tauri/src/presets/88api.json +++ b/src-tauri/src/presets/88api.json @@ -55,9 +55,22 @@ ] } }, - "claude-haiku-4-5": { - "name": "Claude Haiku 4.5", - "reasoning": true + "claude-haiku-5-5": { + "name": "Claude Haiku 5.5", + "reasoning": true, + "limit": { + "context": 1000000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + } }, "claude-fable-5-1": { "name": "Claude Fable 5.1", @@ -106,9 +119,15 @@ "maxTokens": 128000 }, { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5", - "contextWindow": 200000 + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "reasoning": true, + "input": [ + "text", + "image" + ], + "contextWindow": 1000000, + "maxTokens": 128000 }, { "id": "claude-fable-5-1", @@ -140,9 +159,9 @@ "context_length": 1000000 }, { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5", - "context_length": 200000 + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "context_length": 1000000 }, { "id": "claude-fable-5-1", @@ -180,15 +199,15 @@ "maxTokens": 128000 }, { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5", + "id": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", "reasoning": true, "input": [ "text", "image" ], - "contextWindow": 200000, - "maxTokens": 64000 + "contextWindow": 1000000, + "maxTokens": 128000 }, { "id": "claude-fable-5-1", diff --git a/src-tauri/src/presets/README.md b/src-tauri/src/presets/README.md index e94afde6..1794833f 100644 --- a/src-tauri/src/presets/README.md +++ b/src-tauri/src/presets/README.md @@ -1,12 +1,12 @@ # 88API preset data `88api.json` mirrors the native settings in `farion1231/cc-switch` at -`df40d53c36b604e044b4d5b9091aeb961aa56990`, from +`e217bfcbbbbbf82f4619ab62f08fb002635b7858`, from `src/config/{claude,gemini,opencode,openclaw,hermes,pi}ProviderPresets.ts`. Pi model capabilities are expanded from `src/config/piModelCatalog.ts`; TypeScript-only model catalog references are not native configuration fields. Codex uses the existing shared TOML builder with `88api`, -`https://api.88api.ai/v1`, and `gpt-5.6-sol`, matching the upstream preset. +`https://api.88api.ai/v1`, and `gpt-6.1-sol`, matching the upstream preset. The CLI uses its existing native configuration writers and form serialization. OpenClaw's separate suggested-defaults metadata is not a provider settings field; diff --git a/src-tauri/src/provider_preset_builtin.rs b/src-tauri/src/provider_preset_builtin.rs index 1494bd62..f2a27028 100644 --- a/src-tauri/src/provider_preset_builtin.rs +++ b/src-tauri/src/provider_preset_builtin.rs @@ -100,10 +100,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.deepseek.com", "https://api.deepseek.com/anthropic", "ANTHROPIC_AUTH_TOKEN", - "deepseek-v4-pro", - "deepseek-v4-flash", - "deepseek-v4-pro", - "deepseek-v4-pro", + "deepseek-flash[1M]", + "deepseek-flash[1M]", + "deepseek-flash[1M]", + "deepseek-flash[1M]", "cn_official", "deepseek", "#1E88E5", @@ -114,10 +114,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://open.bigmodel.cn", "https://open.bigmodel.cn/api/anthropic", "ANTHROPIC_AUTH_TOKEN", - "glm-5.1", - "glm-5.1", - "glm-5.1", - "glm-5.1", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", "cn_official", "zhipu", "#0F62FE", @@ -128,10 +128,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://z.ai", "https://api.z.ai/api/anthropic", "ANTHROPIC_AUTH_TOKEN", - "glm-5.1", - "glm-5.1", - "glm-5.1", - "glm-5.1", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", + "glm-5.3[1M]", "cn_official", "zhipu", "#0F62FE", @@ -142,10 +142,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://modelscope.cn", "https://api-inference.modelscope.cn", "ANTHROPIC_AUTH_TOKEN", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", - "ZhipuAI/GLM-5.2", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", + "ZhipuAI/GLM-5.2[1M]", "aggregator", "modelscope", "#624AFF", @@ -156,10 +156,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.minimaxi.com", "https://api.minimaxi.com/anthropic", "ANTHROPIC_AUTH_TOKEN", - "MiniMax-M2.7", - "MiniMax-M2.7", - "MiniMax-M2.7", - "MiniMax-M2.7", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3.1-Flash[1M]", + "MiniMax-M3[1M]", "cn_official", "minimax", "#FF6B6B", @@ -173,10 +173,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.xiaomimimo.com", "https://api.xiaomimimo.com/anthropic", "ANTHROPIC_AUTH_TOKEN", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", "cn_official", "xiaomimimo", "#000000", @@ -187,10 +187,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.xiaomimimo.com/#/token-plan", "https://token-plan-cn.xiaomimimo.com/anthropic", "ANTHROPIC_AUTH_TOKEN", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", - "mimo-v2.5-pro", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", + "mimo-v2.6-pro[1M]", "cn_official", "xiaomimimo", "#000000", @@ -201,10 +201,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://opencode.ai/go", "https://opencode.ai/zen/go", "ANTHROPIC_API_KEY", - "deepseek-v4-flash", - "deepseek-v4-flash", - "deepseek-v4-flash", - "deepseek-v4-flash", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", + "deepseek-v4.1-flash[1M]", "third_party", "opencode", "#211E1E", @@ -215,10 +215,10 @@ fn claude_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://openrouter.ai", "https://openrouter.ai/api", "ANTHROPIC_AUTH_TOKEN", - "anthropic/claude-sonnet-5", - "anthropic/claude-haiku-4.5", - "anthropic/claude-sonnet-5", - "anthropic/claude-opus-5", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-haiku-5.5", + "anthropic/claude-sonnet-5.5", + "anthropic/claude-opus-5.5", "aggregator", "openrouter", "#6566F1", @@ -304,26 +304,43 @@ fn chat_reasoning( reasoning } -fn mimo_catalog() -> Vec { +fn mimo_catalog(include_ultraspeed: bool) -> Vec { let base_instructions = "You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024."; - vec![ + let mut models = vec![ json!({ - "model": "mimo-v2.5-pro", - "displayName": "MiMo V2.5 Pro", + "model": "mimo-v2.6-pro", + "displayName": "MiMo V2.6 Pro", "contextWindow": 1_048_576, - "inputModalities": ["text"], - "reasoningLevels": ["none", "high"], + "inputModalities": ["text", "image"], + "supportsParallelToolCalls": false, + "reasoningLevels": ["none", "low", "medium", "high"], + "defaultReasoningLevel": "low", "baseInstructions": base_instructions, }), json!({ - "model": "mimo-v2.5", - "displayName": "MiMo V2.5", + "model": "mimo-v2.6-flash", + "displayName": "MiMo V2.6 Flash", "contextWindow": 1_048_576, "inputModalities": ["text", "image"], - "reasoningLevels": ["none", "high"], + "supportsParallelToolCalls": false, + "reasoningLevels": ["none", "low", "medium", "high"], + "defaultReasoningLevel": "low", "baseInstructions": base_instructions, }), - ] + ]; + if include_ultraspeed { + models.push(json!({ + "model": "mimo-v2.6-pro-ultraspeed", + "displayName": "MiMo V2.6 Pro UltraSpeed", + "contextWindow": 1_048_576, + "inputModalities": ["text", "image"], + "supportsParallelToolCalls": false, + "reasoningLevels": ["none", "low", "medium", "high"], + "defaultReasoningLevel": "low", + "baseInstructions": base_instructions, + })); + } + models } fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { @@ -335,25 +352,18 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.deepseek.com", "deepseek", "https://api.deepseek.com", - "deepseek-v4-flash", + "deepseek-flash", "cn_official", "deepseek", "#1E88E5", Some("openai_responses"), - vec![ - json!({ - "model": "deepseek-v4-flash", - "displayName": "DeepSeek V4 Flash", - "contextWindow": 1_048_576, - "reasoningLevels": ["low", "high", "max"], - }), - json!({ - "model": "deepseek-v4-pro", - "displayName": "DeepSeek V4 Pro", - "contextWindow": 1_048_576, - "reasoningLevels": ["low", "high", "max"], - }), - ], + vec![json!({ + "model": "deepseek-flash", + "displayName": "DeepSeek V4.1 Flash", + "contextWindow": 1_048_576, + "inputModalities": ["text", "image"], + "reasoningLevels": ["low", "high", "max"], + })], None, ), ZhipuGlm | ZhipuGlmEn => { @@ -377,15 +387,15 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { website, provider_name, base_url, - "glm-5.2", + "glm-5.3", "cn_official", "zhipu", "#0F62FE", Some("openai_chat"), vec![json!({ - "model": "glm-5.2", - "displayName": "GLM-5.2", - "contextWindow": 200_000, + "model": "glm-5.3", + "displayName": "GLM-5.3", + "contextWindow": 1_048_576, "reasoningLevels": ["none", "high"], })], Some(chat_reasoning("thinking", false, None)), @@ -413,20 +423,31 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://platform.minimaxi.com", "minimax", "https://api.minimaxi.com/v1", - "MiniMax-M3", + "MiniMax-M3.1-Flash", "cn_official", "minimax", "#FF6B6B", Some("openai_responses"), - vec![json!({ - "model": "MiniMax-M3", - "displayName": "MiniMax-M3", - "contextWindow": 1_000_000, - "reasoningLevels": ["none", "high"], - "supportsParallelToolCalls": true, - "inputModalities": ["text", "image"], - "baseInstructions": "You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.", - })], + vec![ + json!({ + "model": "MiniMax-M3.1-Flash", + "displayName": "MiniMax-M3.1-Flash", + "contextWindow": 1_000_000, + "reasoningLevels": ["low", "medium", "high", "xhigh", "max"], + "supportsParallelToolCalls": true, + "inputModalities": ["text", "image"], + "baseInstructions": "You are Codex, a coding agent based on MiniMax-M3.1-Flash. You and the user share the same workspace and collaborate to achieve the user's goals.", + }), + json!({ + "model": "MiniMax-M3", + "displayName": "MiniMax-M3", + "contextWindow": 1_000_000, + "reasoningLevels": ["none", "high"], + "supportsParallelToolCalls": true, + "inputModalities": ["text", "image"], + "baseInstructions": "You are Codex, a coding agent based on MiniMax-M3. You and the user share the same workspace and collaborate to achieve the user's goals.", + }), + ], None, ), XiaomiMimo | XiaomiMimoTokenPlan => { @@ -450,12 +471,12 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { website, provider_name, base_url, - "mimo-v2.5-pro", + "mimo-v2.6-pro", "cn_official", "xiaomimimo", "#000000", Some("openai_responses"), - mimo_catalog(), + mimo_catalog(preset == XiaomiMimo), None, ) } @@ -464,18 +485,17 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://opencode.ai/go", "opencode_go", "https://opencode.ai/zen/go/v1", - "glm-5.2", + "glm-5.3", "third_party", "opencode", "#211E1E", Some("openai_chat"), vec![ - json!({ "model": "glm-5.2", "displayName": "GLM 5.2", "contextWindow": 204_800, "reasoningLevels": ["high", "max"] }), - json!({ "model": "glm-5.1", "displayName": "GLM 5.1", "contextWindow": 204_800 }), - json!({ "model": "kimi-k2.7-code", "displayName": "Kimi K2.7 Code", "contextWindow": 262_144 }), - json!({ "model": "deepseek-v4-pro", "displayName": "DeepSeek V4 Pro", "contextWindow": 1_048_576, "reasoningLevels": ["high", "max"] }), - json!({ "model": "deepseek-v4-flash", "displayName": "DeepSeek V4 Flash", "contextWindow": 1_048_576, "reasoningLevels": ["low", "high", "max"] }), - json!({ "model": "mimo-v2.5-pro", "displayName": "MiMo V2.5 Pro", "contextWindow": 1_048_576 }), + json!({ "model": "glm-5.3", "displayName": "GLM 5.3", "contextWindow": 1_000_000, "reasoningLevels": ["low", "high", "max"], "defaultReasoningLevel": "high" }), + json!({ "model": "glm-5.3-flash", "displayName": "GLM 5.3 Flash", "contextWindow": 1_000_000, "inputModalities": ["text", "image"], "reasoningLevels": ["low", "high", "max"], "defaultReasoningLevel": "high" }), + json!({ "model": "kimi-k3.1-pro", "displayName": "Kimi K3.1 Pro", "contextWindow": 1_048_576, "inputModalities": ["text", "image"], "reasoningLevels": ["max"] }), + json!({ "model": "deepseek-v4.1-flash", "displayName": "DeepSeek V4.1 Flash", "contextWindow": 1_048_576, "inputModalities": ["text", "image"], "reasoningLevels": ["low", "high", "max"] }), + json!({ "model": "mimo-v2.6-pro", "displayName": "MiMo V2.6 Pro", "contextWindow": 1_048_576, "inputModalities": ["text", "image"] }), ], Some(chat_reasoning("none", true, Some("zen"))), )), @@ -484,7 +504,7 @@ fn codex_provider_preset(preset: BuiltinProviderPresetId) -> Value { "https://openrouter.ai", "openrouter", "https://openrouter.ai/api/v1", - "gpt-5.6-sol", + "openai/gpt-6.1-sol", "aggregator", "openrouter", "#6566F1", diff --git a/src-tauri/src/provider_preset_models.rs b/src-tauri/src/provider_preset_models.rs index ce9c5a61..38403373 100644 --- a/src-tauri/src/provider_preset_models.rs +++ b/src-tauri/src/provider_preset_models.rs @@ -1,25 +1,27 @@ use serde_json::{json, Map, Value}; -pub(crate) const CLAUDE_OPUS_MODEL: &str = "claude-opus-5"; -pub(crate) const CLAUDE_OPUS_NAME: &str = "Claude Opus 5"; -pub(crate) const CLAUDE_SONNET_MODEL: &str = "claude-sonnet-5"; -pub(crate) const CLAUDE_SONNET_NAME: &str = "Claude Sonnet 5"; -pub(crate) const CLAUDE_HAIKU_MODEL: &str = "claude-haiku-4-5"; -pub(crate) const CLAUDE_HAIKU_DATED_MODEL: &str = "claude-haiku-4-5-20251001"; -pub(crate) const CLAUDE_HAIKU_NAME: &str = "Claude Haiku 4.5"; +pub(crate) const CLAUDE_OPUS_MODEL: &str = "claude-opus-5-5"; +pub(crate) const CLAUDE_OPUS_NAME: &str = "Claude Opus 5.5"; +pub(crate) const CLAUDE_SONNET_MODEL: &str = "claude-sonnet-5-5"; +pub(crate) const CLAUDE_SONNET_NAME: &str = "Claude Sonnet 5.5"; +pub(crate) const CLAUDE_HAIKU_MODEL: &str = "claude-haiku-5-5"; +pub(crate) const CLAUDE_HAIKU_NAME: &str = "Claude Haiku 5.5"; -pub(crate) const CODEX_DEFAULT_MODEL: &str = "gpt-5.6-sol"; -pub(crate) const CODEX_DEFAULT_MODEL_NAME: &str = "GPT-5.6 Sol"; -pub(crate) const CODEX_OAUTH_FAST_MODEL: &str = "gpt-5.6-luna"; -pub(crate) const CODEX_OAUTH_CONTEXT_TOKENS: &str = "372000"; +pub(crate) const CODEX_DEFAULT_MODEL: &str = "gpt-6.1-sol"; +pub(crate) const CODEX_DEFAULT_MODEL_NAME: &str = "GPT-6.1 Sol"; +pub(crate) const CODEX_OAUTH_FAST_MODEL: &str = "gpt-6-luna"; +// Codex 订阅后端把 GPT 系窗口封在 272K(超过 272K 输入按 2 倍计价), +// 与上游 cc-switch 的 GPT_CONTEXT_WINDOW_CAP 保持同值。 +pub(crate) const CODEX_OAUTH_CONTEXT_TOKENS: &str = "272000"; -pub(crate) const GEMINI_DEFAULT_MODEL: &str = "gemini-3.6-flash"; +pub(crate) const GEMINI_DEFAULT_MODEL: &str = "gemini-3.8-flash"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SponsorModelFamily { Claude, RunApiClaude, Gpt, + GptAstra, } impl SponsorModelFamily { @@ -28,6 +30,7 @@ impl SponsorModelFamily { Self::Claude => CLAUDE_OPUS_MODEL, Self::RunApiClaude => CLAUDE_SONNET_MODEL, Self::Gpt => CODEX_DEFAULT_MODEL, + Self::GptAstra => "gpt-6-astra", } } @@ -36,13 +39,14 @@ impl SponsorModelFamily { Self::Claude => CLAUDE_OPUS_NAME, Self::RunApiClaude => CLAUDE_SONNET_NAME, Self::Gpt => CODEX_DEFAULT_MODEL_NAME, + Self::GptAstra => "GPT-6 Astra", } } pub(crate) const fn primary_context_window(self) -> &'static str { match self { Self::Claude | Self::RunApiClaude => "1000000", - Self::Gpt => "400000", + Self::Gpt | Self::GptAstra => "272000", } } } @@ -51,11 +55,19 @@ pub(crate) fn sponsor_model_family(preset_id: &str) -> Option Some(SponsorModelFamily::Claude), "runapi" => Some(SponsorModelFamily::RunApiClaude), - "qiniu" | "fenno" => Some(SponsorModelFamily::Gpt), + "qiniu" => Some(SponsorModelFamily::GptAstra), + "fenno" => Some(SponsorModelFamily::Gpt), _ => None, } } +pub(crate) fn sponsor_codex_model(preset_id: &str) -> &'static str { + match sponsor_model_family(preset_id) { + Some(SponsorModelFamily::GptAstra) => SponsorModelFamily::GptAstra.primary_model(), + _ => CODEX_DEFAULT_MODEL, + } +} + pub(crate) fn codex_oauth_claude_env() -> Value { json!({ "ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex", @@ -84,8 +96,12 @@ pub(crate) fn sponsor_opencode_settings( insert_named_model(&mut models, CLAUDE_OPUS_MODEL, CLAUDE_OPUS_NAME); insert_named_model(&mut models, CLAUDE_HAIKU_MODEL, CLAUDE_HAIKU_NAME); } - SponsorModelFamily::Gpt => { - insert_named_model(&mut models, CODEX_DEFAULT_MODEL, CODEX_DEFAULT_MODEL_NAME); + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + insert_named_model( + &mut models, + family.primary_model(), + family.primary_model_name(), + ); } } @@ -94,7 +110,7 @@ pub(crate) fn sponsor_opencode_settings( SponsorModelFamily::Claude | SponsorModelFamily::RunApiClaude => { "@ai-sdk/anthropic" } - SponsorModelFamily::Gpt => "@ai-sdk/openai-compatible", + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => "@ai-sdk/openai-compatible", }, "name": provider_name, "options": { @@ -110,7 +126,7 @@ pub(crate) fn sponsor_hermes_models(family: SponsorModelFamily) -> Vec { SponsorModelFamily::Claude => vec![ named_model(CLAUDE_OPUS_MODEL, CLAUDE_OPUS_NAME), named_model(CLAUDE_SONNET_MODEL, CLAUDE_SONNET_NAME), - named_model(CLAUDE_HAIKU_DATED_MODEL, CLAUDE_HAIKU_NAME), + named_model(CLAUDE_HAIKU_MODEL, CLAUDE_HAIKU_NAME), ], // Hermes derives model.default from the first entry. Upstream keeps a // separate suggested default for RunAPI, so put Sonnet first here to @@ -120,8 +136,11 @@ pub(crate) fn sponsor_hermes_models(family: SponsorModelFamily) -> Vec { named_model(CLAUDE_OPUS_MODEL, CLAUDE_OPUS_NAME), named_model(CLAUDE_HAIKU_MODEL, CLAUDE_HAIKU_NAME), ], - SponsorModelFamily::Gpt => { - vec![named_model(CODEX_DEFAULT_MODEL, CODEX_DEFAULT_MODEL_NAME)] + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => { + vec![named_model( + family.primary_model(), + family.primary_model_name(), + )] } } } @@ -132,20 +151,20 @@ pub(crate) fn sponsor_openclaw_models(family: SponsorModelFamily) -> Vec json!({ "id": CLAUDE_OPUS_MODEL, "name": CLAUDE_OPUS_NAME, + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { - "input": 5, - "output": 25, - }, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }), json!({ "id": CLAUDE_SONNET_MODEL, "name": CLAUDE_SONNET_NAME, + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, - "cost": { - "input": 3, - "output": 15, - }, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }), ], // OpenClaw also derives the no-argument default from the first entry. @@ -154,23 +173,35 @@ pub(crate) fn sponsor_openclaw_models(family: SponsorModelFamily) -> Vec json!({ "id": CLAUDE_SONNET_MODEL, "name": CLAUDE_SONNET_NAME, + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 2, "output": 10, "cacheRead": 0.1, "cacheWrite": 2.5 }, }), json!({ "id": CLAUDE_OPUS_MODEL, "name": CLAUDE_OPUS_NAME, + "reasoning": true, + "input": ["text", "image"], "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 }, }), json!({ "id": CLAUDE_HAIKU_MODEL, "name": CLAUDE_HAIKU_NAME, - "contextWindow": 200000, + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 0.1, "output": 0.5, "cacheRead": 0.01, "cacheWrite": 0.125 }, }), ], - SponsorModelFamily::Gpt => vec![json!({ - "id": CODEX_DEFAULT_MODEL, - "name": CODEX_DEFAULT_MODEL_NAME, - "contextWindow": 400000, + SponsorModelFamily::Gpt | SponsorModelFamily::GptAstra => vec![json!({ + "id": family.primary_model(), + "name": family.primary_model_name(), + "contextWindow": 272000, })], } } @@ -192,10 +223,12 @@ mod tests { #[test] fn upstream_default_model_ids_are_pinned() { - assert_eq!(CLAUDE_OPUS_MODEL, "claude-opus-5"); - assert_eq!(CLAUDE_SONNET_MODEL, "claude-sonnet-5"); - assert_eq!(CODEX_DEFAULT_MODEL, "gpt-5.6-sol"); - assert_eq!(GEMINI_DEFAULT_MODEL, "gemini-3.6-flash"); + assert_eq!(CLAUDE_OPUS_MODEL, "claude-opus-5-5"); + assert_eq!(CLAUDE_SONNET_MODEL, "claude-sonnet-5-5"); + assert_eq!(CLAUDE_HAIKU_MODEL, "claude-haiku-5-5"); + assert_eq!(CODEX_DEFAULT_MODEL, "gpt-6.1-sol"); + assert_eq!(CODEX_OAUTH_FAST_MODEL, "gpt-6-luna"); + assert_eq!(GEMINI_DEFAULT_MODEL, "gemini-3.8-flash"); } #[test] @@ -232,7 +265,10 @@ mod tests { sponsor_model_family("runapi"), Some(SponsorModelFamily::RunApiClaude) ); - assert_eq!(sponsor_model_family("qiniu"), Some(SponsorModelFamily::Gpt)); + assert_eq!( + sponsor_model_family("qiniu"), + Some(SponsorModelFamily::GptAstra) + ); assert_eq!(sponsor_model_family("fenno"), Some(SponsorModelFamily::Gpt)); } } diff --git a/src-tauri/src/provider_preset_pi.rs b/src-tauri/src/provider_preset_pi.rs index 657dcac3..df1a03ea 100644 --- a/src-tauri/src/provider_preset_pi.rs +++ b/src-tauri/src/provider_preset_pi.rs @@ -212,52 +212,55 @@ pub(crate) static PI_SPONSOR_PROVIDER_PRESETS: [PiProviderPreset; 8] = [ }, ]; -fn claude_sonnet(id: &str) -> Value { +fn claude_model(name: &str, id: &str) -> Value { json!({ - "name": "Claude Sonnet 5", + "name": name, "reasoning": true, "input": ["text", "image"], "contextWindow": 1_000_000, "maxTokens": 128_000, "id": id, - "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }, - "compat": { "forceAdaptiveThinking": true }, + "thinkingLevelMap": {}, }) } +/// Opus and Fable run adaptive thinking with no "off" level; Sonnet and Haiku +/// keep Pi's defaults. Mirrors the upstream pi thinking bindings. +fn claude_adaptive(name: &str, id: &str) -> Value { + let mut model = claude_model(name, id); + model["thinkingLevelMap"] = json!({ "off": null, "xhigh": "xhigh", "max": "max" }); + model["compat"] = json!({ "forceAdaptiveThinking": true }); + model +} + +fn claude_sonnet(id: &str) -> Value { + claude_model("Claude Sonnet 5.5", id) +} + fn claude_opus(id: &str) -> Value { - json!({ - "name": "Claude Opus 5", - "reasoning": true, - "input": ["text", "image"], - "contextWindow": 1_000_000, - "maxTokens": 128_000, - "id": id, - "thinkingLevelMap": { "xhigh": "xhigh", "max": "max" }, - "compat": { "forceAdaptiveThinking": true }, - }) + claude_adaptive("Claude Opus 5.5", id) } -fn gpt_5_6_sol() -> Value { +fn gpt_6_1_sol() -> Value { json!({ - "name": "GPT-5.6 Sol", + "name": "GPT-6.1 Sol", "reasoning": true, "input": ["text", "image"], "contextWindow": 272_000, "maxTokens": 128_000, - "id": "gpt-5.6-sol", + "id": "gpt-6.1-sol", "thinkingLevelMap": {}, }) } -fn glm_5_1() -> Value { +fn glm_5_3() -> Value { json!({ - "name": "GLM-5.1", + "name": "GLM-5.3", "reasoning": true, "input": ["text"], - "contextWindow": 200_000, + "contextWindow": 1_000_000, "maxTokens": 131_072, - "id": "glm-5.1", + "id": "glm-5.3", "thinkingLevelMap": {}, }) } @@ -269,8 +272,8 @@ fn anthropic_settings(name: &str, base_url: &str) -> Value { "api": "anthropic-messages", "apiKey": "", "models": [ - claude_sonnet("claude-sonnet-5"), - claude_opus("claude-opus-5"), + claude_sonnet("claude-sonnet-5-5"), + claude_opus("claude-opus-5-5"), ], }) } @@ -281,7 +284,7 @@ fn openai_gpt_settings(name: &str, base_url: &str) -> Value { "baseUrl": base_url, "api": "openai-completions", "apiKey": "", - "models": [gpt_5_6_sol()], + "models": [gpt_6_1_sol()], }) } @@ -302,20 +305,55 @@ fn runapi_settings() -> Value { settings["models"] .as_array_mut() .expect("Pi RunAPI models are an array") - .push(json!({ - "name": "Claude Haiku 4.5 (latest)", - "reasoning": true, - "input": ["text", "image"], - "contextWindow": 200_000, - "maxTokens": 64_000, - "id": "claude-haiku-4-5", - "thinkingLevelMap": {}, - })); + .push(claude_model("Claude Haiku 5.5", "claude-haiku-5-5")); settings } fn qiniu_settings() -> Value { - openai_gpt_settings("Qiniu", "https://api.qnaigc.com/v1") + json!({ + "name": "Qiniu", + "baseUrl": "https://api.qnaigc.com/v1", + "api": "openai-completions", + "apiKey": "", + "models": [ + { + "name": "GPT-6 Astra", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 272_000, + "maxTokens": 128_000, + "id": "gpt-6-astra", + "thinkingLevelMap": {}, + }, + { + "name": "Kimi K3", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "moonshotai/kimi-k3", + "thinkingLevelMap": {}, + }, + { + "name": "GLM-5.3", + "reasoning": true, + "input": ["text"], + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "z-ai/glm-5.3", + "thinkingLevelMap": {}, + }, + { + "name": "GLM-5.3 Flash", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "z-ai/glm-5.3-flash", + "thinkingLevelMap": {}, + }, + ], + }) } fn cubence_settings() -> Value { @@ -326,7 +364,7 @@ fn deepseek_model(name: &str, id: &str) -> Value { json!({ "name": name, "reasoning": true, - "input": ["text"], + "input": ["text", "image"], "contextWindow": 1_000_000, "maxTokens": 384_000, "id": id, @@ -346,10 +384,7 @@ fn deepseek_settings() -> Value { "baseUrl": "https://api.deepseek.com/v1", "api": "openai-completions", "apiKey": "", - "models": [ - deepseek_model("DeepSeek V4 Pro", "deepseek-v4-pro"), - deepseek_model("DeepSeek V4 Flash", "deepseek-v4-flash"), - ], + "models": [deepseek_model("DeepSeek V4.1 Flash", "deepseek-flash")], }) } @@ -359,7 +394,7 @@ fn zhipu_glm_settings() -> Value { "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4", "api": "openai-completions", "apiKey": "", - "models": [glm_5_1()], + "models": [glm_5_3()], }) } @@ -369,7 +404,7 @@ fn zhipu_glm_en_settings() -> Value { "baseUrl": "https://api.z.ai/api/coding/paas/v4", "api": "openai-completions", "apiKey": "", - "models": [glm_5_1()], + "models": [glm_5_3()], }) } @@ -398,13 +433,21 @@ fn minimax_settings() -> Value { "api": "openai-completions", "apiKey": "", "models": [{ - "name": "MiniMax-M2.7", + "name": "MiniMax-M3.1-Flash", "reasoning": true, - "input": ["text"], - "contextWindow": 204_800, + "input": ["text", "image"], + "contextWindow": 1_000_000, "maxTokens": 131_072, - "id": "MiniMax-M2.7", - "thinkingLevelMap": {}, + "id": "MiniMax-M3.1-Flash", + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "max", + }, }], }) } @@ -435,8 +478,24 @@ fn xiaomi_mimo_settings() -> Value { "api": "openai-completions", "apiKey": "", "models": [ - xiaomi_model("MiMo-V2.5-Pro", "mimo-v2.5-pro", &["text"], true), - xiaomi_model("MiMo-V2.5", "mimo-v2.5", &["text", "image"], true), + xiaomi_model( + "MiMo V2.6 Pro", + "mimo-v2.6-pro", + &["text", "image"], + true, + ), + xiaomi_model( + "MiMo V2.6 Flash", + "mimo-v2.6-flash", + &["text", "image"], + true, + ), + xiaomi_model( + "MiMo V2.6 Pro UltraSpeed", + "mimo-v2.6-pro-ultraspeed", + &["text", "image"], + true, + ), ], }) } @@ -448,8 +507,8 @@ fn xiaomi_mimo_token_plan_settings() -> Value { "api": "openai-completions", "apiKey": "", "models": [ - xiaomi_model("MiMo-V2.5-Pro", "mimo-v2.5-pro", &["text"], false), - xiaomi_model("MiMo-V2.5", "mimo-v2.5", &["text", "image"], false), + xiaomi_model("MiMo V2.6 Pro", "mimo-v2.6-pro", &["text", "image"], true), + xiaomi_model("MiMo V2.6 Flash", "mimo-v2.6-flash", &["text", "image"], true), ], }) } @@ -481,12 +540,12 @@ fn opencode_go_settings() -> Value { "apiKey": "", "models": [ { - "name": "GLM 5.2", + "name": "GLM-5.3", "reasoning": true, "input": ["text"], - "contextWindow": 1_000_000, + "contextWindow": 1_048_576, "maxTokens": 131_072, - "id": "glm-5.2", + "id": "glm-5.3", "compat": opencode_compat(json!({})), "thinkingLevelMap": { "off": null, @@ -499,35 +558,32 @@ fn opencode_go_settings() -> Value { }, }, { - "name": "Kimi K2.7 Code", + "name": "GLM-5.3 Flash", "reasoning": true, "input": ["text", "image"], - "contextWindow": 262_144, - "maxTokens": 262_144, - "id": "kimi-k2.7-code", + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "glm-5.3-flash", "compat": opencode_compat(json!({})), "thinkingLevelMap": {}, }, { - "name": "DeepSeek V4 Pro", + "name": "Kimi K3.1 Pro", "reasoning": true, - "input": ["text"], - "contextWindow": 1_000_000, - "maxTokens": 384_000, - "id": "deepseek-v4-pro", - "compat": opencode_compat(json!({ - "requiresReasoningContentOnAssistantMessages": true, - "thinkingFormat": "deepseek", - })), - "thinkingLevelMap": thinking_levels.clone(), + "input": ["text", "image"], + "contextWindow": 1_048_576, + "maxTokens": 131_072, + "id": "kimi-k3.1-pro", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": {}, }, { - "name": "DeepSeek V4 Flash", + "name": "DeepSeek V4.1 Flash", "reasoning": true, - "input": ["text"], + "input": ["text", "image"], "contextWindow": 1_000_000, "maxTokens": 384_000, - "id": "deepseek-v4-flash", + "id": "deepseek-v4.1-flash", "compat": opencode_compat(json!({ "requiresReasoningContentOnAssistantMessages": true, "thinkingFormat": "deepseek", @@ -535,12 +591,32 @@ fn opencode_go_settings() -> Value { "thinkingLevelMap": thinking_levels, }, { - "name": "MiMo-V2.5-Pro", + "name": "MiMo V2.6 Pro", "reasoning": true, - "input": ["text"], + "input": ["text", "image"], "contextWindow": 1_048_576, "maxTokens": 131_072, - "id": "mimo-v2.5-pro", + "id": "mimo-v2.6-pro", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": {}, + }, + { + "name": "Qwen3.8 Max", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 983_616, + "maxTokens": 131_072, + "id": "qwen3.8-max", + "compat": opencode_compat(json!({})), + "thinkingLevelMap": {}, + }, + { + "name": "MiniMax-M3.1-Flash", + "reasoning": true, + "input": ["text", "image"], + "contextWindow": 1_000_000, + "maxTokens": 128_000, + "id": "minimax-m3.1-flash", "compat": opencode_compat(json!({})), "thinkingLevelMap": {}, }, @@ -555,8 +631,10 @@ fn openrouter_settings() -> Value { "api": "anthropic-messages", "apiKey": "", "models": [ - claude_sonnet("anthropic/claude-sonnet-5"), - claude_opus("anthropic/claude-opus-5"), + claude_opus("anthropic/claude-opus-5.5"), + claude_sonnet("anthropic/claude-sonnet-5.5"), + claude_model("Claude Haiku 5.5", "anthropic/claude-haiku-5.5"), + claude_adaptive("Claude Fable 5.1", "anthropic/claude-fable-5.1"), ], }) } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 60ca7fb1..424d515d 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -518,7 +518,7 @@ fn responses_sse_to_response_value(body: &str) -> Result { output_items.push(item.clone()); } } - "response.completed" => { + "response.completed" | "response.incomplete" => { completed_response = Some(data.get("response").cloned().unwrap_or(data)); } "response.failed" => { @@ -533,13 +533,15 @@ fn responses_sse_to_response_value(body: &str) -> Result { } let mut response = completed_response.ok_or_else(|| { - ProxyError::TransformError("No response.completed event in upstream SSE".to_string()) + ProxyError::TransformError( + "No response.completed or response.incomplete event in upstream SSE".to_string(), + ) })?; if !output_items.is_empty() { let Some(response) = response.as_object_mut() else { return Err(ProxyError::TransformError( - "response.completed payload is not an object".to_string(), + "terminal response payload is not an object".to_string(), )); }; response.insert("output".to_string(), Value::Array(output_items)); @@ -1912,4 +1914,20 @@ data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\"}}\n assert!(responses_sse_to_response_value(sse).is_err()); } + + #[test] + fn responses_sse_to_response_value_accepts_incomplete_terminal_event() { + let sse = "event: response.output_item.done\n\ +data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"partial\"}]}}\n\n\ +event: response.incomplete\n\ +data: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_inc\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"output\":[],\"usage\":{\"input_tokens\":10,\"output_tokens\":2}}}\n\n"; + + let response = responses_sse_to_response_value(sse).unwrap(); + assert_eq!(response["status"], "incomplete"); + assert_eq!( + response["incomplete_details"]["reason"], + "max_output_tokens" + ); + assert_eq!(response["output"][0]["content"][0]["text"], "partial"); + } } diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 383c1520..c0f4c059 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -1170,16 +1170,20 @@ wire_api = "chat" .expect("OpenCode Go preset"); let provider: Provider = serde_json::from_value(value).expect("valid provider preset"); - let glm = resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "GLM-5.2" })) + let glm = resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "glm-5.3" })) .expect("GLM reasoning config"); assert_eq!( glm.effort_levels, - Some(vec!["high".to_string(), "max".to_string()]) + Some(vec![ + "low".to_string(), + "high".to_string(), + "max".to_string() + ]) ); let flash = resolve_codex_chat_reasoning_config( &provider, - &json!({ "model": "deepseek-v4-flash" }), + &json!({ "model": "deepseek-v4.1-flash" }), ) .expect("DeepSeek reasoning config"); assert_eq!( @@ -1191,8 +1195,9 @@ wire_api = "chat" ]) ); - let toggle = resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "glm-5.1" })) - .expect("toggle-only reasoning config"); + let toggle = + resolve_codex_chat_reasoning_config(&provider, &json!({ "model": "mimo-v2.6-pro" })) + .expect("toggle-only reasoning config"); assert!(toggle.effort_levels.is_none()); } diff --git a/src-tauri/src/proxy/providers/codex_chat_history.rs b/src-tauri/src/proxy/providers/codex_chat_history.rs index 256b697b..d5cbc5e4 100644 --- a/src-tauri/src/proxy/providers/codex_chat_history.rs +++ b/src-tauri/src/proxy/providers/codex_chat_history.rs @@ -432,7 +432,7 @@ async fn inspect_sse_block( .await; } } - Some("response.completed") => { + Some("response.completed" | "response.incomplete") => { if let Some(response) = value.get("response") { history.record_response(response).await; } @@ -806,4 +806,31 @@ mod tests { assert_eq!(history.enrich_request(&mut request).await, 1); assert_eq!(request["input"][0]["reasoning_content"], "Need a file."); } + + #[tokio::test] + async fn records_function_calls_from_incomplete_terminal_event() { + let history = Arc::new(CodexChatHistoryStore::default()); + let stream = futures::stream::iter(vec![Ok::<_, std::io::Error>(Bytes::from_static( + b"event: response.incomplete\ndata: {\"type\":\"response.incomplete\",\"response\":{\"id\":\"resp_incomplete\",\"status\":\"incomplete\",\"incomplete_details\":{\"reason\":\"max_output_tokens\"},\"output\":[{\"type\":\"function_call\",\"call_id\":\"call_inc\",\"name\":\"read_file\",\"arguments\":\"{}\",\"reasoning_content\":\"Need a file.\"}]}}\n\n", + ))]); + + let output = record_responses_sse_stream(stream, history.clone()) + .collect::>() + .await; + assert_eq!(output.len(), 1); + + let mut request = json!({ + "previous_response_id": "resp_incomplete", + "input": [ + { + "type": "function_call_output", + "call_id": "call_inc", + "output": "ok" + } + ] + }); + + assert_eq!(history.enrich_request(&mut request).await, 1); + assert_eq!(request["input"][0]["reasoning_content"], "Need a file."); + } } diff --git a/src-tauri/src/proxy/providers/codex_responses_sse.rs b/src-tauri/src/proxy/providers/codex_responses_sse.rs index d347e8a4..63bb4745 100644 --- a/src-tauri/src/proxy/providers/codex_responses_sse.rs +++ b/src-tauri/src/proxy/providers/codex_responses_sse.rs @@ -25,7 +25,7 @@ pub(crate) fn sse_event(event: &str, data: Value) -> Bytes { } // --------------------------------------------------------------------------- -// Response lifecycle (created / in_progress / completed / failed) +// Response lifecycle (created / in_progress / completed or incomplete / failed) // --------------------------------------------------------------------------- /// `response.created`, wrapping a caller-built `response` object (usage/created_at differ @@ -45,12 +45,16 @@ pub(crate) fn response_in_progress(response: &Value) -> Bytes { ) } -/// `response.completed`. -pub(crate) fn response_completed(response: &Value) -> Bytes { - sse_event( - "response.completed", - json!({ "type": "response.completed", "response": response }), - ) +/// Terminal event for a finished response: `response.incomplete` when its `status` is +/// `incomplete`, otherwise `response.completed`. Codex reads `incomplete_details` only +/// from a `response.incomplete` event and treats `response.completed` as success. +pub(crate) fn response_terminal(response: &Value) -> Bytes { + let event = if response.get("status").and_then(Value::as_str) == Some("incomplete") { + "response.incomplete" + } else { + "response.completed" + }; + sse_event(event, json!({ "type": event, "response": response })) } /// `response.failed`. diff --git a/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs index 1a4cd1d5..a7b78d5a 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_anthropic.rs @@ -482,7 +482,7 @@ impl AnthropicToResponsesState { response["incomplete_details"] = json!({ "reason": reason }); } - events.push(sse::response_completed(&response)); + events.push(sse::response_terminal(&response)); self.completed = true; events } @@ -907,13 +907,58 @@ mod tests { ); let merged = run(input).await; assert!(merged.contains("\"delta\":\"partial\"")); - assert!(merged.contains("event: response.completed")); + assert!(merged.contains("event: response.incomplete")); + assert!(!merged.contains("event: response.completed")); // The top-level response is incomplete (message output items keep their own // "completed" status, but the response status must not be "completed"). assert!(merged.contains("\"status\":\"incomplete\"")); assert!(merged.contains("\"reason\":\"max_output_tokens\"")); } + #[tokio::test] + async fn test_incomplete_stop_reasons_emit_response_incomplete_event() { + // Codex reads incomplete_details only from a response.incomplete event. + for (stop_reason, reason) in [ + ("max_tokens", "max_output_tokens"), + ("refusal", "content_filter"), + ] { + let input = format!( + concat!( + "event: message_start\n", + "data: {{\"type\":\"message_start\",\"message\":{{\"id\":\"msg_inc\",\"model\":\"claude\",\"usage\":{{\"input_tokens\":4}}}}}}\n\n", + "event: content_block_start\n", + "data: {{\"type\":\"content_block_start\",\"index\":0,\"content_block\":{{\"type\":\"text\",\"text\":\"\"}}}}\n\n", + "event: content_block_delta\n", + "data: {{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{{\"type\":\"text_delta\",\"text\":\"partial\"}}}}\n\n", + "event: content_block_stop\n", + "data: {{\"type\":\"content_block_stop\",\"index\":0}}\n\n", + "event: message_delta\n", + "data: {{\"type\":\"message_delta\",\"delta\":{{\"stop_reason\":\"{}\"}},\"usage\":{{\"output_tokens\":8}}}}\n\n", + "event: message_stop\n", + "data: {{\"type\":\"message_stop\"}}\n\n" + ), + stop_reason + ); + let merged = run(&input).await; + assert!( + merged.contains("event: response.incomplete"), + "{stop_reason}: {merged}" + ); + assert!( + merged.contains("\"type\":\"response.incomplete\""), + "{stop_reason}" + ); + assert!( + !merged.contains("event: response.completed"), + "{stop_reason}" + ); + assert!( + merged.contains(&format!("\"reason\":\"{reason}\"")), + "{stop_reason}" + ); + } + } + #[tokio::test] async fn test_truncated_tool_call_is_not_marked_completed() { let input = concat!( diff --git a/src-tauri/src/proxy/providers/streaming_codex_chat.rs b/src-tauri/src/proxy/providers/streaming_codex_chat.rs index fec31473..f3f94ccf 100644 --- a/src-tauri/src/proxy/providers/streaming_codex_chat.rs +++ b/src-tauri/src/proxy/providers/streaming_codex_chat.rs @@ -4,6 +4,7 @@ use super::{ codex_chat_common::{ extract_reasoning_field_text, split_leading_think_block, strip_leading_think_open_tag, }, + codex_responses_sse::response_terminal, transform_codex_chat::{ chat_usage_to_responses_usage, custom_tool_input_from_chat_arguments, response_id_from_chat_id, response_status_from_finish_reason, @@ -564,13 +565,7 @@ impl ChatToResponsesState { } else { let mut response = self.base_response("incomplete", output); response["incomplete_details"] = json!({ "reason": "stream_truncated" }); - events.push(sse_event( - "response.completed", - json!({ - "type": "response.completed", - "response": response - }), - )); + events.push(response_terminal(&response)); } self.completed = true; return events; @@ -582,13 +577,7 @@ impl ChatToResponsesState { response["incomplete_details"] = json!({ "reason": "max_output_tokens" }); } - events.push(sse_event( - "response.completed", - json!({ - "type": "response.completed", - "response": response - }), - )); + events.push(response_terminal(&response)); self.completed = true; events } @@ -1441,6 +1430,47 @@ mod tests { assert!(output.contains("\"query\":\"Gmail search emails\"")); } + #[tokio::test] + async fn length_finish_reason_emits_response_incomplete_event() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_len\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"content\":\"partial\"},\"finish_reason\":\"length\"}]}\n\n", + "data: [DONE]\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let terminal = events + .iter() + .find(|event| event["type"] == "response.incomplete") + .unwrap(); + + assert!(output.contains("event: response.incomplete")); + assert!(!output.contains("event: response.completed")); + assert_eq!(terminal["response"]["status"], "incomplete"); + assert_eq!( + terminal["response"]["incomplete_details"]["reason"], + "max_output_tokens" + ); + } + + #[tokio::test] + async fn truncated_stream_with_output_emits_response_incomplete_event() { + let output = collect(vec![ + "data: {\"id\":\"chatcmpl_cut\",\"model\":\"gpt-5.4\",\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n", + ]) + .await; + let events = parse_sse_events(&output); + let terminal = events + .iter() + .find(|event| event["type"] == "response.incomplete") + .unwrap(); + + assert!(!output.contains("event: response.completed")); + assert_eq!( + terminal["response"]["incomplete_details"]["reason"], + "stream_truncated" + ); + } + #[tokio::test] async fn stream_error_emits_failed_without_completed() { let upstream = stream::iter(vec![Err::(std::io::Error::other( diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index f3e33eaa..49a94a92 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -121,15 +121,20 @@ pub fn anthropic_to_openai_with_reasoning_content( messages.push(json!({"role": "system", "content": text})); } } else if let Some(arr) = system.as_array() { + // Merge the top-level system blocks into one message. Their content is + // fixed for the whole conversation, so this stays byte-stable across + // turns and does not disturb prefix caching. + let mut parts = Vec::new(); for msg in arr { if let Some(text) = msg.get("text").and_then(|t| t.as_str()) { - let Some(text) = sanitize_system_text(text) else { - continue; - }; - let system_message = json!({"role": "system", "content": text}); - messages.push(system_message); + if let Some(text) = sanitize_system_text(text) { + parts.push(text.into_owned()); + } } } + if !parts.is_empty() { + messages.push(json!({"role": "system", "content": parts.join("\n")})); + } } } @@ -145,7 +150,6 @@ pub fn anthropic_to_openai_with_reasoning_content( } } - normalize_openai_system_messages(&mut messages); result["messages"] = json!(messages); let model = body.get("model").and_then(|m| m.as_str()).unwrap_or(""); @@ -260,58 +264,6 @@ fn map_tool_choice_to_chat(tool_choice: &Value) -> Value { } } -fn normalize_openai_system_messages(messages: &mut Vec) { - let system_count = messages - .iter() - .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("system")) - .count(); - - if system_count == 0 { - return; - } - - if system_count == 1 { - if let Some(index) = messages.iter().position(|message| { - message.get("role").and_then(|value| value.as_str()) == Some("system") - }) { - if index > 0 { - let message = messages.remove(index); - messages.insert(0, message); - } - } - return; - } - - let mut parts = Vec::new(); - messages.retain(|message| { - if message.get("role").and_then(|value| value.as_str()) != Some("system") { - return true; - } - - match message.get("content") { - Some(Value::String(text)) if !text.is_empty() => parts.push(text.clone()), - Some(Value::Array(content_parts)) => { - let text = content_parts - .iter() - .filter_map(|part| part.get("text").and_then(|value| value.as_str())) - .collect::>() - .join("\n"); - if !text.is_empty() { - parts.push(text); - } - } - _ => {} - } - - false - }); - - if !parts.is_empty() { - let merged = json!({"role": "system", "content": parts.join("\n")}); - messages.insert(0, merged); - } -} - fn convert_message_to_openai( role: &str, content: Option<&Value>, @@ -1494,4 +1446,70 @@ mod tests { assert_eq!(msg["tool_calls"][0]["id"], "call_date"); assert_eq!(msg["tool_calls"][0]["function"]["name"], "get_date"); } + + #[test] + fn anthropic_to_openai_preserves_mid_conversation_system_in_place() { + // Claude Code injects a system message every turn (environment and + // system-reminder blocks). Hoisting or merging them into the head + // rewrites the prompt prefix each turn and defeats upstream prefix + // caching, so they must stay exactly where the client put them. + let input = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": "You are Claude Code.", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "# Environment\ncwd=/tmp"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "Continue"} + ] + }); + + let result = anthropic_to_openai(input, None).unwrap(); + let messages = result["messages"].as_array().unwrap(); + + assert_eq!(messages.len(), 5); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[0]["content"], "You are Claude Code."); + assert_eq!(messages[2]["role"], "system"); + assert_eq!(messages[2]["content"], "# Environment\ncwd=/tmp"); + } + + #[test] + fn anthropic_to_openai_keeps_prefix_stable_when_a_turn_is_appended() { + // The serialized messages of one turn must remain a prefix of the next + // turn; otherwise the upstream prompt cache can never hit. + let turn_one = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": [{"type": "text", "text": "You are Claude Code."}], + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "# Environment\ncwd=/tmp"} + ] + }); + let turn_two = json!({ + "model": "claude-3-sonnet", + "max_tokens": 1024, + "system": [{"type": "text", "text": "You are Claude Code."}], + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "# Environment\ncwd=/tmp"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "Continue"}, + {"role": "system", "content": "# Environment\ncwd=/tmp"} + ] + }); + + let first = anthropic_to_openai(turn_one, None).unwrap(); + let second = anthropic_to_openai(turn_two, None).unwrap(); + let first_messages = first["messages"].as_array().unwrap(); + let second_messages = second["messages"].as_array().unwrap(); + + assert_eq!(second_messages.len(), 6); + assert_eq!( + &second_messages[..first_messages.len()], + &first_messages[..] + ); + } } diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index b78cce10..46f721fd 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -532,15 +532,15 @@ impl TokenUsage { for event in events { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { log::debug!("[Codex] 事件类型: {event_type}"); - if event_type == "response.completed" { + if matches!(event_type, "response.completed" | "response.incomplete") { if let Some(response) = event.get("response") { - log::debug!("[Codex] 找到 response.completed 事件,解析 usage"); + log::debug!("[Codex] 找到 {event_type} 事件,解析 usage"); return Self::from_codex_response_adjusted(response); } } } } - log::debug!("[Codex] 未找到 response.completed 事件"); + log::debug!("[Codex] 未找到 response.completed/response.incomplete 事件"); None } @@ -572,12 +572,12 @@ impl TokenUsage { pub fn from_codex_stream_events_auto(events: &[Value]) -> Option { log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len()); - // 先尝试 Codex Responses API 格式 (response.completed 事件) + // 先尝试 Codex Responses API 格式 (response.completed / response.incomplete 事件) for event in events { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { - if event_type == "response.completed" { + if matches!(event_type, "response.completed" | "response.incomplete") { if let Some(response) = event.get("response") { - log::debug!("[Codex] 找到 response.completed 事件"); + log::debug!("[Codex] 找到 {event_type} 事件"); return Self::from_codex_response_auto(response); } } @@ -1233,6 +1233,28 @@ mod tests { assert_eq!(usage.model, Some("o3".to_string())); } + #[test] + fn test_codex_stream_events_read_usage_from_incomplete_event() { + let events = vec![json!({ + "type": "response.incomplete", + "response": { + "model": "o3", + "status": "incomplete", + "incomplete_details": { "reason": "max_output_tokens" }, + "usage": { + "input_tokens": 100, + "output_tokens": 8192 + } + } + })]; + + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 8192); + let usage = TokenUsage::from_codex_stream_events(&events).unwrap(); + assert_eq!(usage.output_tokens, 8192); + } + #[test] fn test_codex_stream_events_auto_openai_format() { // OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage) diff --git a/src-tauri/src/services/provider/claude_editor.rs b/src-tauri/src/services/provider/claude_editor.rs new file mode 100644 index 00000000..f3b2ed32 --- /dev/null +++ b/src-tauri/src/services/provider/claude_editor.rs @@ -0,0 +1,590 @@ +//! Claude Code 供应商编辑器:底部 JSON 就是「切到这个供应商之后 settings.json 的样子」。 +//! +//! - 显示:在内存里对当前 live 做一次切换投影,和切换用同一个补丁:关键字段、独有字段 +//! 换成这个供应商的,其余部分是 live 原样。编辑当前的、非当前的、新增的供应商都一样。 +//! - 保存:关键字段、独有字段写回这个供应商的行(行里其余内容原样保留);其余部分的改动 +//! 是 Claude Code 的全局设置,经引擎写进 live,只改用户动过的键。编辑的是直连模式下的 +//! 当前供应商时,关键字段和独有字段在同一次写入里也换进 live。 +//! - 三方比较:每个改动都带着打开编辑器时的原值。live 里这个键已经被别的程序改成了第三个 +//! 值,就算冲突,由用户选保留哪一边;没改过的键不提交,也就不会覆盖别人的改动。 + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::app_config::AppType; +use crate::database::Database; +use crate::error::AppError; +use crate::live::engine::read_current; +use crate::live::floor; +use crate::live::patch::json::{self as patch_json, JsonPatch}; +use crate::live::patch::{KeyPath, LivePatch, LiveWriteError}; +use crate::live::project::claude::{direct_patch, project_onto, store_into_row, ClaudeProjection}; +use crate::mode::operation::{AppWrite, FileChange}; +use crate::mode::state::{op, PendingTarget}; +use crate::provider::Provider; +use crate::store::AppState; + +fn settings_file() -> crate::live::engine::LiveFile { + crate::live::engine::LiveFile::private(crate::config::get_claude_settings_path()) +} + +/// 旧版存在 settings 里的内部字段:从不写进 live,也不算「不随切换生效的字段」。 +const INTERNAL_TOP: &[&str] = &[ + "api_format", + "apiFormat", + "openrouter_compat_mode", + "openrouterCompatMode", +]; + +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorView { + /// 编辑器里显示的完整配置。 + pub settings: Value, + /// New/copy dialogs compare against the same live snapshot before applying their draft. + #[serde(skip)] + pub(crate) base_for_new: Value, + /// 行里保存着、但不随切换生效的字段(值和显示的不同才列出)。 + pub inactive: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct InactiveField { + /// 键路径,比如 `["env", "API_TIMEOUT_MS"]`、`["hooks"]`。 + pub path: Vec, + pub value: Value, +} + +/// Copy explicitly selected legacy values into an editor draft, without writing files. +pub(crate) fn copy_inactive_fields(settings: &mut Value, fields: &[InactiveField]) { + for field in fields { + let Some((last, parents)) = field.path.split_last() else { + continue; + }; + let mut target = &mut *settings; + for key in parents { + if !target[key].is_object() { + target[key] = Value::Object(Map::new()); + } + target = &mut target[key]; + } + target[last] = field.value.clone(); + } +} + +/// live 里的键在编辑期间被别的程序改过时怎么办。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ConflictPolicy { + /// 报冲突,什么都不写。 + #[default] + Refuse, + /// 用编辑器里的值覆盖。 + KeepMine, + /// 冲突的键保留外部的值,其余改动照常写。 + KeepTheirs, +} + +pub(crate) fn conflict_keys(error: &AppError) -> Option> { + let AppError::Message(message) = error else { + return None; + }; + let value: Value = serde_json::from_str(message).ok()?; + if value["code"] != crate::live::patch::EDIT_CONFLICT_CODE { + return None; + } + serde_json::from_value(value["keys"].clone()).ok() +} + +impl ConflictPolicy { + /// 三方比较:`conflict` 给出冲突改动的显示名(live 里现在的值既不是打开编辑器时的, + /// 也不是要写的)。有冲突时按策略整体拒绝、跳过冲突的改动或照写;返回要写的改动。 + pub(crate) fn resolve<'c, C>( + self, + path: &Path, + changes: &'c [C], + conflict: impl Fn(&C) -> Option, + ) -> Result, LiveWriteError> { + let mut conflicts = Vec::new(); + let mut accepted = Vec::new(); + for change in changes { + if let Some(key) = conflict(change) { + conflicts.push(key); + if self == Self::KeepTheirs { + continue; + } + } + accepted.push(change); + } + if !conflicts.is_empty() && self == Self::Refuse { + return Err(LiveWriteError::EditConflict { + path: path.to_path_buf(), + keys: conflicts, + }); + } + Ok(accepted) + } +} + +/// 编辑器保存时随供应商一起提交的上下文。 +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorSave { + /// 打开编辑器时显示的完整配置(新增对话框里是还没套预设的 live)。 + pub base: Value, + /// 新增对话框(Codex、Gemini CLI、Grok Build):投影成 `base` 的那份草稿(预设或模板)。 + /// 显示里有、草稿里没有的独有字段是从 live 带进来的,不归新供应商。 + #[serde(default)] + pub draft: Option, + #[serde(default)] + pub on_conflict: ConflictPolicy, +} + +/// 编辑器显示的内容。`settings_config` 是这个供应商的行(新增时是空对象)。 +pub fn view(state: &AppState, settings_config: &Value) -> Result { + let current = crate::settings::get_effective_current_provider(&state.db, &AppType::Claude)? + .map(|id| state.db.get_provider_by_id(&id, "claude")) + .transpose()? + .flatten(); + view_for_current(settings_config, current.as_ref()) +} + +/// The TUI already has the current provider in its provider-list snapshot. +pub(crate) fn view_for_current( + settings_config: &Value, + current: Option<&Provider>, +) -> Result { + let file = settings_file(); + let pre = read_current(&file.path)?; + let (live, _) = patch_json::parse(&file.path, pre.as_deref())?; + let mode = + crate::mode::state::mode_state(&crate::live::engine::DeviceStore::for_device(), "claude")?; + let prev = if mode.attached { + mode.contract + .map(|contract| ClaudeProjection { + exclusive: contract.exclusive, + ..Default::default() + }) + .or_else(|| current.map(|row| ClaudeProjection::of(&row.settings_config))) + } else { + current.map(|row| ClaudeProjection::of(&row.settings_config)) + }; + let settings = project_onto( + &file.path, + &live, + prev.as_ref(), + &ClaudeProjection::of(settings_config), + )?; + Ok(EditorView { + base_for_new: project_onto( + &file.path, + &live, + prev.as_ref(), + &ClaudeProjection::default(), + )?, + inactive: inactive_fields(settings_config, &settings), + settings, + }) +} + +fn inactive_fields(row: &Value, display: &Value) -> Vec { + let mut fields = Vec::new(); + if let Some(root) = row.as_object() { + for (key, value) in root { + if key == "env" || floor::claude_floor_top(key) || INTERNAL_TOP.contains(&key.as_str()) + { + continue; + } + if display.get(key) != Some(value) { + fields.push(InactiveField { + path: vec![key.clone()], + value: value.clone(), + }); + } + } + } + if let Some(env) = row.get("env").and_then(Value::as_object) { + for (key, value) in env { + if floor::claude_floor_env(key) || floor::claude_exclusive_env(key) { + continue; + } + if display.get("env").and_then(|env| env.get(key)) != Some(value) { + fields.push(InactiveField { + path: vec!["env".to_string(), key.clone()], + value: value.clone(), + }); + } + } + } + fields +} + +/// 编辑器里对 JSON 全局设置的一处改动。 +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct JsonChange { + pub path: KeyPath, + /// 打开编辑器时的值;`None` 表示当时没有这个键。 + pub before: Option, + /// 保存的值;`None` 表示删掉。 + pub after: Option, +} + +impl JsonChange { + /// live 里这个位置现在是 `now`:既不是打开编辑器时的值,也不是要写的值。 + pub(crate) fn conflicts_with(&self, now: Option<&Value>) -> Option { + (now != self.before.as_ref() && now != self.after.as_ref()).then(|| self.path.to_string()) + } + + /// 把要写的改动收成一个补丁。 + pub(crate) fn patch(changes: &[&Self]) -> JsonPatch { + let mut patch = JsonPatch::default(); + for change in changes { + match &change.after { + Some(value) => patch.set.push((change.path.clone(), value.clone())), + None => patch.remove.push(change.path.clone()), + } + } + patch + } +} + +/// 一次编辑器保存:存进行的内容,和要写进 live 的全局改动。 +#[derive(Debug, Clone)] +pub(crate) struct EditorPlan { + pub row_settings: Value, + changes: Vec, +} + +/// 把编辑器里的完整配置拆开:关键字段、独有字段换进 `stored_row`(新增时为 `None`), +/// 其余部分和 `base` 比,得出用户改过的全局设置。 +/// +/// 显示里有、行里没有的独有字段是从 live 带进来的(用户自己写的,或者上一家留下、值被 +/// 改过的),不归这个供应商:用户没动就不收进行,否则切走时会把用户自己的设置删掉;用户 +/// 删了就从 live 删。新增对话框的 `base` 是还没套预设的 live,预设带的独有字段不在里面。 +pub(crate) fn plan_save( + stored_row: Option<&Value>, + edited: &Value, + base: &Value, +) -> Result { + for (name, doc) in [("edited", edited), ("base", base)] { + let shaped = doc.is_object() + && doc + .get("env") + .is_none_or(|env| env.is_object() || env.is_null()); + if !shaped { + return Err(AppError::localized( + "provider.claude.editor.invalid_shape", + format!("Claude 配置必须是 JSON 对象,env 也必须是对象({name})"), + format!("Claude configuration and its env must be JSON objects ({name})"), + )); + } + } + let empty = Value::Object(Map::new()); + let stored = stored_row.unwrap_or(&empty); + let env_value = |doc: &Value, key: &str| doc.get("env").and_then(|env| env.get(key)).cloned(); + let from_live: Vec = base + .get("env") + .and_then(Value::as_object) + .into_iter() + .flat_map(Map::keys) + .filter(|key| floor::claude_exclusive_env(key) && env_value(stored, key).is_none()) + .cloned() + .collect(); + + let mut projection = ClaudeProjection::of(edited); + projection.exclusive.retain(|key, value| { + !from_live.contains(key) || env_value(base, key).as_ref() != Some(value) + }); + let removed_from_live: Vec = from_live + .into_iter() + .filter(|key| env_value(edited, key).is_none()) + .collect(); + Ok(EditorPlan { + row_settings: store_into_row(stored, &projection), + changes: global_changes(base, edited, &removed_from_live), + }) +} + +/// `removed_from_live`:用户删掉的、从 live 带进来的独有字段,从 live 删。其余独有字段 +/// 归供应商行,不算全局改动。 +fn global_changes(base: &Value, edited: &Value, removed_from_live: &[String]) -> Vec { + let top = |doc: &Value| doc.as_object().cloned().unwrap_or_default(); + let env = |doc: &Value| { + doc.get("env") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default() + }; + + let mut changes = Vec::new(); + diff_level( + &KeyPath::root(), + &top(base), + &top(edited), + |key| key == "env" || floor::claude_floor_top(key) || INTERNAL_TOP.contains(&key), + &mut changes, + ); + diff_level( + &KeyPath::new(&["env"]), + &env(base), + &env(edited), + |key| { + floor::claude_floor_env(key) + || (floor::claude_exclusive_env(key) + && !removed_from_live.iter().any(|removed| removed == key)) + }, + &mut changes, + ); + changes +} + +/// 同一层里改过的键:先按原有顺序,新加的键按编辑器里的顺序排在后面。 +fn diff_level( + parent: &KeyPath, + before: &Map, + after: &Map, + skip: impl Fn(&str) -> bool, + changes: &mut Vec, +) { + let keys = before + .keys() + .chain(after.keys().filter(|key| !before.contains_key(*key))); + for key in keys { + if skip(key) || before.get(key) == after.get(key) { + continue; + } + changes.push(JsonChange { + path: parent.child(key), + before: before.get(key).cloned(), + after: after.get(key).cloned(), + }); + } +} + +struct EditorPatch<'a> { + changes: &'a [JsonChange], + on_conflict: ConflictPolicy, + key_fields: Option, +} + +impl LivePatch for EditorPatch<'_> { + fn apply(&self, path: &Path, pre: Option<&[u8]>) -> Result, LiveWriteError> { + let (mut doc, style) = patch_json::parse(path, pre)?; + let accepted = self.on_conflict.resolve(path, self.changes, |change| { + change.conflicts_with(patch_json::value_at(&doc, &change.path)) + })?; + JsonChange::patch(&accepted).apply_to(path, &mut doc)?; + if let Some(key_fields) = &self.key_fields { + key_fields.apply_to(path, &mut doc)?; + } + patch_json::serialize(path, &doc, &style) + } +} + +/// 要不要在同一次写入里把关键字段也换进 live。 +pub(crate) struct KeyFieldWrite<'a> { + /// live 现在对应的那一版行(编辑前的行;新增第一个供应商时为 `None`)。 + pub prev: Option<&'a Provider>, + pub target: &'a Provider, + /// 同时把当前供应商改成 `target`(新增第一个供应商)。 + pub set_pointer: bool, +} + +/// 把编辑器保存的改动写进 live。没有要写的就什么都不做。 +pub(crate) fn write_live( + db: &Database, + plan: &EditorPlan, + on_conflict: ConflictPolicy, + key_fields: Option>, +) -> Result<(), AppError> { + if plan.changes.is_empty() && key_fields.is_none() { + return Ok(()); + } + let pointer = key_fields + .as_ref() + .filter(|write| write.set_pointer) + .map(|write| write.target.id.clone()); + let key_patch = key_fields.map(|write| { + let prev = write + .prev + .map(|provider| ClaudeProjection::of(&provider.settings_config)); + direct_patch( + prev.as_ref(), + &ClaudeProjection::of(&write.target.settings_config), + ) + }); + let patch = EditorPatch { + changes: &plan.changes, + on_conflict, + key_fields: key_patch, + }; + + // 关键字段的补丁是按调用方读到的行算的:先补完上一次的操作。 + AppWrite::begin(db, AppType::Claude.as_str())?.run( + if pointer.is_some() { + op::SWITCH + } else { + op::APPLY + }, + &[FileChange { + file: settings_file(), + patch: &patch, + }], + PendingTarget::pointer(pointer), + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn apply( + changes: &[JsonChange], + policy: ConflictPolicy, + live: &Value, + ) -> Result { + let patch = EditorPatch { + changes, + on_conflict: policy, + key_fields: None, + }; + let out = patch.apply( + Path::new("settings.json"), + Some(live.to_string().as_bytes()), + )?; + Ok(serde_json::from_slice(&out).expect("json")) + } + + #[test] + fn only_global_settings_count_as_live_changes() { + let base = json!({ + "env": { "ANTHROPIC_BASE_URL": "https://a.example", "DEBUG": "1" }, + "hooks": { "Stop": [] } + }); + let edited = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://b.example", + "CLAUDE_CODE_DISABLE_ARTIFACT": "1", + "EXTRA": "x" + }, + "hooks": { "Stop": [] }, + "alwaysThinkingEnabled": false, + "apiFormat": "openai_chat" + }); + let changes = global_changes(&base, &edited, &[]); + assert_eq!( + changes, + vec![ + JsonChange { + path: KeyPath::new(&["alwaysThinkingEnabled"]), + before: None, + after: Some(json!(false)), + }, + JsonChange { + path: KeyPath::new(&["env", "DEBUG"]), + before: Some(json!("1")), + after: None, + }, + JsonChange { + path: KeyPath::new(&["env", "EXTRA"]), + before: None, + after: Some(json!("x")), + }, + ] + ); + } + + #[test] + fn saving_splits_the_row_from_the_global_settings() { + let stored = json!({ + "env": { "ANTHROPIC_BASE_URL": "https://old.example", "API_TIMEOUT_MS": "300000" }, + "hooks": { "Stop": ["from backfill"] } + }); + let base = json!({ "env": { "ANTHROPIC_BASE_URL": "https://old.example" } }); + let edited = json!({ + "env": { "ANTHROPIC_BASE_URL": "https://new.example", "DEBUG": "1" } + }); + let plan = plan_save(Some(&stored), &edited, &base).expect("plan"); + assert_eq!( + plan.row_settings, + json!({ + "env": { "ANTHROPIC_BASE_URL": "https://new.example", "API_TIMEOUT_MS": "300000" }, + "hooks": { "Stop": ["from backfill"] } + }) + ); + assert_eq!(plan.changes.len(), 1); + + let err = plan_save(None, &json!({ "env": "oops" }), &base).expect_err("bad shape"); + assert!(err.to_string().contains("env"), "{err}"); + } + + #[test] + fn a_key_changed_elsewhere_is_a_conflict_unless_the_user_picks_a_side() { + let changes = vec![ + JsonChange { + path: KeyPath::new(&["x"]), + before: Some(json!(1)), + after: Some(json!(2)), + }, + JsonChange { + path: KeyPath::new(&["y"]), + before: None, + after: Some(json!("mine")), + }, + ]; + // 窗口打开时 x=1,Claude Code 随后改成了 3;用户在旧窗口里改成 2。 + let live = json!({ "x": 3, "z": true }); + match apply(&changes, ConflictPolicy::Refuse, &live) { + Err(LiveWriteError::EditConflict { keys, .. }) => assert_eq!(keys, vec!["x"]), + other => panic!("expected a conflict, got {other:?}"), + } + assert_eq!( + apply(&changes, ConflictPolicy::KeepMine, &live).unwrap(), + json!({ "x": 2, "z": true, "y": "mine" }) + ); + assert_eq!( + apply(&changes, ConflictPolicy::KeepTheirs, &live).unwrap(), + json!({ "x": 3, "z": true, "y": "mine" }) + ); + // 外部已经改成了同样的值:不算冲突。 + assert_eq!( + apply(&changes, ConflictPolicy::Refuse, &json!({ "x": 2 })).unwrap(), + json!({ "x": 2, "y": "mine" }) + ); + } + + #[test] + fn inactive_fields_list_row_values_that_would_not_show_up() { + let row = json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://a.example", + "API_TIMEOUT_MS": "300000", + "ENABLE_TOOL_SEARCH": "true" + }, + "hooks": { "Stop": [] }, + "theme": "dark", + "apiFormat": "anthropic" + }); + let display = json!({ + "env": { "ANTHROPIC_BASE_URL": "https://a.example", "ENABLE_TOOL_SEARCH": "true" }, + "hooks": { "Stop": [] } + }); + assert_eq!( + inactive_fields(&row, &display), + vec![ + InactiveField { + path: vec!["theme".into()], + value: json!("dark"), + }, + InactiveField { + path: vec!["env".into(), "API_TIMEOUT_MS".into()], + value: json!("300000"), + }, + ] + ); + } +} diff --git a/src-tauri/src/services/provider/claude_editor_service.rs b/src-tauri/src/services/provider/claude_editor_service.rs new file mode 100644 index 00000000..4b53f0aa --- /dev/null +++ b/src-tauri/src/services/provider/claude_editor_service.rs @@ -0,0 +1,162 @@ +//! CLI entry points for the upstream Claude editor. File diffing, ownership, +//! conflict decisions and durable publication are implemented in claude_editor. +use super::{claude_editor, live_projection_ownership, ProviderService}; +use crate::{app_config::AppType, error::AppError, provider::Provider, store::AppState}; +use serde_json::Value; + +impl ProviderService { + pub(crate) fn claude_editor_view( + state: &AppState, + settings: &Value, + ) -> Result { + claude_editor::view(state, settings) + } + + pub(crate) fn save_claude_from_editor( + state: &AppState, + mut provider: Provider, + editor: claude_editor::EditorSave, + add: bool, + copy_source: Option<&str>, + ) -> Result { + let app = AppType::Claude; + let _mutation = futures::executor::block_on( + crate::services::state_coordination::acquire_restore_mutation_guard(), + ) + .map_err(AppError::Message)?; + let _switch = + futures::executor::block_on(state.proxy_service.lock_switch_for_app("claude")); + if crate::mode::operation::settle(&state.db, "claude")?.is_some() { + state.reload_config_snapshot_from_db()?; + return Err(AppError::Message( + "An unfinished configuration write was recovered; reopen the editor before saving" + .into(), + )); + } + let copy_snapshot = if let Some(source_id) = copy_source { + state.reload_config_snapshot_from_db()?; + let snapshot = state.config.read().map_err(AppError::from)?.clone(); + let manager = snapshot + .get_manager(&app) + .ok_or_else(|| Self::app_not_found(&app))?; + let source = manager + .providers + .get(source_id) + .ok_or_else(|| AppError::Message(format!("Provider not found: {source_id}")))?; + provider = Self::duplicate_provider_with_overrides( + source, + Some(provider), + &manager.providers.keys().cloned().collect(), + ); + Some(snapshot) + } else { + None + }; + let existing = state.db.get_provider_by_id(&provider.id, "claude")?; + if add && existing.is_some() { + return Err(AppError::Message(format!( + "Provider ID already exists: {}", + provider.id + ))); + } + if !add && existing.is_none() { + return Err(AppError::Message(format!( + "Provider not found: {}", + provider.id + ))); + } + Self::normalize_provider_if_claude(&app, &mut provider); + let plan = claude_editor::plan_save( + existing.as_ref().map(|row| &row.settings_config), + &provider.settings_config, + &editor.base, + )?; + provider.settings_config = plan.row_settings.clone(); + if add { + Self::inject_coding_plan_usage_script(&app, &mut provider); + // Retain the compatibility marker for older clients. + provider + .meta + .get_or_insert_with(Default::default) + .apply_common_config = Some(true); + } + Self::validate_provider_settings(&app, &provider)?; + let current = crate::settings::get_effective_current_provider(&state.db, &app)?; + let ownership = live_projection_ownership(state, &app)?; + let first = add && current.is_none(); + let write_keys = + !ownership.takeover_active && (first || current.as_deref() == Some(&provider.id)); + if let (Some(original), Some(source_id)) = (©_snapshot, copy_source) { + Self::run_transaction_preserving_current_providers( + state, + &[AppType::Claude, AppType::Codex, AppType::Gemini], + |config| { + *config = original.clone(); + let manager = config + .get_manager_mut(&app) + .ok_or_else(|| Self::app_not_found(&app))?; + Self::shift_sort_indices_for_duplicate(manager, source_id, provider.sort_index); + manager + .providers + .insert(provider.id.clone(), provider.clone()); + Ok(((), None)) + }, + )?; + } else { + state.db.save_provider("claude", &provider)?; + } + let written = claude_editor::write_live( + &state.db, + &plan, + editor.on_conflict, + write_keys.then_some(claude_editor::KeyFieldWrite { + prev: existing.as_ref(), + target: &provider, + set_pointer: first, + }), + ) + .and_then(|()| { + state.reload_config_snapshot_from_db()?; + if ownership.takeover_active && current.as_deref() == Some(&provider.id) { + let config = state.config.read().map_err(AppError::from)?; + let mut action = Self::build_post_commit_action_for_current_provider( + &config, + &app, + &provider.id, + true, + ownership.live_taken_over, + ownership.refresh_stale_backup, + None, + )? + .expect("saved provider is present"); + action.previous_provider = existing.clone(); + // An editor save already separated globals from the row. + action.common_config_snippet = None; + drop(config); + let prepared = Self::prepare_post_commit_action(state, action)?; + Self::apply_prepared_post_commit_action(state, &prepared)?; + } + Ok(()) + }); + if let Err(error) = written { + if !crate::mode::operation::has_pending("claude") { + if let Some(original) = copy_snapshot { + Self::restore_config_only_preserving_current_providers( + state, + original, + &[AppType::Claude, AppType::Codex, AppType::Gemini], + )?; + } else { + match existing { + Some(ref row) => state.db.save_provider("claude", row)?, + None => state.db.delete_provider("claude", &provider.id)?, + } + } + } + state.reload_config_snapshot_from_db()?; + return Err(error); + } + state.reload_config_snapshot_from_db()?; + Ok(true) + } +} diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index d8b3733a..95fb17d8 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -626,7 +626,9 @@ pub(super) fn provider_uses_common_config( provider: &Provider, snippet: Option<&str>, ) -> bool { - let _ = app_type; + if matches!(app_type, AppType::Claude) { + return false; + } provider .meta .as_ref() @@ -646,12 +648,7 @@ pub(super) fn apply_common_config_to_settings( } match app_type { - AppType::Claude => { - let source = parse_json_object_snippet(app_type, trimmed)?; - let mut result = settings.clone(); - json_deep_merge(&mut result, &source); - Ok(result) - } + AppType::Claude => Ok(settings.clone()), AppType::Codex => { let mut result = settings.clone(); let config_toml = settings.get("config").and_then(Value::as_str).unwrap_or(""); @@ -858,11 +855,12 @@ pub(super) fn normalize_provider_common_config_for_storage( provider: &mut Provider, snippet: Option<&str>, ) -> Result<(), AppError> { - if provider - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config) - != Some(true) + if matches!(app_type, AppType::Claude) + || provider + .meta + .as_ref() + .and_then(|meta| meta.apply_common_config) + != Some(true) { return Ok(()); } diff --git a/src-tauri/src/services/provider/key_field_live.rs b/src-tauri/src/services/provider/key_field_live.rs index 49b2c029..4edf90eb 100644 --- a/src-tauri/src/services/provider/key_field_live.rs +++ b/src-tauri/src/services/provider/key_field_live.rs @@ -73,7 +73,6 @@ pub(super) fn edit_common( current: Option<&str>, previous: Option<&str>, ) -> Result<(), AppError> { - use crate::live::patch::KeyPath; use serde_json::Value; fn parse(text: &str) -> Result { let value: Value = @@ -85,25 +84,6 @@ pub(super) fn edit_common( } Ok(value) } - fn entries(value: &Value, path: KeyPath, out: &mut Vec<(KeyPath, Value)>) { - if let Some(map) = value.as_object() { - for (key, value) in map { - entries(value, path.child(key), out); - } - } else { - out.push((path, value.clone())); - } - } - fn global_claude(path: &KeyPath) -> bool { - use crate::live::floor; - match path.0.as_slice() { - [top, key, ..] if top == "env" => { - !floor::claude_floor_env(key) && !floor::claude_exclusive_env(key) - } - [top, ..] => top != "env" && !floor::claude_floor_top(top), - _ => false, - } - } let current = current .map(parse) .transpose()? @@ -112,23 +92,6 @@ pub(super) fn edit_common( .and_then(|s| parse(s).ok()) .unwrap_or(serde_json::json!({})); match prepared { - PreparedLiveWrite::Claude { patch } => { - let mut set = Vec::new(); - entries(¤t, KeyPath::root(), &mut set); - // Explicit common edits may change global settings; provider keys - // retain the values selected by the provider projection. - set.retain(|(path, _)| { - global_claude(path) && !patch.set.iter().any(|(key, _)| key == path) - }); - patch.set.extend(set); - let mut old = Vec::new(); - entries(&previous, KeyPath::root(), &mut old); - patch.remove_if.extend( - old.into_iter() - .filter(|(path, _)| global_claude(path)) - .map(|(path, value)| (path, vec![value])), - ); - } PreparedLiveWrite::Gemini { env, .. } => { for (key, value) in current.as_object().unwrap() { if crate::live::floor::gemini_floor_env(key) { diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 74c53f54..6c756b98 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -4,6 +4,8 @@ mod key_field_live; use std::collections::HashSet; mod claude; +pub(crate) mod claude_editor; +mod claude_editor_service; mod codex; pub(crate) mod codex_live; pub(crate) mod codex_login; @@ -1017,13 +1019,10 @@ impl ProviderService { } let effect = if action.edit_common_config && action.takeover_active - && matches!(action.app_type, AppType::Claude | AppType::Gemini) + && matches!(action.app_type, AppType::Gemini) { // Explicit CLI global edits must not replace an active proxy route. PreparedPostCommitEffect::Live(match action.app_type { - AppType::Claude => PreparedLiveWrite::Claude { - patch: Default::default(), - }, AppType::Gemini => PreparedLiveWrite::Gemini { env: Default::default(), settings: Default::default(), @@ -2201,6 +2200,12 @@ impl ProviderService { app_type: AppType, snippet: Option, ) -> Result<(), AppError> { + if matches!(app_type, AppType::Claude) { + return Err(AppError::Message(crate::t!( + "Claude settings are edited in the provider configuration editor; common snippets are retained only for older clients.", + "请在供应商配置编辑器中修改 Claude 设置;通用片段仅保留供旧版本读取。" + ).to_string())); + } let normalized_snippet = snippet.and_then(|value| { let trimmed = value.trim(); if trimmed.is_empty() { diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index ad10a6c1..e918d28c 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -2266,263 +2266,6 @@ fn sync_current_to_live_prefers_effective_current_from_local_settings() { ); } -#[test] -#[serial] -fn updating_common_snippet_uses_db_current_without_fallback_healing_config() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "missing".to_string(); - - let mut p1 = with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - )); - p1.sort_index = Some(10); - - let mut p2 = with_common_enabled(Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - )); - p2.sort_index = Some(0); - - manager.providers.insert("p1".to_string(), p1); - manager.providers.insert("p2".to_string(), p2); - } - - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "LOCAL_ONLY": "preserve-me" - } - }), - ) - .expect("seed live settings"); - - let state = state_from_config(config); - state - .db - .save_provider( - AppType::Claude.as_str(), - &with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - )), - ) - .expect("save first provider to db"); - state - .db - .save_provider( - AppType::Claude.as_str(), - &with_common_enabled(Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - )), - ) - .expect("save second provider to db"); - state - .db - .set_current_provider(AppType::Claude.as_str(), "p1") - .expect("set db current provider"); - - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(r#"{"includeCoAuthoredBy":false}"#.to_string()), - ) - .expect("update common snippet"); - - let cfg = state.config.read().expect("read config"); - let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); - assert_eq!( - manager.current, "missing", - "updating common snippet should not rewrite stale config.current while syncing live from db current" - ); - drop(cfg); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); - assert_eq!( - live.get("includeCoAuthoredBy").and_then(Value::as_bool), - Some(false), - "new common snippet should be applied to the healed current live settings" - ); - let env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); - assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1"), - "live settings should refresh from the effective current provider instead of fallback-healing config.current" - ); -} - -#[test] -#[serial] -fn updating_common_snippet_uses_db_current_when_config_snapshot_is_missing_current_provider() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "missing".to_string(); - manager.providers.insert( - "p2".to_string(), - Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - ), - ); - } - - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "LOCAL_ONLY": "preserve-me" - } - }), - ) - .expect("seed live settings"); - - let state = state_from_config(config); - state - .db - .save_provider( - AppType::Claude.as_str(), - &with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - )), - ) - .expect("save current provider to db"); - state - .db - .save_provider( - AppType::Claude.as_str(), - &with_common_enabled(Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - )), - ) - .expect("save non-current provider to db"); - state - .db - .set_current_provider(AppType::Claude.as_str(), "p1") - .expect("set db current provider"); - - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(r#"{"includeCoAuthoredBy":false}"#.to_string()), - ) - .expect("update common snippet should use db current even when config snapshot is missing it"); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); - assert_eq!( - live.get("includeCoAuthoredBy").and_then(Value::as_bool), - Some(false), - "new common snippet should be applied to live settings" - ); - let env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); - assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1"), - "live settings should be refreshed from the db current provider even when config snapshot lacks it" - ); - - let cfg = state.config.read().expect("read config after update"); - let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); - assert_eq!( - manager.current, "missing", - "updating common snippet should not rewrite stale config.current even when hydrating the current provider snapshot from db" - ); - assert!( - manager.providers.contains_key("p1"), - "missing current provider snapshot should be hydrated from db before the common snippet update is persisted" - ); - - let db_providers = state - .db - .get_all_providers(AppType::Claude.as_str()) - .expect("read db providers after update"); - assert!( - db_providers.contains_key("p1"), - "db current provider should remain persisted after updating the common snippet" - ); -} - #[test] #[serial] fn claude_switch_does_not_replay_saved_common_snippet() { @@ -2582,78 +2325,23 @@ fn claude_switch_does_not_replay_saved_common_snippet() { } #[test] -fn build_effective_live_snapshot_merges_claude_common_config_with_upstream_precedence() { - let provider = with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token", - "ANTHROPIC_BASE_URL": "https://provider.example" - }, - "includeCoAuthoredBy": true, - "permissions": { - "allow": ["Bash(git status)"] - } - }), - None, - )); - - let effective = ProviderService::build_effective_live_snapshot( - &AppType::Claude, - &provider, - Some( - r#"{"env":{"ANTHROPIC_BASE_URL":"https://common.example","CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false,"permissions":{"allow":["Bash(ls)"]}}"#, - ), - true, - ) - .expect("build effective snapshot"); - - assert_eq!( - effective["env"]["ANTHROPIC_AUTH_TOKEN"], - json!("token"), - "provider auth token should be preserved" - ); - assert_eq!( - effective["env"]["ANTHROPIC_BASE_URL"], - json!("https://common.example"), - "common config should follow upstream merge precedence" - ); - assert_eq!( - effective["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], - json!(1), - "common env values should still be merged" - ); - assert_eq!( - effective["includeCoAuthoredBy"], - json!(false), - "common top-level settings should follow upstream merge precedence" - ); - assert_eq!( - effective["permissions"]["allow"], - json!(["Bash(ls)"]), - "common nested settings should follow upstream merge precedence" - ); -} - -#[test] -#[serial] -fn startup_cleanup_scrubs_historical_gemini_credentials_without_schema_changes() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - let env_path = crate::gemini_config::get_gemini_env_path(); - std::fs::create_dir_all(env_path.parent().expect("Gemini env parent")) - .expect("create Gemini directory"); - std::fs::write( - &env_path, - "# preserve\nGOOGLE_API_KEY=leaked-key\nUNRELATED=value\n", - ) - .expect("seed Gemini live env"); - - let db = Database::memory().expect("create memory database"); - let leaked_provider = Provider::with_id( - "victim".to_string(), - "Victim".to_string(), +#[serial] +fn startup_cleanup_scrubs_historical_gemini_credentials_without_schema_changes() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + let env_path = crate::gemini_config::get_gemini_env_path(); + std::fs::create_dir_all(env_path.parent().expect("Gemini env parent")) + .expect("create Gemini directory"); + std::fs::write( + &env_path, + "# preserve\nGOOGLE_API_KEY=leaked-key\nUNRELATED=value\n", + ) + .expect("seed Gemini live env"); + + let db = Database::memory().expect("create memory database"); + let leaked_provider = Provider::with_id( + "victim".to_string(), + "Victim".to_string(), json!({ "env": { "GOOGLE_API_KEY": "leaked-key", @@ -3029,7 +2717,7 @@ fn codex_oauth_backfill_strips_only_injected_context_defaults() { let live = json!({ "env": { "ANTHROPIC_MODEL": "gpt-5.6-sol", - "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "372000", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "272000", "CLAUDE_CODE_AUTO_COMPACT_WINDOW": "360000" } }); @@ -3167,216 +2855,6 @@ fn common_config_snippet_can_be_disabled_per_provider_for_claude() { ); } -#[test] -#[serial] -fn provider_add_strips_common_snippet_before_claude_snapshot_persist() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some( - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# - .to_string(), - ); - - let state = state_from_config(config); - - let provider = with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "includeCoAuthoredBy": false, - "env": { - "ANTHROPIC_AUTH_TOKEN": "token", - "ANTHROPIC_BASE_URL": "https://claude.example", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - None, - )); - - ProviderService::add(&state, AppType::Claude, provider).expect("add should succeed"); - - let cfg = state.config.read().expect("read config after add"); - let provider = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p1") - .expect("p1 exists"); - assert!( - provider - .settings_config - .get("includeCoAuthoredBy") - .is_none(), - "common top-level keys should be stripped before persisting Claude snapshot" - ); - let env = provider - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("provider env should be object"); - assert!( - !env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "common env keys should be stripped before persisting Claude snapshot" - ); - assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token"), - "provider-specific env keys should remain in the stored snapshot" - ); -} - -#[test] -#[cfg(feature = "cli")] -#[serial] -fn tui_claude_quick_config_round_trip_crosses_storage_normalization() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - let common_snippet = r#"{ - "attribution": { "commit": "", "pr": "" }, - "env": { - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1", - "ENABLE_TOOL_SEARCH": "true", - "CLAUDE_CODE_EFFORT_LEVEL": "max", - "DISABLE_AUTOUPDATER": "1" - } - }"#; - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(common_snippet.to_string()); - let state = state_from_config(config); - - let mut form = crate::cli::tui::ProviderAddFormState::new_with_common_snippet( - AppType::Claude, - common_snippet, - ); - form.id.set("p1"); - form.name.set("Provider One"); - form.claude_base_url.set("https://claude.example"); - form.claude_api_key.set("sk-provider"); - assert_eq!(form.claude_quick_config_enabled_count(), 5); - - let provider: Provider = serde_json::from_value(form.to_provider_json_value()) - .expect("form payload should deserialize"); - ProviderService::add(&state, AppType::Claude, provider).expect("provider add should succeed"); - - let stored = state - .db - .get_provider_by_id("p1", AppType::Claude.as_str()) - .expect("read provider from database") - .expect("stored provider"); - assert_eq!( - stored - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config), - Some(true) - ); - assert!(stored.settings_config.get("attribution").is_none()); - let stored_env = stored - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("stored provider env"); - for common_key in [ - "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", - "ENABLE_TOOL_SEARCH", - "CLAUDE_CODE_EFFORT_LEVEL", - "DISABLE_AUTOUPDATER", - ] { - assert!( - !stored_env.contains_key(common_key), - "{common_key} should remain common-owned in storage" - ); - } - - let reopened = crate::cli::tui::ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &stored, - common_snippet, - ); - assert_eq!(reopened.claude_quick_config_enabled_count(), 5); - assert!(!reopened.has_unsaved_changes()); -} - -#[test] -#[cfg(feature = "cli")] -#[serial] -fn tui_claude_quick_edit_persists_legacy_inferred_common_config() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - let common_snippet = r#"{"env":{"CC_SWITCH_SHARED":"1"}}"#; - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(common_snippet.to_string()); - let state = state_from_config(config); - - let legacy_provider = Provider::with_id( - "legacy-provider".to_string(), - "Legacy Provider".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "sk-provider", - "ANTHROPIC_BASE_URL": "https://claude.example", - "CC_SWITCH_SHARED": "1" - } - }), - None, - ); - let mut form = crate::cli::tui::ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &legacy_provider, - common_snippet, - ); - assert!(form.include_common_config); - - form.toggle_claude_quick_config_field( - crate::cli::tui::ProviderAddField::ClaudeToolSearch, - common_snippet, - ) - .expect("provider-only quick edit should succeed"); - let edited: Provider = serde_json::from_value(form.to_provider_json_value()) - .expect("edited form payload should deserialize"); - ProviderService::add(&state, AppType::Claude, edited).expect("provider add should succeed"); - - let stored = state - .db - .get_provider_by_id("legacy-provider", AppType::Claude.as_str()) - .expect("read provider from database") - .expect("stored provider"); - assert_eq!( - stored - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config), - Some(true) - ); - let stored_env = stored - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("stored provider env"); - assert!(!stored_env.contains_key("CC_SWITCH_SHARED")); - assert_eq!( - stored_env.get("ENABLE_TOOL_SEARCH").and_then(Value::as_str), - Some("true") - ); - - let reopened = crate::cli::tui::ProviderAddFormState::from_provider_with_common_snippet( - AppType::Claude, - &stored, - common_snippet, - ); - assert!(reopened.include_common_config); - assert!(reopened.claude_tool_search); -} - #[test] #[cfg(feature = "cli")] #[serial] @@ -3604,7 +3082,7 @@ fn provider_add_migrates_legacy_claude_model_to_provider_owned_roles() { #[test] #[serial] -fn provider_update_strips_common_snippet_before_claude_snapshot_persist() { +fn provider_update_does_not_infer_claude_common_config_opt_in() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) @@ -3639,7 +3117,7 @@ fn provider_update_strips_common_snippet_before_claude_snapshot_persist() { let state = state_from_config(config); - let provider = with_common_enabled(Provider::with_id( + let provider = Provider::with_id( "p1".to_string(), "First Updated".to_string(), json!({ @@ -3651,7 +3129,7 @@ fn provider_update_strips_common_snippet_before_claude_snapshot_persist() { } }), None, - )); + ); ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); @@ -3662,32 +3140,29 @@ fn provider_update_strips_common_snippet_before_claude_snapshot_persist() { .providers .get("p1") .expect("p1 exists"); - assert!( + assert_eq!( provider - .settings_config - .get("includeCoAuthoredBy") - .is_none(), - "common top-level keys should be stripped before persisting updated Claude snapshot" - ); - let env = provider - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("provider env should be object"); - assert!( - !env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "common env keys should be stripped before persisting updated Claude snapshot" + .meta + .as_ref() + .and_then(|meta| meta.apply_common_config), + None, + "provider update must not infer common config opt-in from matching fields" ); assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token-updated"), - "provider-specific env keys should remain in the updated stored snapshot" + provider + .settings_config + .get("includeCoAuthoredBy") + .and_then(Value::as_bool), + Some(false), + "matching common fields remain provider-owned when not explicitly enabled" ); } #[test] #[serial] -fn provider_update_does_not_infer_claude_common_config_opt_in() { +fn provider_update_overwrites_claude_live_for_current_provider() { + // Upstream parity: updating the current provider clean-writes the new + // effective config to settings.json (no conflict prompt / detection). let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) @@ -3695,10 +3170,6 @@ fn provider_update_does_not_infer_claude_common_config_opt_in() { let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some( - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# - .to_string(), - ); { let manager = config .get_manager_mut(&AppType::Claude) @@ -3711,8 +3182,8 @@ fn provider_update_does_not_infer_claude_common_config_opt_in() { "First".to_string(), json!({ "env": { - "ANTHROPIC_AUTH_TOKEN": "token", - "ANTHROPIC_BASE_URL": "https://claude.example" + "ANTHROPIC_AUTH_TOKEN": "token-old", + "ANTHROPIC_BASE_URL": "https://claude.old" } }), None, @@ -3720,98 +3191,22 @@ fn provider_update_does_not_infer_claude_common_config_opt_in() { ); } + write_json_file( + &get_claude_settings_path(), + &json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token-old", + "ANTHROPIC_BASE_URL": "https://claude.old" + } + }), + ) + .expect("seed live settings"); + let state = state_from_config(config); - - let provider = Provider::with_id( - "p1".to_string(), - "First Updated".to_string(), - json!({ - "includeCoAuthoredBy": false, - "env": { - "ANTHROPIC_AUTH_TOKEN": "token-updated", - "ANTHROPIC_BASE_URL": "https://claude.updated", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - None, - ); - - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); - - let cfg = state.config.read().expect("read config after update"); - let provider = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p1") - .expect("p1 exists"); - assert_eq!( - provider - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config), - None, - "provider update must not infer common config opt-in from matching fields" - ); - assert_eq!( - provider - .settings_config - .get("includeCoAuthoredBy") - .and_then(Value::as_bool), - Some(false), - "matching common fields remain provider-owned when not explicitly enabled" - ); -} - -#[test] -#[serial] -fn provider_update_overwrites_claude_live_for_current_provider() { - // Upstream parity: updating the current provider clean-writes the new - // effective config to settings.json (no conflict prompt / detection). - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token-old", - "ANTHROPIC_BASE_URL": "https://claude.old" - } - }), - None, - ), - ); - } - - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token-old", - "ANTHROPIC_BASE_URL": "https://claude.old" - } - }), - ) - .expect("seed live settings"); - - let state = state_from_config(config); - state - .db - .set_current_provider(AppType::Claude.as_str(), "p1") - .expect("set db current provider"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set db current provider"); let provider = Provider::with_id( "p1".to_string(), @@ -4068,93 +3463,6 @@ async fn provider_update_waits_for_state_mutation_guard() { ); } -#[tokio::test] -#[serial] -async fn common_snippet_update_during_takeover_edits_live_globals() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let provider = with_common_enabled(Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token", - "ANTHROPIC_BASE_URL": "https://claude.example" - } - }), - None, - )); - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = - Some(r#"{"includeCoAuthoredBy":true,"env":{"OLD_COMMON":"remove-me"}}"#.to_string()); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert("p1".to_string(), provider.clone()); - } - - let state = state_from_config(config); - let old_backup = ProviderService::build_live_backup_snapshot( - &AppType::Claude, - &provider, - Some(r#"{"includeCoAuthoredBy":true,"env":{"OLD_COMMON":"remove-me"}}"#), - true, - ) - .expect("build old effective backup"); - state - .db - .save_live_backup( - AppType::Claude.as_str(), - &serde_json::to_string(&old_backup).expect("serialize old backup"), - ) - .await - .expect("seed takeover backup"); - let takeover_live = json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED", - "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" - } - }); - write_json_file(&get_claude_settings_path(), &takeover_live) - .expect("seed takeover-owned live settings"); - assert!(!state.proxy_service.is_running().await); - - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(r#"{"includeCoAuthoredBy":false}"#.to_string()), - ) - .expect("update common snippet during stopped takeover"); - - let backup = state - .db - .get_live_backup(AppType::Claude.as_str()) - .await - .expect("read refreshed backup") - .expect("takeover backup should remain"); - let backup: Value = - serde_json::from_str(&backup.original_config).expect("parse refreshed backup"); - assert_eq!( - backup, old_backup, - "global edits do not replay or rewrite legacy backups" - ); - let live: Value = read_json_file(&get_claude_settings_path()).expect("read takeover live"); - assert_eq!( - live["env"], takeover_live["env"], - "proxy fields stay attached" - ); - assert_eq!( - live["includeCoAuthoredBy"], false, - "explicit global edits apply immediately" - ); -} - #[tokio::test] #[serial] async fn provider_update_keeps_running_claude_takeover_and_refreshes_restore_backup() { @@ -4349,663 +3657,172 @@ requires_openai_auth = true .set_app_proxy_preferred_port(AppType::Codex.as_str(), 0) .expect("use an ephemeral proxy port"); state - .proxy_service - .set_takeover_for_app(AppType::Codex.as_str(), true) - .await - .expect("enable Codex takeover"); - - let mut updated = original.clone(); - updated.settings_config["config"] = json!( - r#"model_provider = "custom" -model = "gpt-5.4" - -[model_providers.custom] -name = "Codex A" -base_url = "https://api.updated.example/v1" -wire_api = "anthropic" -requires_openai_auth = true -"# - ); - updated.settings_config["modelCatalog"] = json!({ - "models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }] - }); - - ProviderService::update(&state, AppType::Codex, updated.clone()) - .expect("update current Codex provider mapping"); - - let catalog: Value = read_json_file(&crate::codex_config::get_codex_model_catalog_path()) - .expect("read generated catalog"); - assert_eq!(catalog["models"][0]["slug"], "gpt-5.4"); - assert_eq!( - catalog["models"][0]["input_modalities"], - json!(["text", "image"]), - "unknown and GPT models must fail open to image input" - ); - let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) - .expect("read Codex config.toml"); - assert!(live_config.contains("model_catalog_json")); - let live_toml: toml::Value = toml::from_str(&live_config).expect("parse live Codex config"); - assert_eq!( - live_toml["model_providers"]["custom"]["wire_api"].as_str(), - Some("responses"), - "Codex must speak Responses to the local proxy even for an Anthropic upstream" - ); - - updated.settings_config["modelCatalog"] = json!({ "models": [] }); - ProviderService::update(&state, AppType::Codex, updated) - .expect("remove current Codex provider mapping"); - - let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) - .expect("read Codex config.toml after mapping removal"); - assert!( - !live_config.contains("model_catalog_json"), - "removing mappings during takeover must clear the stale catalog pointer" - ); - - state - .proxy_service - .set_takeover_for_app(AppType::Codex.as_str(), false) - .await - .expect("disable Codex takeover"); -} - -#[tokio::test] -#[serial] -async fn provider_update_uses_live_marker_as_takeover_ownership_when_proxy_is_stopped() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let original = Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token-old", - "ANTHROPIC_BASE_URL": "https://claude.old" - } - }), - None, - ); - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert("p1".to_string(), original); - } - let state = state_from_config(config); - state.save().expect("persist config snapshot to db"); - state - .db - .set_current_provider(AppType::Claude.as_str(), "p1") - .expect("set db current provider"); - - let takeover_live = json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED", - "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" - }, - "permissions": { "allow": ["Bash"] } - }); - write_json_file(&get_claude_settings_path(), &takeover_live) - .expect("seed takeover-owned live settings"); - assert!(!state.proxy_service.is_running().await); - assert!(state - .db - .get_live_backup(AppType::Claude.as_str()) - .await - .expect("read initial backup") - .is_none()); - - let updated = Provider::with_id( - "p1".to_string(), - "First Updated".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token-new", - "ANTHROPIC_BASE_URL": "https://claude.new" - }, - "permissions": { "allow": ["Read"] } - }), - None, - ); - ProviderService::update(&state, AppType::Claude, updated) - .expect("update takeover-owned provider while proxy is stopped"); - ProviderService::sync_current_to_live(&state) - .expect("bulk live sync should preserve takeover ownership"); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read unchanged live"); - assert_eq!( - live, takeover_live, - "a stopped proxy must not trigger a normal live write over takeover placeholders" - ); - let backup = state - .db - .get_live_backup(AppType::Claude.as_str()) - .await - .expect("read refreshed backup") - .expect("refreshed backup should exist"); - let backup_value: Value = - serde_json::from_str(&backup.original_config).expect("parse refreshed backup"); - assert_eq!( - backup_value - .pointer("/env/ANTHROPIC_AUTH_TOKEN") - .and_then(Value::as_str), - Some("token-new") - ); - assert_eq!( - backup_value - .pointer("/env/ANTHROPIC_BASE_URL") - .and_then(Value::as_str), - Some("https://claude.new") - ); -} - -#[test] -#[serial] -fn provider_update_treats_settings_effective_current_as_current_for_live_write() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - ), - ); - manager.providers.insert( - "p2".to_string(), - Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - ), - ); - } - let state = state_from_config(config); - state.save().expect("persist config snapshot to db"); - - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - ) - .expect("seed current live settings as p1"); - - crate::settings::set_current_provider(&AppType::Claude, Some("p2")) - .expect("set local effective current override to p2"); - - let provider = Provider::with_id( - "p2".to_string(), - "Second Updated".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2-updated", - "ANTHROPIC_BASE_URL": "https://claude.two.updated" - } - }), - None, - ); - - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); - let live_env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); - assert_eq!( - live_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token2-updated"), - "update should treat settings effective current (p2) as current and rewrite live settings" - ); - assert_eq!( - live_env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str), - Some("https://claude.two.updated"), - "live settings should reflect updated effective current provider" - ); -} - -#[test] -#[serial] -fn provider_update_clears_invalid_local_current_override_and_falls_back_to_stored_current() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - ), - ); - manager.providers.insert( - "p2".to_string(), - Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - ), - ); - } - let state = state_from_config(config); - state.save().expect("persist config snapshot to db"); - - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - ) - .expect("seed current live settings as p2"); - - crate::settings::set_current_provider(&AppType::Claude, Some("missing")) - .expect("set invalid local current override"); - - let provider = Provider::with_id( - "p1".to_string(), - "First Updated".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1-updated", - "ANTHROPIC_BASE_URL": "https://claude.one.updated" - } - }), - None, - ); - - ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); - - assert_eq!( - crate::settings::get_current_provider(&AppType::Claude), - None, - "invalid local current override should be cleared during effective-current fallback" - ); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); - let live_env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); - assert_eq!( - live_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1-updated"), - "update should fall back to stored current provider when local override is invalid" - ); - assert_eq!( - live_env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str), - Some("https://claude.one.updated"), - "live settings should reflect stored current provider fallback" - ); -} - -#[test] -#[serial] -fn common_config_snippet_is_not_persisted_into_provider_snapshot_on_switch() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some( - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# - .to_string(), - ); - - let state = state_from_config(config); - - let p1 = Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - ); - let p2 = Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - ); - - ProviderService::add(&state, AppType::Claude, p1).expect("add p1"); - ProviderService::add(&state, AppType::Claude, p2).expect("add p2"); - - ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); - - let cfg = state.config.read().expect("read config"); - let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); - let p1_after = manager.providers.get("p1").expect("p1 exists"); - - assert!( - p1_after - .settings_config - .get("includeCoAuthoredBy") - .is_none(), - "common top-level keys should not be persisted into provider snapshot" - ); - - let env = p1_after - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("provider env should be object"); - assert!( - !env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "common env keys should not be persisted into provider snapshot" - ); - assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1"), - "provider-specific env should remain in snapshot" - ); -} - -#[test] -#[serial] -fn switch_backfill_preserves_matching_common_fields_when_meta_missing() { - let temp_home = TempDir::new().expect("create temp home"); - let _env = TestEnvGuard::isolated(temp_home.path()); - - let mut config = MultiAppConfig::default(); - config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some( - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# - .to_string(), - ); - - let mut p1 = Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - }, - "includeCoAuthoredBy": false - }), - None, - ); - p1.meta = None; - let p2 = Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two" - } - }), - None, - ); - - let state = state_from_config(config); - ProviderService::add(&state, AppType::Claude, p1).expect("add p1"); - ProviderService::add(&state, AppType::Claude, p2).expect("add p2"); + .proxy_service + .set_takeover_for_app(AppType::Codex.as_str(), true) + .await + .expect("enable Codex takeover"); - write_json_file( - &get_claude_settings_path(), - &json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - }, - "includeCoAuthoredBy": false - }), - ) - .expect("seed live settings with provider-owned fields matching common snippet"); + let mut updated = original.clone(); + updated.settings_config["config"] = json!( + r#"model_provider = "custom" +model = "gpt-5.4" - ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); +[model_providers.custom] +name = "Codex A" +base_url = "https://api.updated.example/v1" +wire_api = "anthropic" +requires_openai_auth = true +"# + ); + updated.settings_config["modelCatalog"] = json!({ + "models": [{ "model": "gpt-5.4", "displayName": "GPT 5.4" }] + }); - let cfg = state.config.read().expect("read config"); - let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); - let p1_after = manager.providers.get("p1").expect("p1 exists"); - let env = p1_after - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("provider env should be object"); + ProviderService::update(&state, AppType::Codex, updated.clone()) + .expect("update current Codex provider mapping"); + let catalog: Value = read_json_file(&crate::codex_config::get_codex_model_catalog_path()) + .expect("read generated catalog"); + assert_eq!(catalog["models"][0]["slug"], "gpt-5.4"); assert_eq!( - p1_after.settings_config.get("includeCoAuthoredBy"), - Some(&json!(false)), - "matching top-level fields are provider-owned when common config was never explicitly enabled" + catalog["models"][0]["input_modalities"], + json!(["text", "image"]), + "unknown and GPT models must fail open to image input" ); + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read Codex config.toml"); + assert!(live_config.contains("model_catalog_json")); + let live_toml: toml::Value = toml::from_str(&live_config).expect("parse live Codex config"); assert_eq!( - env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - Some(&json!(1)), - "matching env fields are provider-owned when common config was never explicitly enabled" + live_toml["model_providers"]["custom"]["wire_api"].as_str(), + Some("responses"), + "Codex must speak Responses to the local proxy even for an Anthropic upstream" ); - assert_eq!( - p1_after - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config), - None, - "backfill must not silently opt missing-meta providers into common config" + + updated.settings_config["modelCatalog"] = json!({ "models": [] }); + ProviderService::update(&state, AppType::Codex, updated) + .expect("remove current Codex provider mapping"); + + let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path()) + .expect("read Codex config.toml after mapping removal"); + assert!( + !live_config.contains("model_catalog_json"), + "removing mappings during takeover must clear the stale catalog pointer" ); + + state + .proxy_service + .set_takeover_for_app(AppType::Codex.as_str(), false) + .await + .expect("disable Codex takeover"); } -#[test] +#[tokio::test] #[serial] -fn updating_common_snippet_removes_stale_fields_from_other_claude_provider_snapshots() { +async fn provider_update_uses_live_marker_as_takeover_ownership_when_proxy_is_stopped() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) .expect("create ~/.claude (initialized)"); - let old_snippet = - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"#; - let new_snippet = r#"{"env":{"CLAUDE_CODE_USE_BEDROCK":1},"includeCoAuthoredBy":true}"#; - + let original = Provider::with_id( + "p1".to_string(), + "First".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token-old", + "ANTHROPIC_BASE_URL": "https://claude.old" + } + }), + None, + ); let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(old_snippet.to_string()); { let manager = config .get_manager_mut(&AppType::Claude) .expect("claude manager"); manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - ), - ); - manager.providers.insert( - "p2".to_string(), - Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "includeCoAuthoredBy": false, - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - None, - ), - ); + manager.providers.insert("p1".to_string(), original); } - - write_json_file( - &get_claude_settings_path(), - &json!({ - "includeCoAuthoredBy": false, - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - ) - .expect("seed current live settings"); - let state = state_from_config(config); state.save().expect("persist config snapshot to db"); + state + .db + .set_current_provider(AppType::Claude.as_str(), "p1") + .expect("set db current provider"); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(new_snippet.to_string()), - ) - .expect("update common snippet"); - - let cfg = state.config.read().expect("read config after update"); - assert_eq!( - cfg.common_config_snippets.claude.as_deref(), - Some(new_snippet), - "new snippet should be persisted into app config" - ); + let takeover_live = json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED", + "ANTHROPIC_BASE_URL": "http://127.0.0.1:15721" + }, + "permissions": { "allow": ["Bash"] } + }); + write_json_file(&get_claude_settings_path(), &takeover_live) + .expect("seed takeover-owned live settings"); + assert!(!state.proxy_service.is_running().await); + assert!(state + .db + .get_live_backup(AppType::Claude.as_str()) + .await + .expect("read initial backup") + .is_none()); - let p2_after = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p2") - .expect("p2 exists"); - assert!( - p2_after - .settings_config - .get("includeCoAuthoredBy") - .is_none(), - "old top-level common keys should be stripped from other provider snapshots" - ); - let p2_env = p2_after - .settings_config - .get("env") - .and_then(Value::as_object) - .expect("p2 env should be object"); - assert!( - !p2_env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "old common env keys should be stripped from other provider snapshots" - ); - assert_eq!( - p2_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token2"), - "provider-specific env keys should remain after migration" + let updated = Provider::with_id( + "p1".to_string(), + "First Updated".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token-new", + "ANTHROPIC_BASE_URL": "https://claude.new" + }, + "permissions": { "allow": ["Read"] } + }), + None, ); - drop(cfg); + ProviderService::update(&state, AppType::Claude, updated) + .expect("update takeover-owned provider while proxy is stopped"); + ProviderService::sync_current_to_live(&state) + .expect("bulk live sync should preserve takeover ownership"); - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); + let live: Value = read_json_file(&get_claude_settings_path()).expect("read unchanged live"); assert_eq!( - live.get("includeCoAuthoredBy").and_then(Value::as_bool), - Some(true), - "current live settings should reflect the new common snippet" + live, takeover_live, + "a stopped proxy must not trigger a normal live write over takeover placeholders" ); - let live_env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); + let backup = state + .db + .get_live_backup(AppType::Claude.as_str()) + .await + .expect("read refreshed backup") + .expect("refreshed backup should exist"); + let backup_value: Value = + serde_json::from_str(&backup.original_config).expect("parse refreshed backup"); assert_eq!( - live_env - .get("CLAUDE_CODE_USE_BEDROCK") - .and_then(Value::as_i64), - None, - "global snippet edits must not switch provider-owned protocol selectors" - ); - assert!( - !live_env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "old common env key should be removed from current live settings" + backup_value + .pointer("/env/ANTHROPIC_AUTH_TOKEN") + .and_then(Value::as_str), + Some("token-new") ); assert_eq!( - live_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1"), - "current provider env should remain in live settings" + backup_value + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str), + Some("https://claude.new") ); } #[test] #[serial] -fn updating_common_snippet_preserves_migrated_provider_owned_claude_models() { +fn provider_update_treats_settings_effective_current_as_current_for_live_write() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) .expect("create ~/.claude (initialized)"); - let old_snippet = r#"{"env":{"ANTHROPIC_SMALL_FAST_MODEL":"claude-3-5-haiku-20241022"}}"#; - let new_snippet = r#"{"env":{"CLAUDE_CODE_USE_BEDROCK":1}}"#; - let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(old_snippet.to_string()); { let manager = config .get_manager_mut(&AppType::Claude) @@ -5033,16 +3850,15 @@ fn updating_common_snippet_preserves_migrated_provider_owned_claude_models() { json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-3-5-haiku-20241022", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-3-5-haiku-20241022", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-3-5-haiku-20241022" + "ANTHROPIC_BASE_URL": "https://claude.two" } }), None, ), ); } + let state = state_from_config(config); + state.save().expect("persist config snapshot to db"); write_json_file( &get_claude_settings_path(), @@ -5053,63 +3869,52 @@ fn updating_common_snippet_preserves_migrated_provider_owned_claude_models() { } }), ) - .expect("seed current live settings"); + .expect("seed current live settings as p1"); - let state = state_from_config(config); + crate::settings::set_current_provider(&AppType::Claude, Some("p2")) + .expect("set local effective current override to p2"); + + let provider = Provider::with_id( + "p2".to_string(), + "Second Updated".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token2-updated", + "ANTHROPIC_BASE_URL": "https://claude.two.updated" + } + }), + None, + ); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(new_snippet.to_string()), - ) - .expect("update common snippet"); + ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); - let cfg = state.config.read().expect("read config after update"); - let p2_after = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p2") - .expect("p2 exists"); - let p2_env = p2_after - .settings_config + let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); + let live_env = live .get("env") .and_then(Value::as_object) - .expect("p2 env should be object"); - - for key in [ - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - ] { - assert_eq!( - p2_env.get(key).and_then(Value::as_str), - Some("claude-3-5-haiku-20241022"), - "{key} should remain provider-owned after the old common snippet is retired" - ); - } + .expect("live env should be object"); assert_eq!( - p2_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token2"), - "provider-specific env keys should remain after migration" + live_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), + Some("token2-updated"), + "update should treat settings effective current (p2) as current and rewrite live settings" + ); + assert_eq!( + live_env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str), + Some("https://claude.two.updated"), + "live settings should reflect updated effective current provider" ); } #[test] #[serial] -fn updating_common_snippet_skips_providers_with_apply_common_config_disabled() { +fn provider_update_clears_invalid_local_current_override_and_falls_back_to_stored_current() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::config::get_claude_config_dir()) .expect("create ~/.claude (initialized)"); - let old_snippet = - r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"#; - let new_snippet = r#"{"env":{"CLAUDE_CODE_USE_BEDROCK":1},"includeCoAuthoredBy":true}"#; - let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(old_snippet.to_string()); { let manager = config .get_manager_mut(&AppType::Claude) @@ -5131,212 +3936,186 @@ fn updating_common_snippet_skips_providers_with_apply_common_config_disabled() { ); manager.providers.insert( "p2".to_string(), - serde_json::from_value(json!({ - "id": "p2", - "name": "Second", - "settingsConfig": { - "includeCoAuthoredBy": false, + Provider::with_id( + "p2".to_string(), + "Second".to_string(), + json!({ "env": { "ANTHROPIC_AUTH_TOKEN": "token2", - "ANTHROPIC_BASE_URL": "https://claude.two", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 + "ANTHROPIC_BASE_URL": "https://claude.two" } - }, - "meta": { "applyCommonConfig": false } - })) - .expect("parse provider p2"), + }), + None, + ), ); } + let state = state_from_config(config); + state.save().expect("persist config snapshot to db"); write_json_file( &get_claude_settings_path(), &json!({ - "includeCoAuthoredBy": false, "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 + "ANTHROPIC_AUTH_TOKEN": "token2", + "ANTHROPIC_BASE_URL": "https://claude.two" } }), ) - .expect("seed current live settings"); + .expect("seed current live settings as p2"); - let state = state_from_config(config); + crate::settings::set_current_provider(&AppType::Claude, Some("missing")) + .expect("set invalid local current override"); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(new_snippet.to_string()), - ) - .expect("update common snippet"); + let provider = Provider::with_id( + "p1".to_string(), + "First Updated".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token1-updated", + "ANTHROPIC_BASE_URL": "https://claude.one.updated" + } + }), + None, + ); + + ProviderService::update(&state, AppType::Claude, provider).expect("update should succeed"); - let cfg = state.config.read().expect("read config after update"); - let p2_after = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p2") - .expect("p2 exists"); assert_eq!( - p2_after - .settings_config - .get("includeCoAuthoredBy") - .and_then(Value::as_bool), - Some(false), - "applyCommonConfig=false provider should keep its stored top-level fields during migration" + crate::settings::get_current_provider(&AppType::Claude), + None, + "invalid local current override should be cleared during effective-current fallback" ); - let p2_env = p2_after - .settings_config + + let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); + let live_env = live .get("env") .and_then(Value::as_object) - .expect("p2 env should be object"); + .expect("live env should be object"); assert_eq!( - p2_env - .get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC") - .and_then(Value::as_i64), - Some(1), - "applyCommonConfig=false provider should keep its stored common env keys during migration" + live_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), + Some("token1-updated"), + "update should fall back to stored current provider when local override is invalid" ); assert_eq!( - p2_env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token2"), - "provider-specific env keys should remain untouched" + live_env.get("ANTHROPIC_BASE_URL").and_then(Value::as_str), + Some("https://claude.one.updated"), + "live settings should reflect stored current provider fallback" ); } #[test] #[serial] -fn setting_claude_common_snippet_does_not_infer_existing_provider_opt_in() { +fn common_config_snippet_is_not_persisted_into_provider_snapshot_on_switch() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let new_snippet = - r#"{"includeCoAuthoredBy":false,"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1}}"#; let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "includeCoAuthoredBy": false, - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - None, - ), - ); - } + config.common_config_snippets.claude = Some( + r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# + .to_string(), + ); let state = state_from_config(config); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(new_snippet.to_string()), - ) - .expect("set common snippet"); - - let cfg = state.config.read().expect("read config after update"); - let provider = cfg - .get_manager(&AppType::Claude) - .expect("claude manager") - .providers - .get("p1") - .expect("p1 exists"); - - assert_eq!( - provider - .meta - .as_ref() - .and_then(|meta| meta.apply_common_config), + let p1 = Provider::with_id( + "p1".to_string(), + "First".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.one" + } + }), None, - "setting a new snippet must not silently enable common config on existing providers" ); - assert_eq!( - provider + let p2 = Provider::with_id( + "p2".to_string(), + "Second".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token2", + "ANTHROPIC_BASE_URL": "https://claude.two" + } + }), + None, + ); + + ProviderService::add(&state, AppType::Claude, p1).expect("add p1"); + ProviderService::add(&state, AppType::Claude, p2).expect("add p2"); + + ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); + + let cfg = state.config.read().expect("read config"); + let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); + let p1_after = manager.providers.get("p1").expect("p1 exists"); + + assert!( + p1_after .settings_config .get("includeCoAuthoredBy") - .and_then(Value::as_bool), - Some(false), - "new Claude common top-level fields should remain provider-owned without explicit opt-in" + .is_none(), + "common top-level keys should not be persisted into provider snapshot" ); - let env = provider + + let env = p1_after .settings_config .get("env") .and_then(Value::as_object) - .expect("stored claude env should be object"); - assert_eq!( - env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC") - .and_then(Value::as_i64), - Some(1), - "new Claude common env fields should remain provider-owned without explicit opt-in" + .expect("provider env should be object"); + assert!( + !env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), + "common env keys should not be persisted into provider snapshot" ); assert_eq!( env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), Some("token1"), - "provider-specific Claude env should remain after normalization" + "provider-specific env should remain in snapshot" ); } #[test] #[serial] -fn clearing_claude_common_snippet_tolerates_invalid_stored_snippet() { +fn switch_backfill_preserves_matching_common_fields_when_meta_missing() { let temp_home = TempDir::new().expect("create temp home"); let _env = TestEnvGuard::isolated(temp_home.path()); - std::fs::create_dir_all(crate::config::get_claude_config_dir()) - .expect("create ~/.claude (initialized)"); - - let invalid_old_snippet = r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1}"#; let mut config = MultiAppConfig::default(); config.ensure_app(&AppType::Claude); - config.common_config_snippets.claude = Some(invalid_old_snippet.to_string()); - { - let manager = config - .get_manager_mut(&AppType::Claude) - .expect("claude manager"); - manager.current = "p1".to_string(); - manager.providers.insert( - "p1".to_string(), - Provider::with_id( - "p1".to_string(), - "First".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token1", - "ANTHROPIC_BASE_URL": "https://claude.one" - } - }), - None, - ), - ); - manager.providers.insert( - "p2".to_string(), - Provider::with_id( - "p2".to_string(), - "Second".to_string(), - json!({ - "env": { - "ANTHROPIC_AUTH_TOKEN": "token2", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } - }), - None, - ), - ); - } + config.common_config_snippets.claude = Some( + r#"{"env":{"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC":1},"includeCoAuthoredBy":false}"# + .to_string(), + ); + + let mut p1 = Provider::with_id( + "p1".to_string(), + "First".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token1", + "ANTHROPIC_BASE_URL": "https://claude.one", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 + }, + "includeCoAuthoredBy": false + }), + None, + ); + p1.meta = None; + let p2 = Provider::with_id( + "p2".to_string(), + "Second".to_string(), + json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "token2", + "ANTHROPIC_BASE_URL": "https://claude.two" + } + }), + None, + ); + + let state = state_from_config(config); + ProviderService::add(&state, AppType::Claude, p1).expect("add p1"); + ProviderService::add(&state, AppType::Claude, p2).expect("add p2"); write_json_file( &get_claude_settings_path(), @@ -5345,37 +4124,40 @@ fn clearing_claude_common_snippet_tolerates_invalid_stored_snippet() { "ANTHROPIC_AUTH_TOKEN": "token1", "ANTHROPIC_BASE_URL": "https://claude.one", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": 1 - } + }, + "includeCoAuthoredBy": false }), ) - .expect("seed current live settings"); + .expect("seed live settings with provider-owned fields matching common snippet"); - let state = state_from_config(config); - state.save().expect("persist config snapshot to db"); + ProviderService::switch(&state, AppType::Claude, "p2").expect("switch to p2"); - ProviderService::clear_common_config_snippet(&state, AppType::Claude) - .expect("clear should recover from invalid stored snippet"); + let cfg = state.config.read().expect("read config"); + let manager = cfg.get_manager(&AppType::Claude).expect("claude manager"); + let p1_after = manager.providers.get("p1").expect("p1 exists"); + let env = p1_after + .settings_config + .get("env") + .and_then(Value::as_object) + .expect("provider env should be object"); - let cfg = state.config.read().expect("read config after clear"); assert_eq!( - cfg.common_config_snippets.claude, None, - "invalid stored snippet should not block clearing the saved common snippet" + p1_after.settings_config.get("includeCoAuthoredBy"), + Some(&json!(false)), + "matching top-level fields are provider-owned when common config was never explicitly enabled" ); - drop(cfg); - - let live: Value = read_json_file(&get_claude_settings_path()).expect("read live settings"); - let env = live - .get("env") - .and_then(Value::as_object) - .expect("live env should be object"); - assert!( - env.contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), - "an unreadable old snippet cannot establish ownership of existing user settings" + assert_eq!( + env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"), + Some(&json!(1)), + "matching env fields are provider-owned when common config was never explicitly enabled" ); assert_eq!( - env.get("ANTHROPIC_AUTH_TOKEN").and_then(Value::as_str), - Some("token1"), - "provider-specific Claude env should remain after recovery" + p1_after + .meta + .as_ref() + .and_then(|meta| meta.apply_common_config), + None, + "backfill must not silently opt missing-meta providers into common config" ); } @@ -7863,52 +6645,6 @@ fn key_field_gemini_invalid_settings_does_not_publish_env_or_pointer() { ); } -#[test] -#[serial] -fn key_field_common_edit_preserves_active_proxy_route() { - let (_home, _env, state) = setup_claude_switch_preview_state(json!({ - "env": {"ANTHROPIC_AUTH_TOKEN":"PROXY_MANAGED", "ANTHROPIC_BASE_URL":"http://127.0.0.1:15721"}, - "permissions": {"allow":["Read"]} - })); - { - let mut config = state.config.write().unwrap(); - let current = config - .get_manager_mut(&AppType::Claude) - .unwrap() - .providers - .get_mut("p1") - .unwrap(); - current.meta = Some(ProviderMeta { - apply_common_config: Some(true), - ..Default::default() - }); - } - state.save().unwrap(); - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(r#"{"env":{"API_TIMEOUT_MS":"999"},"includeCoAuthoredBy":false}"#.into()), - ) - .expect("edit global settings while proxy is attached"); - let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); - assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "PROXY_MANAGED"); - assert_eq!(live["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:15721"); - assert_eq!(live["env"]["API_TIMEOUT_MS"], "999"); - assert_eq!(live["includeCoAuthoredBy"], false); - assert_eq!(live["permissions"]["allow"], json!(["Read"])); - for env in [json!(null), json!("invalid"), json!([])] { - ProviderService::set_common_config_snippet( - &state, - AppType::Claude, - Some(json!({"env":env}).to_string()), - ) - .unwrap(); - let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); - assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "PROXY_MANAGED"); - assert_eq!(live["env"]["ANTHROPIC_BASE_URL"], "http://127.0.0.1:15721"); - } -} - #[tokio::test] #[serial] async fn key_field_proxy_update_cleans_removed_exclusive_fields() { @@ -8276,3 +7012,356 @@ async fn key_field_gemini_removing_entire_config_does_not_restore_old_model() { assert!(settings.pointer("/model/name").is_none()); } } + +#[test] +#[serial] +fn claude_editor_saves_issue_530_fields_and_preserves_globals_on_switch() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + std::fs::create_dir_all(crate::config::get_claude_config_dir()).unwrap(); + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + let mut first = Provider::with_id( + "first".into(), + "First".into(), + json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key-first","ANTHROPIC_BASE_URL":"https://first.example"}}), + None, + ); + let second = Provider::with_id( + "second".into(), + "Second".into(), + json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key-second","ANTHROPIC_BASE_URL":"https://second.example"}}), + None, + ); + ProviderService::add(&state, AppType::Claude, first.clone()).unwrap(); + ProviderService::add(&state, AppType::Claude, second.clone()).unwrap(); + let view = ProviderService::claude_editor_view(&state, &first.settings_config).unwrap(); + first.settings_config = view.settings.clone(); + first.settings_config["env"]["CLAUDE_CODE_DISABLE_1M_CONTEXT"] = json!("1"); + first.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = json!("120000"); + first.settings_config["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = json!("102000"); + first.settings_config["language"] = json!("chinese"); + first.settings_config["disableDeepLinkRegistration"] = json!("disable"); + ProviderService::save_claude_from_editor( + &state, + first, + claude_editor::EditorSave { + base: view.settings, + draft: None, + on_conflict: Default::default(), + }, + false, + None, + ) + .unwrap(); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "120000"); + assert_eq!(live["env"]["CLAUDE_CODE_DISABLE_1M_CONTEXT"], "1"); + assert_eq!(live["env"]["CLAUDE_CODE_AUTO_COMPACT_WINDOW"], "102000"); + assert_eq!(live["language"], "chinese"); + assert_eq!(live["disableDeepLinkRegistration"], "disable"); + let stored = state + .db + .get_provider_by_id("first", "claude") + .unwrap() + .unwrap(); + assert!(stored.settings_config.get("language").is_none()); + assert_eq!( + stored.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], + "120000" + ); + ProviderService::switch(&state, AppType::Claude, "second").unwrap(); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["language"], "chinese"); + assert!(live["env"].get("CLAUDE_CODE_MAX_CONTEXT_TOKENS").is_none()); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "key-second"); + ProviderService::switch(&state, AppType::Claude, "first").unwrap(); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "120000"); +} + +#[test] +#[serial] +fn claude_editor_inactive_save_conflicts_are_explicit_and_row_is_rolled_back() { + use claude_editor::{ConflictPolicy, EditorSave}; + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + std::fs::create_dir_all(crate::config::get_claude_config_dir()).unwrap(); + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + let first = Provider::with_id( + "first".into(), + "First".into(), + json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key-first"}}), + None, + ); + let mut second = Provider::with_id( + "second".into(), + "Second".into(), + json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key-second"},"language":"legacy"}), + None, + ); + ProviderService::add(&state, AppType::Claude, first).unwrap(); + ProviderService::add(&state, AppType::Claude, second.clone()).unwrap(); + let view = ProviderService::claude_editor_view(&state, &second.settings_config).unwrap(); + assert_eq!(view.inactive.len(), 1); + assert!(view.settings.get("language").is_none()); + second.settings_config = view.settings.clone(); + second.settings_config["language"] = json!("mine"); + second.settings_config["env"]["ANTHROPIC_AUTH_TOKEN"] = json!("new-key"); + second.settings_config["permissions"] = json!({"allow":["Read"]}); + let path = get_claude_settings_path(); + let mut external: Value = read_json_file(&path).unwrap(); + external["language"] = json!("external"); + external["env"]["EXTERNAL_UNTOUCHED"] = json!("keep"); + std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap(); + let editor = EditorSave { + base: view.settings, + draft: None, + on_conflict: ConflictPolicy::Refuse, + }; + let before_copy = serde_json::to_value(state.db.get_all_providers("claude").unwrap()).unwrap(); + let copy_error = ProviderService::save_claude_from_editor( + &state, + second.clone(), + editor.clone(), + true, + Some("first"), + ) + .unwrap_err(); + assert!(claude_editor::conflict_keys(©_error).is_some()); + assert_eq!( + serde_json::to_value(state.db.get_all_providers("claude").unwrap()).unwrap(), + before_copy + ); + assert_eq!(read_json_file::(&path).unwrap(), external); + let error = ProviderService::save_claude_from_editor( + &state, + second.clone(), + editor.clone(), + false, + None, + ) + .unwrap_err(); + assert_eq!( + claude_editor::conflict_keys(&error).unwrap(), + vec!["language"] + ); + assert_eq!(read_json_file::(&path).unwrap(), external); + assert_eq!( + state + .db + .get_provider_by_id("second", "claude") + .unwrap() + .unwrap() + .settings_config["env"]["ANTHROPIC_AUTH_TOKEN"], + "key-second" + ); + ProviderService::save_claude_from_editor( + &state, + second.clone(), + EditorSave { + on_conflict: ConflictPolicy::KeepTheirs, + ..editor.clone() + }, + false, + None, + ) + .unwrap(); + let live: Value = read_json_file(&path).unwrap(); + assert_eq!(live["language"], "external"); + assert_eq!(live["permissions"]["allow"], json!(["Read"])); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "key-first"); + assert_eq!(live["env"]["EXTERNAL_UNTOUCHED"], "keep"); + ProviderService::save_claude_from_editor( + &state, + second, + EditorSave { + on_conflict: ConflictPolicy::KeepMine, + ..editor + }, + false, + None, + ) + .unwrap(); + assert_eq!(read_json_file::(&path).unwrap()["language"], "mine"); +} + +#[tokio::test] +#[serial] +async fn claude_editor_proxy_save_keeps_route_and_globals_after_restore() { + let (_home, _env, state) = setup_claude_switch_preview_state(json!({"env":{}})); + state.db.set_app_proxy_preferred_port("claude", 0).unwrap(); + state + .proxy_service + .set_takeover_for_app("claude", true) + .await + .unwrap(); + let mut provider = state + .db + .get_provider_by_id("p1", "claude") + .unwrap() + .unwrap(); + let view = ProviderService::claude_editor_view(&state, &provider.settings_config).unwrap(); + let expected_key = view.settings["env"]["ANTHROPIC_AUTH_TOKEN"].clone(); + assert_ne!(expected_key, json!("PROXY_MANAGED")); + provider.settings_config = view.settings.clone(); + provider.settings_config["language"] = json!("chinese"); + provider.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"] = json!("120000"); + ProviderService::save_claude_from_editor( + &state, + provider, + claude_editor::EditorSave { + base: view.settings, + draft: None, + on_conflict: Default::default(), + }, + false, + None, + ) + .unwrap(); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "PROXY_MANAGED"); + assert_eq!(live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "120000"); + assert_eq!(live["language"], "chinese"); + state + .proxy_service + .set_takeover_for_app("claude", false) + .await + .unwrap(); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], expected_key); + assert_eq!(live["language"], "chinese"); + assert_eq!(live["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], "120000"); +} + +#[test] +#[serial] +fn claude_editor_preserves_legacy_snippet_and_row_data_without_runtime_merging() { + let home = TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let snippet = r#"{"env":{"CLAUDE_CODE_MAX_CONTEXT_TOKENS":"120000"},"language":"legacy"}"#; + let row = json!({"env":{"ANTHROPIC_AUTH_TOKEN":"key","CLAUDE_CODE_MAX_CONTEXT_TOKENS":"120000"},"language":"legacy"}); + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + config.common_config_snippets.claude = Some(snippet.into()); + let mut provider = with_common_enabled(Provider::with_id( + "p1".into(), + "First".into(), + row.clone(), + None, + )); + config + .get_manager_mut(&AppType::Claude) + .unwrap() + .providers + .insert("p1".into(), provider.clone()); + let state = state_from_config(config); + super::common_config::migrate_common_config_upstream_semantics_if_needed( + &state.db, + &mut state.config.write().unwrap(), + ) + .unwrap(); + assert_eq!( + state + .db + .get_provider_by_id("p1", "claude") + .unwrap() + .unwrap() + .settings_config, + row + ); + assert_eq!( + state.db.get_config_snippet("claude").unwrap().as_deref(), + Some(snippet) + ); + assert!( + ProviderService::set_common_config_snippet(&state, AppType::Claude, Some("{}".into())) + .is_err() + ); + assert_eq!( + state.db.get_config_snippet("claude").unwrap().as_deref(), + Some(snippet) + ); + let view = ProviderService::claude_editor_view(&state, &provider.settings_config).unwrap(); + assert!(view.settings.get("language").is_none()); + assert!(view + .inactive + .iter() + .any(|field| field.path == vec!["language"])); + provider.settings_config = view.settings.clone(); + provider.settings_config["language"] = json!("chinese"); + ProviderService::save_claude_from_editor( + &state, + provider, + claude_editor::EditorSave { + base: view.settings, + draft: None, + on_conflict: Default::default(), + }, + false, + None, + ) + .unwrap(); + let saved = state + .db + .get_provider_by_id("p1", "claude") + .unwrap() + .unwrap(); + assert_eq!(saved.settings_config["language"], "legacy"); + assert_eq!( + saved.settings_config["env"]["CLAUDE_CODE_MAX_CONTEXT_TOKENS"], + "120000" + ); + assert_eq!( + read_json_file::(&get_claude_settings_path()).unwrap()["language"], + "chinese" + ); +} + +#[test] +#[serial] +fn claude_editor_first_add_commits_pointer_and_compatibility_marker() { + let home = tempfile::TempDir::new().unwrap(); + let _env = TestEnvGuard::isolated(home.path()); + let mut config = MultiAppConfig::default(); + config.ensure_app(&AppType::Claude); + let state = state_from_config(config); + let view = ProviderService::claude_editor_view(&state, &json!({})).unwrap(); + let provider = Provider::with_id( + "first".into(), + "First".into(), + json!({"env":{"ANTHROPIC_AUTH_TOKEN":"first-key"},"language":"chinese"}), + None, + ); + ProviderService::save_claude_from_editor( + &state, + provider, + claude_editor::EditorSave { + base: view.settings, + draft: None, + on_conflict: Default::default(), + }, + true, + None, + ) + .unwrap(); + assert_eq!( + crate::settings::get_effective_current_provider(&state.db, &AppType::Claude) + .unwrap() + .as_deref(), + Some("first") + ); + let stored = state + .db + .get_provider_by_id("first", "claude") + .unwrap() + .unwrap(); + assert_eq!(stored.meta.unwrap().apply_common_config, Some(true)); + assert!(stored.settings_config.get("language").is_none()); + let live: Value = read_json_file(&get_claude_settings_path()).unwrap(); + assert_eq!(live["language"], "chinese"); + assert_eq!(live["env"]["ANTHROPIC_AUTH_TOKEN"], "first-key"); +} diff --git a/src-tauri/src/services/sync_protocol.rs b/src-tauri/src/services/sync_protocol.rs index cc47830b..f93f4467 100644 --- a/src-tauri/src/services/sync_protocol.rs +++ b/src-tauri/src/services/sync_protocol.rs @@ -570,8 +570,8 @@ mod tests { } #[test] - fn upstream_v19_and_v20_database_schemas_are_accepted() { - for version in [19, 20] { + fn upstream_v19_through_v22_database_schemas_are_accepted() { + for version in 19..=22 { let sql = format!("PRAGMA user_version={version};\n"); assert!(validate_sql_user_version_for_import(&sql).is_ok()); } diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 263aa29f..568fdeea 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -2318,9 +2318,9 @@ mod tests { [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), )?; - assert_eq!(input_cost, "5.000000"); - assert_eq!(output_cost, "30.000000"); - assert_eq!(total_cost, "35.000000"); + assert_eq!(input_cost, "4.000000"); + assert_eq!(output_cost, "20.000000"); + assert_eq!(total_cost, "24.000000"); Ok(()) } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 89f01708..6d2d4d3a 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -1303,11 +1303,9 @@ requires_openai_auth = true let state = AppState::try_new_with_startup_recovery().expect("first startup"); assert!(state.db.get_config_snippet("claude").unwrap().is_some()); - crate::services::provider::ProviderService::clear_common_config_snippet( - &state, - crate::app_config::AppType::Claude, - ) - .expect("clear snippet"); + // A previous-version client cleared this compatibility data. + state.db.set_config_snippet("claude", None).unwrap(); + state.db.set_config_snippet_cleared("claude", true).unwrap(); assert!(state.db.is_config_snippet_cleared("claude").unwrap()); assert!(state.db.get_config_snippet("claude").unwrap().is_none()); diff --git a/src-tauri/tests/provider_add_noninteractive.rs b/src-tauri/tests/provider_add_noninteractive.rs index bf6043a8..c8bebbc0 100644 --- a/src-tauri/tests/provider_add_noninteractive.rs +++ b/src-tauri/tests/provider_add_noninteractive.rs @@ -514,7 +514,7 @@ fn add_packycode_template_is_scriptable_for_every_additive_app() { "https://www.packyapi.ai/v1" ); assert!(opencode.settings_config["models"] - .get("claude-opus-5") + .get("claude-opus-5-5") .is_some()); let hermes = saved_provider(AppType::Hermes, "packy-hermes"); @@ -524,7 +524,7 @@ fn add_packycode_template_is_scriptable_for_every_additive_app() { "https://www.packyapi.ai" ); assert_eq!(hermes.settings_config["api_mode"], "anthropic_messages"); - assert_eq!(hermes.settings_config["models"][0]["id"], "claude-opus-5"); + assert_eq!(hermes.settings_config["models"][0]["id"], "claude-opus-5-5"); let openclaw = saved_provider(AppType::OpenClaw, "packy-openclaw"); assert_eq!(openclaw.settings_config["apiKey"], "sk-packy"); @@ -533,7 +533,10 @@ fn add_packycode_template_is_scriptable_for_every_additive_app() { "https://www.packyapi.ai" ); assert_eq!(openclaw.settings_config["api"], "anthropic-messages"); - assert_eq!(openclaw.settings_config["models"][0]["id"], "claude-opus-5"); + assert_eq!( + openclaw.settings_config["models"][0]["id"], + "claude-opus-5-5" + ); } #[test] @@ -574,7 +577,7 @@ fn add_openmodel_template_is_scriptable_for_every_app() { let codex_config = codex.settings_config["config"] .as_str() .expect("OpenModel Codex config should be TOML"); - assert!(codex_config.contains("model = \"gpt-5.6-sol\"")); + assert!(codex_config.contains("model = \"gpt-6.1-sol\"")); assert!(codex_config.contains("base_url = \"https://api.openmodel.ai/v1\"")); assert!(codex_config.contains("wire_api = \"responses\"")); @@ -584,7 +587,7 @@ fn add_openmodel_template_is_scriptable_for_every_app() { env_str(&gemini, "GOOGLE_GEMINI_BASE_URL"), Some("https://api.openmodel.ai") ); - assert_eq!(env_str(&gemini, "GEMINI_MODEL"), Some("gemini-3.6-flash")); + assert_eq!(env_str(&gemini, "GEMINI_MODEL"), Some("gemini-3.8-flash")); let opencode = saved_provider(AppType::OpenCode, "openmodel-opencode"); assert_eq!(opencode.settings_config["options"]["apiKey"], "om-test"); @@ -593,7 +596,7 @@ fn add_openmodel_template_is_scriptable_for_every_app() { "https://api.openmodel.ai/v1" ); assert!(opencode.settings_config["models"] - .get("claude-opus-5") + .get("claude-opus-5-5") .is_some()); let hermes = saved_provider(AppType::Hermes, "openmodel-hermes"); @@ -603,7 +606,7 @@ fn add_openmodel_template_is_scriptable_for_every_app() { "https://api.openmodel.ai" ); assert_eq!(hermes.settings_config["api_mode"], "anthropic_messages"); - assert_eq!(hermes.settings_config["models"][0]["id"], "claude-opus-5"); + assert_eq!(hermes.settings_config["models"][0]["id"], "claude-opus-5-5"); let openclaw = saved_provider(AppType::OpenClaw, "openmodel-openclaw"); assert_eq!(openclaw.settings_config["apiKey"], "om-test"); @@ -612,7 +615,10 @@ fn add_openmodel_template_is_scriptable_for_every_app() { "https://api.openmodel.ai" ); assert_eq!(openclaw.settings_config["api"], "anthropic-messages"); - assert_eq!(openclaw.settings_config["models"][0]["id"], "claude-opus-5"); + assert_eq!( + openclaw.settings_config["models"][0]["id"], + "claude-opus-5-5" + ); } #[test] @@ -653,7 +659,7 @@ fn add_patewayai_template_uses_upstream_claude_and_codex_settings() { let config = codex.settings_config["config"] .as_str() .expect("PatewayAI Codex config should be TOML"); - assert!(config.contains("model = \"gpt-5.6-sol\"")); + assert!(config.contains("model = \"gpt-6.1-sol\"")); assert!(config.contains("base_url = \"https://api.pateway.ai/v1\"")); assert!(config.contains("name = \"patewayai\"")); assert!(config.contains("wire_api = \"responses\"")); diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs index 0982b702..49062575 100644 --- a/src-tauri/tests/provider_commands.rs +++ b/src-tauri/tests/provider_commands.rs @@ -406,7 +406,7 @@ fn provider_usage_query_set_rejects_enabled_script_without_return() { #[test] #[serial] -fn provider_export_writes_merged_claude_settings_to_default_path() { +fn provider_export_does_not_replay_legacy_claude_snippets() { let _guard = lock_test_mutex(); reset_test_fs(); let home = ensure_test_home(); @@ -476,15 +476,15 @@ fn provider_export_writes_merged_claude_settings_to_default_path() { .get("env") .and_then(|v| v.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")) .and_then(|v| v.as_i64()), - Some(1), - "common config snippet should be merged into export" + None, + "legacy common snippets must not be replayed into exports" ); assert_eq!( exported .get("includeCoAuthoredBy") .and_then(|v| v.as_bool()), - Some(false), - "top-level common config should be present in export" + None, + "legacy global snippet values must not enter exports" ); assert_eq!( exported