From 3f4739365e47ffba98fb3c04e95864cfbd3c5c39 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 20 Apr 2026 10:34:02 +0800 Subject: [PATCH] feat(hermes): surface providers: dict entries read-only Hermes v12+ migrated some provider entries from the `custom_providers:` list into a `providers:` dict (keyed by id). CC Switch previously ignored that source entirely, leaving users blind to providers they had configured via Hermes' own Web UI; the only feedback was a generic migration warning in the health banner. `get_providers()` now unions both sources, matching upstream `get_compatible_custom_providers` dedup order (list wins on name collision). Entries coming from the dict carry a `_cc_source = "providers_dict"` marker plus the original `provider_key`, which the UI layer will use to render them read-only. `set_provider` and `remove_provider` now refuse to touch dict-only entries, steering the user to Hermes Web UI. `sanitize_hermes_provider_keys` strips the UI markers on write so they never reach YAML. The `schema_migrated_v12` health warning copy reframes the situation: entries are shown read-only in CC Switch rather than invisible. --- src-tauri/src/hermes_config.rs | 279 ++++++++++++++++++- src/components/hermes/HermesHealthBanner.tsx | 2 +- src/i18n/locales/en.json | 2 +- src/i18n/locales/ja.json | 2 +- src/i18n/locales/zh.json | 2 +- 5 files changed, 270 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index a7ab7cd8a..a851167d5 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -432,8 +432,9 @@ fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) { ("contextLength", "context_length"), ]; // Legacy DeepLink emitted `api: "openai-completions"` which is neither a - // Hermes field nor mappable to `api_mode`. - const LEGACY_FIELDS_TO_DROP: &[&str] = &["api"]; + // Hermes field nor mappable to `api_mode`. `_cc_source` / `provider_key` + // are UI-only markers injected on read — they must never reach YAML. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api", PROVIDER_SOURCE_FIELD, "provider_key"]; let Some(obj) = config.as_object_mut() else { return; @@ -507,18 +508,92 @@ fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarnin } } -/// Get all custom providers as a JSON map keyed by provider name. +/// Marker field injected on provider payloads sourced from Hermes v12+ +/// `providers:` dict. CC Switch treats those as read-only — writes have to +/// go through Hermes' own Web UI to keep its overlay semantics intact. +pub const PROVIDER_SOURCE_FIELD: &str = "_cc_source"; +pub const PROVIDER_SOURCE_CUSTOM_LIST: &str = "custom_providers"; +pub const PROVIDER_SOURCE_DICT: &str = "providers_dict"; + +/// Normalize a single entry from the v12+ `providers:` dict into the same +/// JSON shape that `custom_providers:` list entries take, mirroring upstream +/// `_normalize_custom_provider_entry` (hermes_cli/config.py). /// -/// Reads the `custom_providers:` YAML sequence where each item has a `name` -/// field, and converts it to a map for CC Switch consumption. Each entry's -/// `models` field is converted from the YAML dict shape back to the -/// UI-friendly ordered array shape. +/// Returns `None` when the entry is not a mapping or lacks any usable name. +fn normalize_providers_dict_entry( + key: &str, + entry: &serde_yaml::Value, +) -> Result, AppError> { + if !entry.is_mapping() { + return Ok(None); + } + let mut json_val = yaml_to_json(entry)?; + let Some(obj) = json_val.as_object_mut() else { + return Ok(None); + }; + // Upstream prefers an explicit `name` when present, falling back to the + // dict key. Always round-trip it to a trimmed non-empty string. + let resolved_name = obj + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| key.trim().to_string()); + if resolved_name.is_empty() { + return Ok(None); + } + obj.insert("name".to_string(), serde_json::json!(resolved_name)); + obj.insert("provider_key".to_string(), serde_json::json!(key)); + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + serde_json::json!(PROVIDER_SOURCE_DICT), + ); + Ok(Some(json_val)) +} + +/// Collect provider entries living under the v12+ `providers:` dict. +fn read_providers_dict_entries( + config: &serde_yaml::Value, +) -> Vec<(String, serde_json::Value)> { + let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (k, v) in mapping { + let Some(key_str) = k.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + match normalize_providers_dict_entry(key_str, v) { + Ok(Some(entry)) => { + let name = entry + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(key_str) + .to_string(); + out.push((name, entry)); + } + Ok(None) => { + log::debug!("Skipping Hermes providers['{key_str}']: not a mapping"); + } + Err(e) => { + log::warn!("Failed to normalize Hermes providers['{key_str}']: {e}"); + } + } + } + out +} + +/// Get all providers as a JSON map keyed by provider name. /// -/// Entries that live under Hermes' v12+ `providers:` dict are deliberately -/// not surfaced here — Hermes' own runtime merges both shapes via -/// `get_compatible_custom_providers`, but CC Switch only manages the legacy -/// `custom_providers:` list. The `schema_migrated_v12` health warning flags -/// any non-empty `providers:` dict so the user can reconcile via Hermes Web UI. +/// Unions two on-disk sources, matching upstream `get_compatible_custom_providers`: +/// - `custom_providers:` list entries (writable by CC Switch) +/// - `providers:` dict entries (v12+ schema, surfaced read-only with +/// `_cc_source = "providers_dict"` so the UI can disable edit/delete) +/// +/// When a name appears in both, the list entry wins (upstream dedup order), +/// keeping CC Switch free to edit it. Models are denormalized from the YAML +/// dict shape to the UI-friendly ordered array. pub fn get_providers() -> Result, AppError> { let config = read_hermes_config()?; let mut map = serde_json::Map::new(); @@ -533,6 +608,12 @@ pub fn get_providers() -> Result, App // reveal stale `baseUrl` / `apiKey` fields. sanitize_hermes_provider_keys(&mut json_val); denormalize_provider_models_for_read(&mut json_val); + if let Some(obj) = json_val.as_object_mut() { + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + serde_json::json!(PROVIDER_SOURCE_CUSTOM_LIST), + ); + } map.insert(name.to_string(), json_val); } Err(e) => { @@ -543,9 +624,48 @@ pub fn get_providers() -> Result, App } } + for (name, mut entry) in read_providers_dict_entries(&config) { + if map.contains_key(&name) { + continue; // list wins over dict on duplicate names + } + denormalize_provider_models_for_read(&mut entry); + map.insert(name, entry); + } + Ok(map) } +/// True when `name` appears in `providers:` dict but not in `custom_providers:` +/// list — i.e. it is a read-only overlay CC Switch must not touch. +fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { + let list_has = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .any(|item| item.get("name").and_then(|n| n.as_str()) == Some(name)) + }) + .unwrap_or(false); + if list_has { + return false; + } + config + .get("providers") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter().any(|(k, v)| { + let key_matches = k.as_str() == Some(name); + let name_matches = v + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s == name) + .unwrap_or(false); + (key_matches || name_matches) && v.is_mapping() + }) + }) + .unwrap_or(false) +} + /// Get a single custom provider by name. pub fn get_provider(name: &str) -> Result, AppError> { Ok(get_providers()?.get(name).cloned()) @@ -571,6 +691,11 @@ pub fn set_provider( let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; + if is_dict_only_provider(&config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — edit via Hermes Web UI" + ))); + } let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -643,6 +768,12 @@ pub fn remove_provider(name: &str) -> Result { let _guard = hermes_write_lock().lock()?; let config = read_hermes_config()?; + if is_dict_only_provider(&config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by Hermes' 'providers:' dict — remove via Hermes Web UI" + ))); + } + let mut providers: Vec = config .get("custom_providers") .and_then(|v| v.as_sequence()) @@ -864,7 +995,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec { if version >= 12 && providers_dict_populated { warnings.push(hermes_warning( "schema_migrated_v12", - "Hermes' newer schema moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(), + "Hermes' newer schema moved some entries into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.".to_string(), "providers", )); } @@ -1409,6 +1540,128 @@ custom_providers: }); } + #[test] + #[serial] + fn get_providers_surfaces_providers_dict_as_read_only() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +custom_providers: + - name: mine + base_url: https://mine.example.com + api_key: sk-mine +providers: + anthropic: + base_url: https://api.anthropic.com + api_key: sk-ant + model: claude-opus-4.6 + ollama-local: + base_url: http://localhost:11434/v1 + request_timeout_seconds: 300 +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 3); + + let mine = providers.get("mine").unwrap(); + assert_eq!(mine[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST); + + let anthropic = providers.get("anthropic").unwrap(); + assert_eq!(anthropic[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT); + assert_eq!(anthropic["provider_key"], "anthropic"); + assert_eq!(anthropic["base_url"], "https://api.anthropic.com"); + + let ollama = providers.get("ollama-local").unwrap(); + assert_eq!(ollama[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT); + // Forward-compat fields from the dict pass through untouched + assert_eq!(ollama["request_timeout_seconds"], 300); + }); + } + + #[test] + #[serial] + fn get_providers_list_wins_on_name_collision() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +custom_providers: + - name: shared + base_url: https://writable.example.com +providers: + shared: + base_url: https://overlay.example.com +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + let shared = providers.get("shared").unwrap(); + assert_eq!(shared["base_url"], "https://writable.example.com"); + assert_eq!(shared[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST); + }); + } + + #[test] + #[serial] + fn set_provider_rejects_dict_only_entries() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +providers: + anthropic: + base_url: https://api.anthropic.com + model: claude-opus-4.6 +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + let update = serde_json::json!({ "base_url": "https://hacked.example.com" }); + let err = set_provider("anthropic", update).unwrap_err(); + assert!( + format!("{err}").contains("providers:"), + "error message should point user at providers dict: {err}" + ); + }); + } + + #[test] + #[serial] + fn remove_provider_rejects_dict_only_entries() { + with_test_home(|| { + let yaml = "\ +_config_version: 19 +providers: + anthropic: + base_url: https://api.anthropic.com +"; + let config_path = get_hermes_config_path(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, yaml).unwrap(); + + assert!(remove_provider("anthropic").is_err()); + }); + } + + #[test] + fn sanitize_strips_ui_only_markers() { + let mut v = serde_json::json!({ + "base_url": "https://api.example.com", + "_cc_source": "providers_dict", + "provider_key": "anthropic", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.get("_cc_source").is_none()); + assert!(obj.get("provider_key").is_none()); + assert!(obj.get("base_url").is_some()); + } + #[test] #[serial] fn get_providers_heals_legacy_camel_case_on_read() { diff --git a/src/components/hermes/HermesHealthBanner.tsx b/src/components/hermes/HermesHealthBanner.tsx index 244036803..ad3750f78 100644 --- a/src/components/hermes/HermesHealthBanner.tsx +++ b/src/components/hermes/HermesHealthBanner.tsx @@ -63,7 +63,7 @@ function getWarningText( case "schema_migrated_v12": return t("hermes.health.schemaMigratedV12", { defaultValue: - "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.", + "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI.", }); default: return fallback; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 491806485..ecc8e8b5e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default is not in the selected provider's models list.", "duplicateProviderName": "custom_providers contains duplicate provider names — only one entry will be used.", "duplicateProviderBaseUrl": "custom_providers contains duplicate base_urls — possible accidental copy.", - "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI." + "schemaMigratedV12": "Hermes' newer schema moved some providers into the 'providers:' dict. They are shown read-only in CC Switch — edit or remove those entries via Hermes Web UI." }, "memory": { "title": "Memory", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1041f3256..657a12fe6 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default は選択中のプロバイダーのモデル一覧に含まれていません。", "duplicateProviderName": "custom_providers にプロバイダー名の重複があります。1 件のみ有効になります。", "duplicateProviderBaseUrl": "custom_providers に重複した base_url があります。誤ってコピーされた可能性があります。", - "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch は 'custom_providers:' のみを管理します。該当する項目は Hermes Web UI で編集・削除してください。" + "schemaMigratedV12": "Hermes の新しいスキーマでは一部のプロバイダーが 'providers:' dict に移動しました。CC Switch では読み取り専用で表示されます。編集・削除は Hermes Web UI から行ってください。" }, "memory": { "title": "メモリ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index ec1965d93..7017fab4e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1685,7 +1685,7 @@ "modelDefaultNotInProvider": "model.default 指向的模型不在所选供应商的模型列表中。", "duplicateProviderName": "custom_providers 中存在重复的供应商名,只会有一条生效。", "duplicateProviderBaseUrl": "custom_providers 中存在重复的 base_url,可能是意外复制。", - "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 只管理 'custom_providers:',请在 Hermes Web UI 编辑或删除这些条目。" + "schemaMigratedV12": "Hermes 新版 schema 把部分供应商移到了 'providers:' dict。CC Switch 中以只读方式显示,编辑或删除请通过 Hermes Web UI 操作。" }, "memory": { "title": "记忆管理",