refactor(hermes): simplify schema handling + preserve unknown provider fields

Drops the v11→v12 providers-dict compat layer: CC Switch now only
reads/writes `custom_providers:`, leaving migrated `providers:` dict
entries to Hermes Web UI for reconciliation (Hermes' runtime already
merges both shapes via `get_compatible_custom_providers`). The
`schema_migrated_v12` health warning now points users there when a
dict-migrated config is detected.

Adds forward-compat merge to `set_provider`: when updating an existing
entry, on-disk fields the UI payload didn't submit (e.g. Hermes-only
`request_timeout_seconds`, `key_env`) are carried over. Without this,
editing one field via CC Switch would silently strip the rest.

Adds `set_memory_enabled` + `set_hermes_memory_enabled` Tauri command
for the upcoming memory-switch UI. Writes go through a merge-aware
section replacement so character budgets and external-provider fields
survive toggle operations.

Removes four dict-only helpers (`normalize_providers_dict_entry_for_read`,
`rename_alias_key`, `json_obj_non_empty_str`,
`resolve_provider_name_from_yaml_entry`) and the multi-section write
helper. Simplifies `get_providers` / `remove_provider` / health scan
back to list-only. Replaces nine obsolete dict-related tests with
`set_provider_preserves_unknown_fields_on_update` and
`set_memory_enabled_preserves_other_fields`.
This commit is contained in:
Jason
2026-04-20 09:48:59 +08:00
parent acc6d795e4
commit 31fb998575
3 changed files with 399 additions and 61 deletions
+8
View File
@@ -76,6 +76,14 @@ pub fn get_hermes_memory_limits() -> Result<hermes_config::HermesMemoryLimits, S
hermes_config::read_memory_limits().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_hermes_memory_enabled(
kind: hermes_config::MemoryKind,
enabled: bool,
) -> Result<hermes_config::HermesWriteOutcome, String> {
hermes_config::set_memory_enabled(kind, enabled).map_err(|e| e.to_string())
}
// ============================================================================
// Hermes Web UI launcher
// ============================================================================
+390 -61
View File
@@ -346,7 +346,8 @@ fn write_yaml_section_to_config_locked(
let warnings = scan_hermes_health_internal(&new_raw);
log::debug!(
"Hermes config section '{section_key}' written to {:?}",
"Hermes config section '{}' written to {:?}",
section_key,
config_path
);
Ok(HermesWriteOutcome {
@@ -447,6 +448,28 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) {
}
}
/// Borrow a YAML scalar as a non-empty trimmed `&str`, or `None` when the
/// value is absent, non-string, or blank after trimming.
fn yaml_as_non_empty_str(v: &serde_yaml::Value) -> Option<&str> {
v.as_str().map(str::trim).filter(|s| !s.is_empty())
}
/// Extract string keys from a YAML mapping as owned strings, dropping non-string keys.
fn collect_mapping_string_keys(m: &serde_yaml::Mapping) -> Vec<String> {
m.iter()
.filter_map(|(k, _)| k.as_str().map(str::to_string))
.collect()
}
/// Boilerplate-free constructor for [`HermesHealthWarning`].
fn hermes_warning(code: &str, message: String, path: &str) -> HermesHealthWarning {
HermesHealthWarning {
code: code.to_string(),
message,
path: Some(path.to_string()),
}
}
/// Get all custom providers as a JSON map keyed by provider name.
///
/// Reads the `custom_providers:` YAML sequence where each item has a `name`
@@ -454,38 +477,32 @@ fn denormalize_provider_models_for_read(config: &mut serde_json::Value) {
/// `models` field is converted from the YAML dict shape back to the
/// UI-friendly ordered array shape.
///
/// We intentionally use the legacy `custom_providers:` list (not the v12+
/// `providers:` dict) because Hermes's runtime_provider.py
/// `_get_named_custom_provider` has a bug in its `providers:` branch: the
/// returned entry dict drops `api_mode` / `transport` / `models` /
/// singular `model:`. That leaves `_resolve_named_custom_runtime` to fall
/// back to `chat_completions`, breaking every anthropic_messages provider.
/// The legacy `custom_providers:` branch goes through
/// `_normalize_custom_provider_entry` and preserves all fields correctly.
/// 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.
pub fn get_providers() -> Result<serde_json::Map<String, serde_json::Value>, AppError> {
let config = read_hermes_config()?;
let providers = config
.get("custom_providers")
.and_then(|v| v.as_sequence())
.map(|seq| {
let mut map = serde_json::Map::new();
for item in seq {
if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
match yaml_to_json(item) {
Ok(mut json_val) => {
denormalize_provider_models_for_read(&mut json_val);
map.insert(name.to_string(), json_val);
}
Err(e) => {
log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}");
}
let mut map = serde_json::Map::new();
if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) {
for item in seq {
if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
match yaml_to_json(item) {
Ok(mut json_val) => {
denormalize_provider_models_for_read(&mut json_val);
map.insert(name.to_string(), json_val);
}
Err(e) => {
log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}");
}
}
}
map
})
.unwrap_or_default();
Ok(providers)
}
}
Ok(map)
}
/// Get a single custom provider by name.
@@ -551,6 +568,18 @@ pub fn set_provider(
.iter_mut()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name))
{
// Forward-compat: carry over any on-disk fields the UI payload didn't
// include. Hermes keeps evolving (e.g. `request_timeout_seconds`,
// `key_env`), and users may set those via Hermes Web UI — without
// this merge, a CC Switch edit to an unrelated field would silently
// strip them on write-back.
if let (Some(existing_map), serde_yaml::Value::Mapping(new_map)) =
(existing.as_mapping(), &mut yaml_val)
{
for (k, v) in existing_map {
new_map.entry(k.clone()).or_insert_with(|| v.clone());
}
}
*existing = yaml_val;
} else {
providers.push(yaml_val);
@@ -567,8 +596,8 @@ pub fn set_provider(
/// read-modify-write is done under the write lock to prevent TOCTOU races.
pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
let mut providers: Vec<serde_yaml::Value> = config
.get("custom_providers")
.and_then(|v| v.as_sequence())
@@ -577,7 +606,6 @@ pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
let original_len = providers.len();
providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name));
if providers.len() == original_len {
return Ok(HermesWriteOutcome::default());
}
@@ -669,39 +697,131 @@ fn scan_hermes_health_internal(content: &str) -> Vec<HermesHealthWarning> {
return warnings;
}
match serde_yaml::from_str::<serde_yaml::Value>(content) {
Ok(config) => {
// Check for model section issues
if let Some(model) = config.get("model") {
if model.get("default").is_none() && model.get("provider").is_none() {
warnings.push(HermesHealthWarning {
code: "model_no_default".to_string(),
message: "No default model or provider configured in 'model' section"
.to_string(),
path: Some("model".to_string()),
});
}
}
// Check custom_providers is a sequence
if let Some(providers) = config.get("custom_providers") {
if !providers.is_sequence() {
warnings.push(HermesHealthWarning {
code: "custom_providers_not_list".to_string(),
message: "custom_providers should be a YAML list (sequence), not a mapping"
.to_string(),
path: Some("custom_providers".to_string()),
});
}
}
}
let config = match serde_yaml::from_str::<serde_yaml::Value>(content) {
Ok(cfg) => cfg,
Err(err) => {
warnings.push(HermesHealthWarning {
code: "config_parse_failed".to_string(),
message: format!("Hermes config could not be parsed as YAML: {err}"),
path: Some(get_hermes_config_path().display().to_string()),
});
warnings.push(hermes_warning(
"config_parse_failed",
format!("Hermes config could not be parsed as YAML: {err}"),
&get_hermes_config_path().display().to_string(),
));
return warnings;
}
};
if let Some(model) = config.get("model") {
if model.get("default").is_none() && model.get("provider").is_none() {
warnings.push(hermes_warning(
"model_no_default",
"No default model or provider configured in 'model' section".to_string(),
"model",
));
}
}
let cp_is_mapping = config
.get("custom_providers")
.map(|v| v.is_mapping())
.unwrap_or(false);
if cp_is_mapping {
warnings.push(hermes_warning(
"custom_providers_not_list",
"custom_providers should be a YAML list (sequence), not a mapping".to_string(),
"custom_providers",
));
}
let mut provider_models: HashMap<String, Vec<String>> = HashMap::new();
let mut name_counts: HashMap<String, usize> = HashMap::new();
let mut base_url_counts: HashMap<String, usize> = HashMap::new();
if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) {
for item in seq {
if let Some(name) = item.get("name").and_then(yaml_as_non_empty_str) {
*name_counts.entry(name.to_string()).or_insert(0) += 1;
if let Some(models) = item.get("models").and_then(|m| m.as_mapping()) {
provider_models
.entry(name.to_string())
.or_insert_with(|| collect_mapping_string_keys(models));
}
}
if let Some(url) = item
.get("base_url")
.and_then(|n| n.as_str())
.map(|s| s.trim().trim_end_matches('/').to_lowercase())
.filter(|s| !s.is_empty())
{
*base_url_counts.entry(url).or_insert(0) += 1;
}
}
}
// name_counts keys are unique by construction, so iterating it already
// reports each duplicate name exactly once — no extra dedupe set needed.
for (name, count) in &name_counts {
if *count > 1 {
warnings.push(hermes_warning(
"duplicate_provider_name",
format!(
"Duplicate provider name '{name}' in custom_providers — only one entry will be used"
),
"custom_providers",
));
}
}
for (url, count) in &base_url_counts {
if *count > 1 {
warnings.push(hermes_warning(
"duplicate_provider_base_url",
format!(
"Duplicate base_url '{url}' in custom_providers — possible accidental copy"
),
"custom_providers",
));
}
}
if let Some(model) = config.get("model") {
if let Some(provider_ref) = model.get("provider").and_then(yaml_as_non_empty_str) {
if !name_counts.contains_key(provider_ref) {
warnings.push(hermes_warning(
"model_provider_unknown",
format!(
"model.provider '{provider_ref}' does not match any configured provider"
),
"model.provider",
));
} else if let Some(default) = model.get("default").and_then(yaml_as_non_empty_str) {
if let Some(ids) = provider_models.get(provider_ref) {
if !ids.is_empty() && !ids.iter().any(|id| id == default) {
warnings.push(hermes_warning(
"model_default_not_in_provider",
format!(
"model.default '{default}' is not in provider '{provider_ref}' models list"
),
"model.default",
));
}
}
}
}
}
let version = config
.get("_config_version")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let providers_dict_populated = config
.get("providers")
.and_then(|v| v.as_mapping())
.map(|m| !m.is_empty())
.unwrap_or(false);
if version >= 12 && providers_dict_populated {
warnings.push(hermes_warning(
"schema_migrated_v12",
"Hermes v12 moved some entries into the 'providers:' dict. CC Switch only manages 'custom_providers:' — edit or remove those entries via Hermes Web UI.".to_string(),
"providers",
));
}
warnings
@@ -842,6 +962,32 @@ impl Default for HermesMemoryLimits {
}
}
/// Toggle the on/off flag for one of Hermes' two memory blobs, preserving all
/// other fields in the `memory:` section (character budgets, external provider
/// settings, etc.). Hermes stores the user-profile toggle under
/// `user_profile_enabled` (not `user_enabled`), so the mapping to on-disk keys
/// lives here rather than leaking to callers.
pub fn set_memory_enabled(kind: MemoryKind, enabled: bool) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
let mut memory = match config.get("memory") {
Some(serde_yaml::Value::Mapping(m)) => m.clone(),
_ => serde_yaml::Mapping::new(),
};
let key = match kind {
MemoryKind::Memory => "memory_enabled",
MemoryKind::User => "user_profile_enabled",
};
memory.insert(
serde_yaml::Value::String(key.to_string()),
serde_yaml::Value::Bool(enabled),
);
write_yaml_section_to_config_locked("memory", &serde_yaml::Value::Mapping(memory))
}
/// Read memory budgets + toggles from `config.yaml`. Missing/unparsable
/// fields fall back to `HermesMemoryLimits::default()` rather than erroring,
/// so an empty or partially-populated config still yields a usable UI.
@@ -1114,6 +1260,41 @@ model:
});
}
#[test]
#[serial]
fn set_provider_preserves_unknown_fields_on_update() {
// Hermes keeps adding provider-level fields (e.g.
// `request_timeout_seconds`, `key_env`). Users may set those via
// Hermes Web UI; a later CC Switch edit must not strip them — set_provider
// carries over any existing on-disk fields that the UI payload didn't
// submit.
with_test_home(|| {
let yaml = "\
custom_providers:
- name: acme
base_url: https://old.example.com
api_key: sk-old
request_timeout_seconds: 300
key_env: ACME_API_KEY
";
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://new.example.com",
"api_key": "sk-new"
});
set_provider("acme", update).unwrap();
let provider = get_provider("acme").unwrap().unwrap();
assert_eq!(provider["base_url"], "https://new.example.com");
assert_eq!(provider["api_key"], "sk-new");
assert_eq!(provider["request_timeout_seconds"], 300);
assert_eq!(provider["key_env"], "ACME_API_KEY");
});
}
// ---- Model config tests ----
#[test]
@@ -1496,6 +1677,43 @@ custom_providers:
});
}
#[test]
#[serial]
fn set_memory_enabled_preserves_other_fields() {
// Flipping one toggle must preserve character budgets and external
// provider settings the user configured via Hermes Web UI — otherwise
// a CC Switch toggle would silently wipe those fields.
with_test_home(|| {
let yaml = "\
memory:
memory_char_limit: 4096
user_char_limit: 2048
memory_enabled: true
user_profile_enabled: true
provider: mem0
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
set_memory_enabled(MemoryKind::Memory, false).unwrap();
let limits = read_memory_limits().unwrap();
assert!(!limits.memory_enabled, "toggle applied");
assert!(limits.user_enabled, "unrelated toggle untouched");
assert_eq!(limits.memory, 4096, "budgets preserved");
assert_eq!(limits.user, 2048);
// Verify the external provider field survived the section replacement.
let config = read_hermes_config().unwrap();
let provider = config
.get("memory")
.and_then(|v| v.get("provider"))
.and_then(|v| v.as_str());
assert_eq!(provider, Some("mem0"));
});
}
#[test]
#[serial]
fn memory_limits_read_from_config_yaml() {
@@ -1553,4 +1771,115 @@ user_profile_enabled: false
assert_eq!(user, MemoryKind::User);
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
}
// ---- Extended health scan rules ----
#[test]
fn health_check_model_provider_unknown() {
let yaml = "\
model:
default: gpt-4
provider: ghost
custom_providers:
- name: real
base_url: https://real.example.com
";
let warnings = scan_hermes_health_internal(yaml);
assert!(warnings.iter().any(|w| w.code == "model_provider_unknown"));
}
#[test]
fn health_check_model_default_not_in_provider() {
let yaml = "\
model:
default: missing-model
provider: real
custom_providers:
- name: real
base_url: https://real.example.com
models:
present-model: {}
";
let warnings = scan_hermes_health_internal(yaml);
assert!(warnings
.iter()
.any(|w| w.code == "model_default_not_in_provider"));
}
#[test]
fn health_check_duplicate_provider_name() {
let yaml = "\
custom_providers:
- name: twin
base_url: https://a.example.com
- name: twin
base_url: https://b.example.com
";
let warnings = scan_hermes_health_internal(yaml);
let dup_count = warnings
.iter()
.filter(|w| w.code == "duplicate_provider_name")
.count();
assert_eq!(dup_count, 1, "each duplicate name should be reported once");
}
#[test]
fn health_check_duplicate_provider_base_url() {
let yaml = "\
custom_providers:
- name: a
base_url: https://same.example.com/
- name: b
base_url: https://SAME.example.com
";
let warnings = scan_hermes_health_internal(yaml);
assert!(warnings
.iter()
.any(|w| w.code == "duplicate_provider_base_url"));
}
#[test]
fn health_check_schema_migrated_v12_warning() {
let yaml = "\
_config_version: 19
providers:
ds-1:
base_url: https://api.deepseek.com
";
let warnings = scan_hermes_health_internal(yaml);
assert!(warnings.iter().any(|w| w.code == "schema_migrated_v12"));
}
#[test]
fn health_check_schema_migrated_not_reported_when_dict_empty() {
// v19 with empty providers: {} should NOT surface the migration warning —
// otherwise fresh installs would see it spuriously.
let yaml = "\
_config_version: 19
providers: {}
";
let warnings = scan_hermes_health_internal(yaml);
assert!(!warnings.iter().any(|w| w.code == "schema_migrated_v12"));
}
#[test]
fn health_check_valid_config_with_matching_model_and_provider() {
// Regression guard: none of the new rules should fire on a well-formed config.
let yaml = "\
model:
default: gpt-4
provider: openrouter
custom_providers:
- name: openrouter
base_url: https://openrouter.ai/api/v1
models:
gpt-4: {}
";
let warnings = scan_hermes_health_internal(yaml);
assert!(
warnings.is_empty(),
"expected no warnings, got: {:?}",
warnings
);
}
}
+1
View File
@@ -1261,6 +1261,7 @@ pub fn run() {
commands::get_hermes_memory,
commands::set_hermes_memory,
commands::get_hermes_memory_limits,
commands::set_hermes_memory_enabled,
// Global upstream proxy
commands::get_global_proxy_url,
commands::set_global_proxy_url,