mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-24 06:40:21 +08:00
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.
This commit is contained in:
+266
-13
@@ -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<Option<serde_json::Value>, 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<serde_json::Map<String, serde_json::Value>, AppError> {
|
||||
let config = read_hermes_config()?;
|
||||
let mut map = serde_json::Map::new();
|
||||
@@ -533,6 +608,12 @@ pub fn get_providers() -> Result<serde_json::Map<String, serde_json::Value>, 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<serde_json::Map<String, serde_json::Value>, 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<Option<serde_json::Value>, 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<serde_yaml::Value> = config
|
||||
.get("custom_providers")
|
||||
.and_then(|v| v.as_sequence())
|
||||
@@ -643,6 +768,12 @@ pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
|
||||
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<serde_yaml::Value> = config
|
||||
.get("custom_providers")
|
||||
.and_then(|v| v.as_sequence())
|
||||
@@ -864,7 +995,7 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "メモリ",
|
||||
|
||||
@@ -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": "记忆管理",
|
||||
|
||||
Reference in New Issue
Block a user