diff --git a/core-version.txt b/core-version.txt index 41cda36c0..7c973d6f1 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -8.0.36 +8.0.38 diff --git a/docs/release-notes/v0.3.51/en.md b/docs/release-notes/v0.3.51/en.md new file mode 100644 index 000000000..3f4fa01bb --- /dev/null +++ b/docs/release-notes/v0.3.51/en.md @@ -0,0 +1,24 @@ +- If you have any questions or suggestions about the app, please contact us through our [QQ group](https://qm.qq.com/q/3queDaIG) or [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues). We will respond as soon as possible. + +## Improvements + +- Updated gateway configuration for newer Claude Desktop versions. CPA now explicitly uses static credentials and a specified model list to prevent legacy authentication, automatic configuration, or session service settings from overriding the CPA connection. +- Updating Claude Desktop configuration now correctly switches to the local CPA profile. Restoring a backup also restores the previous configuration selection. +- Added recognition of the Fable and Mythos model families in Claude Desktop configuration. + +## Fixes + +- Fixed incomplete Claude Desktop model alias handling for xAI, Meta, Vertex, and Interactions API key providers. API key models are no longer incorrectly mapped to OAuth channels, which could make models unavailable. +- Fixed aliases still in use being removed when saving configuration again. Valid existing routes are preserved when multiple providers offer the same model ID, preventing unintended provider changes. +- Fixed disabled or excluded API key models being incorrectly accepted through stale OAuth mappings. +- Improved Claude Desktop configuration checks to detect authentication and configuration source conflicts, as well as incomplete model lists. +- Improved credential protection and rollback handling when saving model mappings, preserving provider keys and connection settings. + +## Release Workflow + +- Restored automatic Miaomiao preview builds and publication. Non-draft PRs authored by Roomen and targeting `miaomiao` are automatically merged after all six platform builds pass. The merged commit is then built and the rolling preview is updated. + +## Usage Notes + +- After upgrading, save the Claude Desktop configuration again in CPA, then fully quit and reopen Claude Desktop. +- Model aliases must point to an available source model. If the source model is disabled or excluded, adjust its access configuration or select another available model. diff --git a/docs/release-notes/v0.3.51/ja.md b/docs/release-notes/v0.3.51/ja.md new file mode 100644 index 000000000..581ab88ae --- /dev/null +++ b/docs/release-notes/v0.3.51/ja.md @@ -0,0 +1,24 @@ +- アプリに関するご質問やご提案は、[QQ グループ](https://qm.qq.com/q/3queDaIG) または [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) までお寄せください。できる限り速やかに対応します。 + +## 改善 + +- 新しい Claude Desktop のゲートウェイ設定に対応しました。静的な認証情報と指定したモデル一覧を明示的に使用し、以前の認証方式、自動設定、セッションサービスの設定によって CPA 接続が上書きされることを防ぎます。 +- Claude Desktop の設定更新時に、CPA のローカル設定へ正しく切り替わるようになりました。バックアップの復元時には、以前の設定の選択状態も復元します。 +- Claude Desktop の設定で Fable、Mythos モデル系列を認識できるようになりました。 + +## 修正 + +- xAI、Meta、Vertex、Interactions の API キー接続における Claude Desktop のモデルエイリアス処理を修正しました。API キーのモデルが誤って OAuth チャネルに割り当てられ、利用できなくなる問題を防ぎます。 +- 設定を再保存すると、使用中のモデルエイリアスが削除される問題を修正しました。複数のプロバイダーに同名のモデルがある場合も、有効な既存のルーティングを維持し、意図しないプロバイダーの切り替えを防ぎます。 +- 無効化または除外された API キーのモデルが、残存する OAuth マッピングによって誤って受け入れられる問題を修正しました。 +- Claude Desktop の設定状態の確認を改善し、認証方式や設定元の競合、モデル一覧の不備を検出できるようにしました。 +- モデルマッピングの保存時における認証情報の保護と失敗時のロールバック処理を改善し、プロバイダーのキーと接続設定を保持します。 + +## リリースフロー + +- Miaomiao プレビュー版の自動ビルドと公開を復旧しました。Roomen が作成した `miaomiao` 向けのドラフトではない PR は、全 6 プラットフォームのビルド成功後に自動マージされます。その後、マージ済みのコミットをビルドし、ローリングプレビュー版を更新します。 + +## 使用上の注意 + +- アップグレード後は、CPA で Claude Desktop の設定を再保存し、Claude Desktop を完全に終了してから再起動してください。 +- モデルエイリアスには、利用可能な元のモデルが必要です。元のモデルが無効化または除外されている場合は、該当する接続設定を調整するか、別の利用可能なモデルを選択してください。 diff --git a/docs/release-notes/v0.3.51/zh-CN.md b/docs/release-notes/v0.3.51/zh-CN.md new file mode 100644 index 000000000..b537543b5 --- /dev/null +++ b/docs/release-notes/v0.3.51/zh-CN.md @@ -0,0 +1,25 @@ +- 关于软件,如果您有任何问题或建议,请通过 [QQ群](https://qm.qq.com/q/3queDaIG) 或 [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) 反馈,我们会尽快处理。 + +## 优化 + +- 适配新版 Claude Desktop 的网关配置,明确使用静态凭证和指定模型列表,避免旧的认证方式、自动配置或会话服务设置覆盖 CPA 连接。 +- 更新 Claude Desktop 配置时正确切换到 CPA 本地配置,并支持在恢复备份时还原原有配置选择。 +- 补充 Claude Desktop 对 Fable、Mythos 模型系列的识别。 + +## 修复 + +- 修复 xAI、Meta、Vertex 和 Interactions API Key 通道的 Claude Desktop 模型别名处理不完整的问题,避免将 API Key 模型错误映射到 OAuth 通道,导致模型不可用。 +- 修复重复保存配置时,仍在使用的模型别名被清理的问题。同名模型存在于多个提供方时,保留有效的原有路由,避免意外切换提供方。 +- 修复被禁用或排除的 API Key 模型可能通过残留 OAuth 映射被错误接受的问题。 +- 完善 Claude Desktop 配置状态检查,识别认证方式、配置来源冲突及模型列表不完整的情况。 +- 完善模型映射保存时的凭证保护与失败回滚处理,保留提供方的密钥和连接设置。 + +## 发布流程 + +- 恢复 Miaomiao 预览版自动构建与发布:由 Roomen 提交、目标为 `miaomiao` 的非草稿 PR,在六个平台构建通过后自动合并,并构建合并后的提交、更新滚动预览版。 + +## 使用说明 + +- 升级后,请在 CPA 中重新保存 Claude Desktop 配置,并完全退出、重新打开 Claude Desktop。 +- 模型别名需要对应可用的原始模型。若原始模型已被停用或排除,请调整对应接入配置,或选择其他可用模型。 + diff --git a/docs/release-notes/v0.3.51/zh-TW.md b/docs/release-notes/v0.3.51/zh-TW.md new file mode 100644 index 000000000..547850e5a --- /dev/null +++ b/docs/release-notes/v0.3.51/zh-TW.md @@ -0,0 +1,24 @@ +- 關於軟體,如果您有任何問題或建議,請透過 [QQ群組](https://qm.qq.com/q/3queDaIG) 或 [GitHub Issues](https://github.com/router-for-me/EasyCLIProxyAPI/issues) 回饋,我們會盡快處理。 + +## 優化 + +- 適配新版 Claude Desktop 的閘道設定,明確使用靜態憑證與指定模型清單,避免舊的驗證方式、自動設定或工作階段服務設定覆寫 CPA 連線。 +- 更新 Claude Desktop 設定時正確切換至 CPA 本機設定,並支援在還原備份時恢復原有的設定選擇。 +- 補充 Claude Desktop 對 Fable、Mythos 模型系列的識別。 + +## 修復 + +- 修復 xAI、Meta、Vertex 和 Interactions API Key 通道的 Claude Desktop 模型別名處理不完整的問題,避免將 API Key 模型錯誤對應至 OAuth 通道,導致模型無法使用。 +- 修復重複儲存設定時,仍在使用的模型別名被清除的問題。同名模型存在於多個提供者時,保留有效的原有路由,避免意外切換提供者。 +- 修復被停用或排除的 API Key 模型可能透過殘留 OAuth 對應被錯誤接受的問題。 +- 完善 Claude Desktop 設定狀態檢查,識別驗證方式、設定來源衝突及模型清單不完整的情況。 +- 完善模型對應儲存時的憑證保護與失敗回復處理,保留提供者的金鑰與連線設定。 + +## 發布流程 + +- 恢復 Miaomiao 預覽版自動建置與發布:由 Roomen 提交、目標為 `miaomiao` 的非草稿 PR,在六個平台建置通過後自動合併,並建置合併後的提交、更新滾動預覽版。 + +## 使用說明 + +- 升級後,請在 CPA 中重新儲存 Claude Desktop 設定,並完全結束、重新開啟 Claude Desktop。 +- 模型別名需要對應可用的原始模型。若原始模型已被停用或排除,請調整對應連線設定,或選擇其他可用模型。 diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 2237b2e38..eddb4fc2c 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -620,6 +620,43 @@ fn claude_desktop_cowork_hosts_survive_update_close_and_reconnect() { ); } +#[test] +fn claude_desktop_update_exits_hybrid_and_backup_restores_pointer() { + if !AgentClient::ClaudeDesktop.supported_platform() { return; } + let home = Home::new(); + let paths = config_paths("claude-desktop", &home.0).unwrap(); + let pointer = serde_json::json!({"bootstrapUrl":"https://config.example.test"}); + let meta = serde_json::json!({"appliedId":"other", "hybridPointer":pointer, + "entries":[{"id":"other","name":"Original"}], "custom":true}); + for path in &paths[..2] { save(path, r#"{"deploymentMode":"3p"}"#); } + save(&paths[3], meta.to_string()); + let original = config_images(&paths).unwrap(); + apply(&home.0, AgentClient::ClaudeDesktop, "gpt-one").unwrap(); + let connected: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap(); + assert!(connected.get("hybridPointer").is_none()); + assert_eq!(connected["appliedId"], CLAUDE_DESKTOP_PROFILE_ID); + assert_eq!(connected["custom"], true); + let restored = prepare_recorded_integration_restore(AgentClient::ClaudeDesktop, + &paths, &config_images(&paths).unwrap()).unwrap().unwrap(); + for ((path, restored), (_, original)) in restored.iter().zip(&original) { + assert_eq!(restored.is_some(), original.is_some()); + assert_eq!(parse(path, text(restored.as_deref()).unwrap()).unwrap(), + parse(path, text(original.as_deref()).unwrap()).unwrap()); + } + + // A user who switches back to a hybrid source must not lose 3P mode + // when subsequently removing the dormant CPA integration. + let mut hybrid = connected; + hybrid["hybridPointer"] = pointer.clone(); + save(&paths[3], hybrid.to_string()); + assert!(!inspect_claude_desktop_agent_config(&paths, 8317, "test-secret").unwrap().0); + clear_agent_managed_configuration(AgentClient::ClaudeDesktop, &home.0, 8317).unwrap(); + let meta: Value = serde_json::from_slice(&fs::read(&paths[3]).unwrap()).unwrap(); + assert_eq!(meta["hybridPointer"], pointer); + let settings: Value = serde_json::from_slice(&fs::read(&paths[1]).unwrap()).unwrap(); + assert_eq!(settings["deploymentMode"], "3p"); +} + #[test] fn clear_integration_preserves_claude_settings_for_other_endpoints() { let home = Home::new(); diff --git a/src-tauri/src/agents/configuration.rs b/src-tauri/src/agents/configuration.rs index 353471134..7e080731b 100644 --- a/src-tauri/src/agents/configuration.rs +++ b/src-tauri/src/agents/configuration.rs @@ -876,6 +876,21 @@ pub(crate) fn build_claude_desktop_deployment_config( render_agent_json(root, "Claude Desktop configuration") } +// Keep generation, removal and selective restoration in agreement. These keys +// belong to CPA's gateway profile; all other preferences remain user-owned. +pub(crate) const CLAUDE_DESKTOP_PROFILE_KEYS: &[&str] = &[ + "disableDeploymentModeChooser", + "inferenceGatewayApiKey", + "inferenceGatewayAuthScheme", + "inferenceGatewayBaseUrl", + "inferenceProvider", + "inferenceCredentialKind", + "inferenceModels", + "modelDiscoveryEnabled", + "bootstrapEnabled", + "selfHostedUrl", +]; + pub(crate) fn build_claude_desktop_profile( existing: Option<&str>, base_url: &str, @@ -885,6 +900,15 @@ pub(crate) fn build_claude_desktop_profile( mappings: Option<&ClaudeDesktopModelMappings>, ) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop gateway configuration")?; + // An explicit credential source prevents an old helper/SSO setting from + // taking precedence over the local proxy key. The full model IDs below + // are authoritative, so discovery must not resolve bare tier aliases. + root.insert("inferenceCredentialKind".into(), serde_json::json!("static")); + root.insert("modelDiscoveryEnabled".into(), serde_json::json!(false)); + // Preserve bootstrap details for restoration, but don't fetch an overlay + // which could replace this gateway. A session host also overrides inference. + root.insert("bootstrapEnabled".into(), serde_json::json!(false)); + root.remove("selfHostedUrl"); root.insert( "disableDeploymentModeChooser".to_string(), serde_json::json!(true), @@ -1023,6 +1047,10 @@ pub(crate) fn claude_desktop_family_tier(route_model: &str) -> Option<&'static s || route_model.eq_ignore_ascii_case(LEGACY_CLAUDE_DESKTOP_MODEL_IDS[2]) { Some("haiku") + } else if lowered.starts_with("claude-fable-") || lowered.contains("-fable-") { + Some("fable") + } else if lowered.starts_with("claude-mythos-") || lowered.contains("-mythos-") { + Some("mythos") } else { None } @@ -1030,6 +1058,9 @@ pub(crate) fn claude_desktop_family_tier(route_model: &str) -> Option<&'static s pub(crate) fn build_claude_desktop_meta(existing: Option<&str>) -> Result { let mut root = parse_agent_json_object(existing, "Claude Desktop configuration index")?; + // Desktop resolves a hybrid pointer before appliedId. Match its own + // "apply locally" transition; transaction snapshots retain the old pointer. + root.remove("hybridPointer"); repair_claude_desktop_meta_names(&mut root); let entries = ensure_json_array_entry(&mut root, "entries"); let mut managed_entry = None; @@ -1155,8 +1186,9 @@ pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Resul } let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?; let applied_id = meta.get("appliedId").and_then(serde_json::Value::as_str); - let managed_active = applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID) - || (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?); + let managed_active = !claude_desktop_has_hybrid_pointer(&meta) + && (applied_id == Some(CLAUDE_DESKTOP_PROFILE_ID) + || (applied_id.is_none() && agent_has_managed_marker(AgentClient::ClaudeDesktop, paths)?)); let mut changed = Vec::new(); for (path, label) in [ (&paths[0], "Claude Desktop main configuration"), @@ -1182,15 +1214,8 @@ pub(crate) fn prepare_claude_desktop_managed_removal(paths: &[PathBuf]) -> Resul if let Some(update) = prepare_agent_json_removal(&paths[2], "Claude Desktop gateway configuration", |root| { let mut updated = false; - for key in [ - "disableDeploymentModeChooser", - "inferenceGatewayApiKey", - "inferenceGatewayAuthScheme", - "inferenceGatewayBaseUrl", - "inferenceProvider", - "inferenceModels", - ] { - updated |= root.remove(key).is_some(); + for key in CLAUDE_DESKTOP_PROFILE_KEYS { + updated |= root.remove(*key).is_some(); } updated })? @@ -2018,19 +2043,13 @@ pub(crate) fn build_restored_claude_desktop_config( match index { 0 | 1 => restore_json_key(&mut root, original_root.as_ref(), "deploymentMode"), 2 => { - for key in [ - "disableDeploymentModeChooser", - "inferenceGatewayApiKey", - "inferenceGatewayAuthScheme", - "inferenceGatewayBaseUrl", - "inferenceProvider", - "inferenceModels", - ] { + for key in CLAUDE_DESKTOP_PROFILE_KEYS { restore_json_key(&mut root, original_root.as_ref(), key); } } 3 => { restore_json_key(&mut root, original_root.as_ref(), "appliedId"); + restore_json_key(&mut root, original_root.as_ref(), "hybridPointer"); let mut entries = root .remove("entries") .and_then(|value| value.as_array().cloned()) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index e62a1d2be..a212fbcc0 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -3393,6 +3393,31 @@ pub(crate) fn claude_code_model_presentation_environment( .collect()) } +pub(crate) fn claude_desktop_has_hybrid_pointer(meta: &serde_json::Value) -> bool { + meta.get("hybridPointer") + .and_then(|pointer| pointer.get("bootstrapUrl")) + .and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) +} + +pub(crate) fn claude_desktop_profile_uses_local_gateway(profile: &serde_json::Value) -> bool { + profile.get("inferenceProvider").and_then(serde_json::Value::as_str) == Some("gateway") + && profile.get("inferenceCredentialKind") + .is_none_or(|kind| kind.as_str() == Some("static")) + && profile.get("inferenceGatewayAuthScheme") + .is_none_or(|scheme| scheme.as_str() == Some("bearer")) + && !profile.get("selfHostedUrl").and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) + && (!profile.get("bootstrapUrl").and_then(serde_json::Value::as_str) + .is_some_and(|url| !url.trim().is_empty()) + || profile.get("bootstrapEnabled").and_then(serde_json::Value::as_bool) == Some(false)) + && profile.get("inferenceModels").and_then(serde_json::Value::as_array) + .is_some_and(|models| !models.is_empty() && models.iter().all(|model| { + model.as_str().or_else(|| model.get("name").and_then(serde_json::Value::as_str)) + .is_some_and(|name| !name.trim().is_empty()) + })) +} + pub(crate) fn inspect_claude_desktop_agent_config( paths: &[PathBuf], port: u16, @@ -3406,7 +3431,9 @@ pub(crate) fn inspect_claude_desktop_agent_config( let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop gateway configuration")?; let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop configuration index")?; let expected_base = managed_core_loopback_origin(port); - let configured = normal + let configured = claude_desktop_profile_uses_local_gateway(&profile) + && !claude_desktop_has_hybrid_pointer(&meta) + && normal .get("deploymentMode") .and_then(serde_json::Value::as_str) == Some("3p") diff --git a/src-tauri/src/agents/transactions.rs b/src-tauri/src/agents/transactions.rs index e1442196d..1266bad27 100644 --- a/src-tauri/src/agents/transactions.rs +++ b/src-tauri/src/agents/transactions.rs @@ -1165,6 +1165,7 @@ fn restore_text( let mut root = parse(path, Some(current))?; let target = parse(path, target)?; set(&mut root, &["appliedId".into()], target.get("appliedId")); + set(&mut root, &["hybridPointer".into()], target.get("hybridPointer")); let mut entries = root .get("entries") .and_then(Value::as_array) diff --git a/src-tauri/src/core_config/alias_save.rs b/src-tauri/src/core_config/alias_save.rs index 863e1a836..b13f74bac 100644 --- a/src-tauri/src/core_config/alias_save.rs +++ b/src-tauri/src/core_config/alias_save.rs @@ -46,11 +46,7 @@ fn validate_alias_api_access_preserved(current: &str, updated: &str) -> Result<( }; let current = parse(current)?; let updated = parse(updated)?; - for section in MODEL_ALIAS_CONFIG_SECTIONS - .iter() - .copied() - .chain(["vertex-api-key", "xai-api-key", "interactions-api-key"]) - { + for (section, _) in V8_PROVIDER_FAMILIES { let without_models = |root: &serde_norway::Value| -> Result<_, String> { let mut value = root .get(section) @@ -62,11 +58,9 @@ fn validate_alias_api_access_preserved(current: &str, updated: &str) -> Result<( let providers = value .as_sequence_mut() .ok_or_else(|| format!("{section} must be an array; alias was not saved"))?; - if MODEL_ALIAS_CONFIG_SECTIONS.contains(§ion) { - for provider in providers { - if let Some(provider) = provider.as_mapping_mut() { - provider.remove(yaml_key("models")); - } + for provider in providers { + if let Some(provider) = provider.as_mapping_mut() { + provider.remove(yaml_key("models")); } } Ok(value) diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 3d267831b..3f43146e2 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -830,7 +830,8 @@ pub(crate) fn ensure_claude_desktop_model_aliases_with_oauth_definitions_and_rou } } for alias in managed_aliases { - if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias)) { + if !entries.iter().any(|entry| entry.source_or_alias().eq_ignore_ascii_case(&alias) + || entry.model_id().eq_ignore_ascii_case(&alias)) { remove_managed_claude_model_alias(root, &alias)?; } } @@ -955,7 +956,7 @@ fn claude_desktop_has_other_model_family(name: &str) -> bool { fn claude_desktop_configured_models(root: &serde_norway::Mapping) -> Vec<&serde_norway::Value> { let mut models = Vec::new(); - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { for provider in yaml_mapping_value(root, section) .and_then(serde_norway::Value::as_sequence) .into_iter() @@ -1016,7 +1017,7 @@ pub(crate) fn remove_managed_claude_model_alias( alias: &str, ) -> Result { let mut changed = false; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value_mut(root, section) else { continue; }; @@ -1047,7 +1048,7 @@ pub(crate) fn remove_existing_claude_model_alias( alias: &str, ) -> Result { let mut changed = false; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value_mut(root, section) else { continue; }; @@ -1218,7 +1219,7 @@ pub(crate) fn is_managed_claude_model_alias(model: &serde_norway::Value, alias: .is_some_and(|value| value == expected_display_name) } -pub(crate) fn configured_managed_claude_alias_matches( +fn configured_managed_claude_provider_alias_matches( root: &serde_norway::Mapping, alias: &str, source_model: &str, @@ -1228,26 +1229,19 @@ pub(crate) fn configured_managed_claude_alias_matches( && configured_model_identity(model) .is_some_and(|(source, _, _)| source.eq_ignore_ascii_case(source_model)) }; - let configured_provider_alias = MODEL_ALIAS_CONFIG_SECTIONS.iter().any(|section| { + V8_PROVIDER_FAMILIES.iter().any(|(section, _)| { yaml_mapping_value(root, section) .and_then(serde_norway::Value::as_sequence) .into_iter() .flatten() .filter_map(serde_norway::Value::as_mapping) - .filter(|provider| configured_provider_model_is_enabled(provider, alias)) + .filter(|provider| configured_provider_model_is_enabled(provider, alias) + && configured_provider_model_is_enabled(provider, source_model)) .filter_map(|provider| yaml_mapping_value(provider, "models")) .filter_map(serde_norway::Value::as_sequence) .flatten() .any(matches) - }); - configured_provider_alias - || yaml_mapping_value(root, "oauth-model-alias") - .and_then(serde_norway::Value::as_mapping) - .into_iter() - .flat_map(|channels| channels.values()) - .filter_map(serde_norway::Value::as_sequence) - .flatten() - .any(matches) + }) } pub(crate) fn ensure_claude_desktop_model_alias( @@ -1257,10 +1251,14 @@ pub(crate) fn ensure_claude_desktop_model_alias( alias: &str, oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result<(), String> { - if configured_managed_claude_alias_matches(root, alias, source_model) { + // Keep a valid existing API-key route even when another provider publishes + // the same model. Provider ordering is not a user choice of credentials. + if configured_managed_claude_provider_alias_matches(root, alias, source_model) { return Ok(()); } - let source = resolve_claude_desktop_alias_source(sources, source_model, alias)?; + let source = resolve_claude_desktop_alias_source( + sources, source_model, alias, oauth_model_definitions, + )?; if let Some(source) = source { remove_existing_claude_model_alias(root, alias)?; append_claude_desktop_model_alias(root, source, alias)?; @@ -1291,7 +1289,7 @@ pub(crate) fn configured_model_client_identity( root: &serde_norway::Mapping, client_model: &str, ) -> Option<(String, String)> { - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value(root, section).and_then(serde_norway::Value::as_sequence) else { @@ -1393,6 +1391,7 @@ fn resolve_claude_desktop_alias_source( root: &serde_norway::Mapping, source_model: &str, alias: &str, + oauth_model_definitions: &[OAuthModelDefinitions], ) -> Result, String> { for upstream in [false, true] { let matching_model = |model: &serde_norway::Value| { @@ -1405,7 +1404,7 @@ fn resolve_claude_desktop_alias_source( model.insert(yaml_key("name"), serde_norway::Value::String(name)); Some(model) }; - for section in MODEL_ALIAS_CONFIG_SECTIONS { + for (section, _) in V8_PROVIDER_FAMILIES { let Some(providers) = yaml_mapping_value(root, section) else { continue; }; @@ -1426,8 +1425,9 @@ fn resolve_claude_desktop_alias_source( .as_sequence() .ok_or_else(|| format!("{section}.models must be an array"))?; if let Some(model) = models.iter().find_map(|model| { - let (_, client_model, _) = configured_model_identity(model)?; - if !configured_provider_model_is_enabled(provider, &client_model) { + let (upstream_model, client_model, _) = configured_model_identity(model)?; + if !configured_provider_model_is_enabled(provider, &client_model) + || !configured_provider_model_is_enabled(provider, &upstream_model) { return None; } matching_model(model) @@ -1447,7 +1447,33 @@ fn resolve_claude_desktop_alias_source( let (Some(channel), Some(models)) = (channel.as_str(), models.as_sequence()) else { continue; }; - if let Some(model) = models.iter().find_map(matching_model) { + if let Some(model) = models.iter().find_map(|model| { + let resolved = matching_model(model)?; + let (upstream, client, _) = configured_model_identity(model)?; + // A managed OAuth mapping is not evidence of an OAuth source + // when this model belongs to an API-key provider. In particular, + // do not bypass that provider's exclusions via a stale mapping. + let api_source_exists = V8_PROVIDER_FAMILIES.iter().any(|(section, _)| { + yaml_mapping_value(root, section) + .and_then(serde_norway::Value::as_sequence) + .into_iter().flatten() + .filter_map(|provider| provider.get("models")) + .filter_map(serde_norway::Value::as_sequence) + .flatten().filter_map(configured_model_identity) + .any(|(name, id, _)| name.eq_ignore_ascii_case(&upstream) + || id.eq_ignore_ascii_case(source_model)) + }); + let confirmed_oauth_source = oauth_model_definitions.iter().any(|definition| { + definition.channel.key == channel && definition.models.iter() + .any(|entry| entry.id.eq_ignore_ascii_case(&upstream)) + }); + if is_managed_claude_model_alias(model, &client) + && api_source_exists && !confirmed_oauth_source + { + return None; + } + Some(resolved) + }) { return Ok(Some(ClaudeDesktopAliasSource::OAuth { channel: channel.to_string(), model, diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 245de4e28..f8eafed91 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -735,6 +735,9 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { serde_json::json!(["*"]) ); assert_eq!(profile["inferenceGatewayApiKey"], DEFAULT_API_KEY); + assert_eq!(profile["inferenceCredentialKind"], "static"); + assert_eq!(profile["modelDiscoveryEnabled"], false); + assert_eq!(profile["bootstrapEnabled"], false); assert_eq!(profile["inferenceGatewayBaseUrl"], "http://127.0.0.1:8317"); assert_eq!( profile["inferenceModels"], @@ -757,6 +760,63 @@ fn claude_desktop_config_builds_gateway_profile_and_index() { assert_eq!(managed_entry["custom"], true); } +#[test] +fn claude_desktop_gateway_update_and_restore_handle_competing_sources() { + let original = serde_json::json!({ + "inferenceProvider": "gateway", "inferenceCredentialKind": "helper-script", + "inferenceCredentialHelper": "/user/helper", "inferenceGatewayAuthScheme": "sso", + "bootstrapUrl": "https://config.example.test/settings", "bootstrapEnabled": true, + "selfHostedUrl": "https://sessions.example.test", "modelDiscoveryEnabled": true, + "defaultModelEffort": "high", "coworkEgressAllowedHosts": ["example.test"] + }).to_string(); + let profile = build_claude_desktop_profile(Some(&original), "http://127.0.0.1:8317", + "test-key", "claude-sonnet-5", &[], None).unwrap(); + let value: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert!(claude_desktop_profile_uses_local_gateway(&value)); + assert!(value.get("selfHostedUrl").is_none()); + assert_eq!(value["inferenceCredentialHelper"], "/user/helper"); + assert_eq!(value["defaultModelEffort"], "high"); + let restored = build_restored_claude_desktop_config(2, &profile, Some(&original)).unwrap().unwrap(); + assert_eq!(serde_json::from_str::(&restored).unwrap(), + serde_json::from_str::(&original).unwrap()); +} + +#[test] +fn claude_desktop_status_rejects_overridden_and_incomplete_profiles() { + let profile: serde_json::Value = serde_json::from_str(&build_claude_desktop_profile( + None, "http://127.0.0.1:8317", "key", "claude-sonnet-5", &[], None).unwrap()).unwrap(); + for (key, value) in [ + ("inferenceProvider", serde_json::json!("anthropic")), + ("inferenceCredentialKind", serde_json::json!("interactive")), + ("inferenceGatewayAuthScheme", serde_json::json!("sso")), + ("selfHostedUrl", serde_json::json!("https://host.example.test")), + ("inferenceModels", serde_json::json!([])), + ("inferenceModels", serde_json::json!([{"labelOverride":"only a label"}])), + ] { + let mut changed = profile.clone(); + changed[key] = value; + assert!(!claude_desktop_profile_uses_local_gateway(&changed), "{key}"); + } + let mut bootstrap = profile.clone(); + bootstrap["bootstrapUrl"] = serde_json::json!("https://config.example.test"); + assert!(claude_desktop_profile_uses_local_gateway(&bootstrap)); + bootstrap["bootstrapEnabled"] = serde_json::json!(true); + assert!(!claude_desktop_profile_uses_local_gateway(&bootstrap)); + let mut legacy = profile; + legacy.as_object_mut().unwrap().remove("inferenceCredentialKind"); + assert!(claude_desktop_profile_uses_local_gateway(&legacy)); +} + +#[test] +fn claude_desktop_current_family_tiers_include_fable_and_mythos() { + for (id, tier) in [("claude-fable-5-1", "fable"), ("claude-mythos-5", "mythos"), + ("claude-sonnet-5-cc", "sonnet"), ("claude-opus-5", "opus"), ("claude-haiku-4-5", "haiku")] { + assert_eq!(claude_desktop_family_tier(id), Some(tier)); + let model = claude_desktop_inference_model(id, "source-model", false, &[]); + assert_eq!(model["anthropicFamilyTier"], tier); + } +} + #[test] fn claude_desktop_profile_keeps_non_claude_models_internal() { let mappings = ClaudeDesktopModelMappings { diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index a179beb50..19db117fa 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -252,7 +252,11 @@ impl MockCore { let request_body = &bytes[header_end..header_end + content_length]; let mut status = "200 OK"; let mut response = "{\"status\":\"ok\"}".to_string(); - if method == "GET" { + if method == "GET" && matches!(path, "/v1/models" | "/models") { + // Public discovery is not a complete routing contract. + // Keep this empty even after a successful alias write. + response = r#"{"data":[]}"#.to_string(); + } else if method == "GET" { assert_eq!(path, "/v8/management/config.yaml"); response = if v8_response { v8_yaml_from_legacy_json(&persisted) @@ -410,6 +414,28 @@ fn v8_yaml_from_legacy_json(legacy: &serde_json::Value) -> String { serde_norway::to_string(&serde_json::Value::Object(root)).unwrap() } +#[tokio::test(flavor = "current_thread")] +async fn desktop_save_does_not_require_routes_to_appear_in_public_discovery() { + let initial = "codex-api-key:\n - api-key: test-upstream\n models: [{name: model-a}]\n"; + let core = MockCore::new(initial, Failure::None); + assert!(fetch_agent_models(core.config.port, "test-client").await.unwrap().is_empty()); + let models = super::support::test_agent_models(&["model-a"]); + let mappings = ClaudeDesktopModelMappings::all("model-a"); + let profile = commit_agent_with_core(&core.config, Some(mappings), &models, |mappings| { + build_claude_desktop_profile(None, "http://127.0.0.1:8317", "test-client", + "model-a", &models, mappings) + }).await.unwrap(); + let profile: serde_json::Value = serde_json::from_str(&profile).unwrap(); + assert!(!profile["inferenceModels"].as_array().unwrap().is_empty()); + assert!(fetch_agent_models(core.config.port, "test-client").await.unwrap().is_empty()); + let (persisted, requests) = core.finish(); + assert!(persisted["codex-api-key"][0]["models"].as_array().unwrap().iter() + .any(|model| model["alias"] == CLAUDE_DESKTOP_SONNET_MODEL_ID)); + // Only the two explicit reads above; saving must not poll discovery or infer. + assert_eq!(requests.iter().filter(|request| *request == "GET /v1/models").count(), 2); + assert!(!requests.iter().any(|request| request.contains("/v1/messages"))); +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_uses_only_targeted_writes_with_a_real_v8_config_view() { let core = MockCore::new_v8(CURRENT, Failure::None); @@ -1041,3 +1067,30 @@ async fn alias_transaction_checks_core_before_a_local_only_commit() { assert!(committed.load(Ordering::SeqCst)); assert_eq!(core.finish().0, yaml_json(¤t)); } + +#[tokio::test(flavor = "current_thread")] +async fn desktop_additional_api_channels_save_and_rollback_without_changing_credentials() { + for section in ["xai-api-key", "meta-api-key", "vertex-api-key", "interactions-api-key"] { + for fail in [false, true] { + let initial = format!("{section}:\n - api-key: preserved-secret\n base-url: https://provider.test\n models: [{{name: model-a}}]\n"); + let core = MockCore::new(&initial, Failure::None); + let result = commit_agent_with_core( + &core.config, + Some(ClaudeDesktopModelMappings::all("model-a")), + &super::support::test_agent_models(&["model-a"]), + |_| if fail { Err("local write rejected".into()) } else { Ok(()) }, + ).await; + let (persisted, _) = core.finish(); + if fail { + assert!(result.is_err()); + assert_eq!(persisted, yaml_json(&initial)); + } else { + result.unwrap(); + assert_eq!(persisted[section][0]["api-key"], "preserved-secret"); + assert_eq!(persisted[section][0]["base-url"], "https://provider.test"); + assert!(persisted[section][0]["models"].as_array().unwrap().iter() + .any(|entry| entry["alias"] == CLAUDE_DESKTOP_SONNET_MODEL_ID)); + } + } + } +} diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs index 54749e4f8..c36afa8b7 100644 --- a/src-tauri/src/tests/core_v8_contract.rs +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -11,6 +11,31 @@ struct TestCore { client: reqwest::Client, } +#[tokio::test] +#[ignore = "requires an explicitly prepared isolated CPA_DESKTOP_E2E_CONFIG; never use a production config"] +async fn desktop_e2e_prepare_isolated_gateway_profile() { + let path = PathBuf::from(std::env::var_os("CPA_DESKTOP_E2E_CONFIG").expect("set isolated config path")); + let native: serde_json::Value = serde_norway::from_str(&fs::read_to_string(&path).unwrap()).unwrap(); + let port = native["server"]["port"].as_u64().unwrap() as u16; + assert_ne!(port, 8317, "Never modify the production kernel"); + assert_eq!(native["server"]["host"], "127.0.0.1"); + let key = native["access"]["api-keys"][0].as_str().unwrap(); + let gui = GuiConfigFile { port, + management_secret_key: "claude-e2e-local-management".into(), + api_keys: vec![GuiApiKeyEntry {key:key.into(),remark:String::new()}], + ..GuiConfigFile::default() }; + let models = fetch_agent_models(port, key).await.unwrap(); + let mappings = ClaudeDesktopModelMappings { + desktop_models: Some(vec![ClaudeDesktopModelMapping { + model:"grok-4.7-build-fast".into(), alias:"claude-sonnet-5-cc".into(), context_1m:true, + }]), ..ClaudeDesktopModelMappings::all("grok-4.7-build-fast") }; + let profile = commit_agent_with_core(&gui, Some(mappings), &models, |mappings| { + build_claude_desktop_profile(None, &managed_core_loopback_origin(port), key, + "grok-4.7-build-fast", &models, mappings) + }).await.unwrap(); + fs::write(path.parent().unwrap().join("desktop-profile.json"), profile).unwrap(); +} + impl Drop for TestCore { fn drop(&mut self) { self.stop(); diff --git a/src-tauri/src/tests/desktop_alias_routing.rs b/src-tauri/src/tests/desktop_alias_routing.rs index 67c774a43..b71f846fd 100644 --- a/src-tauri/src/tests/desktop_alias_routing.rs +++ b/src-tauri/src/tests/desktop_alias_routing.rs @@ -1,6 +1,21 @@ use super::support::*; use super::*; +#[test] +fn desktop_repairs_oauth_alias_on_api_key_provider_channels() { + for section in ["xai-api-key", "meta-api-key", "vertex-api-key", "interactions-api-key"] { + let initial = format!("{section}:\n - api-key: test-key\n models: [{{name: source-model}}]\noauth-model-alias:\n xai:\n - name: source-model\n alias: claude-sonnet-5-cc\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"); + let mappings = custom_mappings(&[("source-model", "claude-sonnet-5-cc")]); + let rendered = ensure_claude_desktop_model_aliases_in_yaml(&initial, &mappings, &[]).unwrap(); + let parsed = json(&rendered); + assert!(parsed[section][0]["models"].as_array().unwrap().iter() + .any(|model| model["alias"] == "claude-sonnet-5-cc")); + assert!(!parsed["oauth-model-alias"]["xai"].as_array() + .is_some_and(|entries| entries.iter().any(|model| model["alias"] == "claude-sonnet-5-cc"))); + assert_eq!(ensure_claude_desktop_model_aliases_in_yaml(&rendered, &mappings, &[]).unwrap(), rendered); + } +} + fn json(content: &str) -> serde_json::Value { serde_json::to_value(serde_norway::from_str::(content).unwrap()).unwrap() } @@ -599,3 +614,45 @@ fn legacy_desktop_upstream_fallback_does_not_shadow_an_exact_oauth_alias() { .iter() .all(|entry| entry["name"] == "actual-upstream")); } + +#[test] +fn excluded_api_source_must_not_accept_stale_managed_oauth_route() { + let input = "xai-api-key:\n - api-key: key\n excluded-models: [grok-4.6]\n models: [{name: grok-4.6}]\noauth-model-alias:\n xai:\n - name: grok-4.6\n alias: claude-opus-4-5\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + let result = ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("grok-4.6", "claude-opus-4-5")]), &[]); + assert!(result.is_err(), "Excluded source was accepted as usable: {result:?}"); +} + +#[test] +fn valid_existing_provider_route_should_not_move_to_other_provider() { + let input = "gemini-api-key:\n - api-key: gemini-key\n models: [{name: shared-model}]\ncodex-api-key:\n - api-key: codex-key\n models:\n - name: shared-model\n - name: shared-model\n alias: claude-sonnet-5-cc\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + let result = ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("shared-model", "claude-sonnet-5-cc")]), &[]).unwrap(); + assert_eq!(json(&result), json(input), "A valid existing route changed provider during an unchanged save"); +} + +#[test] +fn desktop_stale_oauth_route_respects_api_exclusions_but_allows_confirmed_oauth() { + for restriction in ["disabled: true", "excluded-models: ['grok-*']"] { + let input = format!("xai-api-key:\n - {restriction}\n models: [{{name: grok-4.6}}]\noauth-model-alias:\n xai:\n - name: grok-4.6\n alias: claude-opus-4-5\n fork: true\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"); + let mappings = custom_mappings(&[("grok-4.6", "claude-opus-4-5")]); + assert!(ensure_claude_desktop_model_aliases_in_yaml(&input, &mappings, &[]).is_err()); + let definitions = [OAuthModelDefinitions { + channel: oauth_alias_channel("xai").unwrap(), + models: vec![CodexModelDefinition { + id: "grok-4.6".into(), display_name: None, description: None, + context_window: None, reasoning_levels: vec![], supports_tools: None, + }], + }]; + let updated = ensure_claude_desktop_model_aliases_with_oauth_definitions_in_yaml( + &input, &mappings, &[], &definitions).unwrap(); + assert_eq!(json(&updated), json(&input)); + } +} + +#[test] +fn desktop_excluded_upstream_cannot_reuse_its_existing_provider_alias() { + let input = "xai-api-key:\n - excluded-models: [grok-4.6]\n models:\n - name: grok-4.6\n - name: grok-4.6\n alias: claude-opus-4-5\n display-name: EasyCLIProxyAPI managed Claude Desktop mapping\n"; + assert!(ensure_claude_desktop_model_aliases_in_yaml( + input, &custom_mappings(&[("grok-4.6", "claude-opus-4-5")]), &[]).is_err()); +}