diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 6dd226c..cec273d 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -94,41 +94,64 @@ fn api_access_locator_identity( Some((sha256_bytes(&identity), api_key_hashes)) } +fn find_api_access_record_remark( + config: &GuiConfigFile, + provider_section: &str, + locator: &ApiAccessRemarkLocator, +) -> Option { + let (record_hash, api_key_hashes) = api_access_locator_identity(provider_section, locator)?; + let exact_entries = config.api_access_remarks.iter().filter(|entry| { + entry.provider_section == provider_section + && entry.record_hash == record_hash + && api_key_hashes.contains(&entry.api_key_hash) + }); + let mut exact_remark = None; + for entry in exact_entries { + exact_remark = Some(entry.remark.clone()); + if !entry.remark.is_empty() { + break; + } + } + exact_remark +} + fn resolve_api_access_remark( config: &GuiConfigFile, query: &ApiAccessRemarkQuery, ) -> Result { validate_api_access_provider_section(&query.provider_section)?; - let Some((record_hash, api_key_hashes)) = + let mut locators = vec![query.locator.clone()]; + if matches!( + query.provider_section.as_str(), + "gemini-api-key" | "codex-api-key" | "claude-api-key" + ) && !query.locator.provider_name.trim().is_empty() + { + let mut unnamed = query.locator.clone(); + unnamed.provider_name.clear(); + locators.push(unnamed); + } + + for mut locator in locators { + if let Some(remark) = + find_api_access_record_remark(config, &query.provider_section, &locator) + { + return Ok(remark); + } + if !locator.config_identity.is_empty() { + locator.config_identity.clear(); + if let Some(remark) = + find_api_access_record_remark(config, &query.provider_section, &locator) + { + return Ok(remark); + } + } + } + + let Some((_, api_key_hashes)) = api_access_locator_identity(&query.provider_section, &query.locator) else { return Ok(String::new()); }; - - let exact_entries = config.api_access_remarks.iter().filter(|entry| { - entry.provider_section == query.provider_section - && entry.record_hash == record_hash - && api_key_hashes.contains(&entry.api_key_hash) - }); - let mut exact_found = false; - let mut exact_remark = String::new(); - for entry in exact_entries { - exact_found = true; - if !entry.remark.is_empty() { - exact_remark = entry.remark.clone(); - break; - } - } - if exact_found { - return Ok(exact_remark); - } - - if !query.locator.config_identity.is_empty() { - let mut legacy_query = query.clone(); - legacy_query.locator.config_identity.clear(); - return resolve_api_access_remark(config, &legacy_query); - } - Ok(api_key_hashes .iter() .find_map(|hash| { @@ -274,6 +297,166 @@ mod tests { } } + fn store_record_remark( + config: &mut GuiConfigFile, + provider_section: &str, + locator: &ApiAccessRemarkLocator, + remark: &str, + ) { + let (record_hash, api_key_hashes) = + api_access_locator_identity(provider_section, locator).unwrap(); + for api_key_hash in api_key_hashes { + config.api_access_remarks.push(GuiApiAccessRemark { + provider_section: provider_section.to_string(), + api_key_hash, + record_hash: record_hash.clone(), + remark: remark.to_string(), + }); + } + } + + #[test] + fn v8_group_names_preserve_and_migrate_unnamed_record_remarks() { + for provider_section in ["gemini-api-key", "codex-api-key", "claude-api-key"] { + for config_identity in ["", r#"{"models":[{"name":"a"}]}"#] { + let mut config = GuiConfigFile::default(); + let mut legacy = locator("https://api.example/v1", &["shared-key"]); + legacy.config_identity = config_identity.into(); + store_record_remark(&mut config, provider_section, &legacy, "original"); + let legacy_hash = config.api_access_remarks[0].record_hash.clone(); + + let mut current = legacy.clone(); + current.provider_name = "production".into(); + current.config_identity = r#"{"models":[{"name":"a"}]}"#.into(); + let current_query = ApiAccessRemarkQuery { + provider_section: provider_section.into(), + locator: current.clone(), + }; + assert_eq!( + resolve_api_access_remark(&config, ¤t_query).unwrap(), + "original", + "{provider_section}, legacy config identity: {config_identity}" + ); + + let mut other = current.clone(); + other.provider_name = "staging".into(); + other.config_identity = r#"{"models":[{"name":"b"}]}"#.into(); + let mut other_update = update( + Vec::new(), + vec![other.clone()], + vec![current.clone(), other.clone()], + "other", + ); + other_update.provider_section = provider_section.into(); + apply_api_access_remark_update(&mut config, other_update).unwrap(); + + assert_eq!(config.api_access_remarks.len(), 2); + let current_hash = api_access_locator_identity(provider_section, ¤t) + .unwrap() + .0; + assert!(config.api_access_remarks.iter().any(|entry| { + entry.record_hash == current_hash && entry.remark == "original" + })); + assert!(config + .api_access_remarks + .iter() + .all(|entry| entry.record_hash != legacy_hash)); + assert_eq!( + resolve_api_access_remark(&config, ¤t_query).unwrap(), + "original" + ); + assert_eq!( + resolve_api_access_remark( + &config, + &ApiAccessRemarkQuery { + provider_section: provider_section.into(), + locator: other, + } + ) + .unwrap(), + "other" + ); + } + } + } + + #[test] + fn named_record_remarks_override_unnamed_fallbacks_including_empty_notes() { + for named_config_identity in ["", r#"{"models":[{"name":"a"}]}"#] { + for remark in ["updated", ""] { + let mut config = GuiConfigFile::default(); + let mut legacy = locator("https://api.example/v1", &["shared-key"]); + legacy.config_identity = r#"{"models":[{"name":"a"}]}"#.into(); + store_record_remark(&mut config, "codex-api-key", &legacy, "legacy"); + let mut current = legacy.clone(); + current.provider_name = "production".into(); + let mut saved = current.clone(); + saved.config_identity = named_config_identity.into(); + store_record_remark(&mut config, "codex-api-key", &saved, remark); + + assert_eq!( + resolve_api_access_remark(&config, &query(current)).unwrap(), + remark + ); + } + } + } + + #[test] + fn unnamed_fallback_preserves_config_scope_and_explicitly_empty_notes() { + let mut config = GuiConfigFile::default(); + let legacy = locator("https://api.example/v1", &["shared-key"]); + store_record_remark(&mut config, "codex-api-key", &legacy, "unscoped"); + for (model, remark) in [("a", "first"), ("b", "")] { + let mut scoped = legacy.clone(); + scoped.config_identity = format!(r#"{{"models":[{{"name":"{model}"}}]}}"#); + store_record_remark(&mut config, "codex-api-key", &scoped, remark); + } + + for (model, expected) in [("a", "first"), ("b", ""), ("c", "unscoped")] { + let mut current = legacy.clone(); + current.provider_name = "production".into(); + current.config_identity = format!(r#"{{"models":[{{"name":"{model}"}}]}}"#); + assert_eq!( + resolve_api_access_remark(&config, &query(current)).unwrap(), + expected + ); + } + } + + #[test] + fn group_name_fallback_does_not_cross_openai_names_or_record_boundaries() { + let mut config = GuiConfigFile::default(); + let legacy = locator("https://api.example/v1", &["shared-key"]); + for provider_section in ["codex-api-key", "openai-compatibility"] { + store_record_remark(&mut config, provider_section, &legacy, "legacy"); + } + let mut named = legacy.clone(); + named.provider_name = "production".into(); + let mut different_url = named.clone(); + different_url.base_url = "https://other.example/v1".into(); + let mut different_key = named.clone(); + different_key.api_keys = vec!["other-key".into()]; + for (provider_section, locator) in [ + ("openai-compatibility", named.clone()), + ("claude-api-key", named), + ("codex-api-key", different_url), + ("codex-api-key", different_key), + ] { + assert_eq!( + resolve_api_access_remark( + &config, + &ApiAccessRemarkQuery { + provider_section: provider_section.into(), + locator, + } + ) + .unwrap(), + "" + ); + } + } + #[test] fn remarks_are_scoped_to_provider_records_that_share_an_api_key() { let mut config = GuiConfigFile::default(); diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 2d30949..82b6bb8 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -291,6 +291,40 @@ fn is_v8_shared_provider_field(field: &str) -> bool { ) } +fn normalize_v8_provider_record( + provider: &str, + record: &serde_norway::Mapping, +) -> serde_norway::Mapping { + let mut record = record.clone(); + if provider == "openai-compatibility" { + record.remove(yaml_key("test-model")); + record.remove(yaml_key("testModel")); + } + if let Some(models) = record + .get_mut(yaml_key("models")) + .and_then(serde_norway::Value::as_sequence_mut) + { + for model in models { + let Some(thinking) = model + .as_mapping_mut() + .and_then(|model| model.get_mut(yaml_key("thinking"))) + .and_then(serde_norway::Value::as_mapping_mut) + else { + continue; + }; + for (json, yaml) in [ + ("zero_allowed", "zero-allowed"), + ("dynamic_allowed", "dynamic-allowed"), + ] { + if let Some(value) = thinking.remove(yaml_key(json)) { + thinking.entry(yaml_key(yaml)).or_insert(value); + } + } + } + } + record +} + pub(crate) fn flatten_v8_provider_groups( provider: &str, groups: &serde_norway::Value, @@ -317,7 +351,9 @@ pub(crate) fn flatten_v8_provider_groups( serde_norway::Value::Sequence(keys.clone()), ); } - records.push(serde_norway::Value::Mapping(record)); + records.push(serde_norway::Value::Mapping(normalize_v8_provider_record( + provider, &record, + ))); continue; } for (key_index, key) in keys.iter().enumerate() { @@ -327,6 +363,20 @@ pub(crate) fn flatten_v8_provider_groups( ) })?; let mut record = serde_norway::Mapping::new(); + if let Some(name) = yaml_mapping_value(group, "name") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + { + let generated = name + .strip_prefix(&format!("{provider}-")) + .is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit()) + }); + if !generated { + record.insert(yaml_key("name"), yaml_key(name)); + } + } for (field, value) in group { if field .as_str() @@ -340,7 +390,9 @@ pub(crate) fn flatten_v8_provider_groups( record.insert(field.clone(), value.clone()); } } - records.push(serde_norway::Value::Mapping(record)); + records.push(serde_norway::Value::Mapping(normalize_v8_provider_record( + provider, &record, + ))); } } Ok(serde_norway::Value::Sequence(records)) @@ -358,6 +410,7 @@ pub(crate) fn group_legacy_provider_records( let record = record .as_mapping() .ok_or_else(|| format!("{provider} provider entry {index} must be an object"))?; + let record = normalize_v8_provider_record(provider, record); if provider == "openai-compatibility" { let mut group = record.clone(); let keys = group @@ -368,12 +421,18 @@ pub(crate) fn group_legacy_provider_records( continue; } let mut group = serde_norway::Mapping::new(); - group.insert( - yaml_key("name"), - serde_norway::Value::String(format!("{provider}-{}", index + 1)), - ); + let name = yaml_mapping_value(&record, "name") + .and_then(serde_norway::Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{provider}-{}", index + 1)); + group.insert(yaml_key("name"), serde_norway::Value::String(name)); let mut key = serde_norway::Mapping::new(); - for (field, value) in record { + for (field, value) in &record { + if field.as_str() == Some("name") { + continue; + } if field .as_str() .is_some_and(is_v8_shared_provider_field) diff --git a/src-tauri/src/core_config/commands.rs b/src-tauri/src/core_config/commands.rs index ee64170..292254d 100644 --- a/src-tauri/src/core_config/commands.rs +++ b/src-tauri/src/core_config/commands.rs @@ -113,7 +113,7 @@ pub(crate) fn save_network_routing_settings( return Err("Port must be between 1 and 65535".to_string()); } let routing_session_affinity_ttl = - normalize_optional_config_string(settings.routing_session_affinity_ttl, "Session affinity TTL")?; + normalize_session_affinity_ttl(settings.routing_session_affinity_ttl)?; let _refresh_guard = cache .refresh_lock @@ -235,10 +235,7 @@ pub(crate) fn save_session_routing_settings( gui_config_state: tauri::State<'_, GuiConfigState>, settings: GuiSessionRoutingSettings, ) -> Result { - let ttl = normalize_optional_config_string( - settings.routing_session_affinity_ttl, - "Session affinity TTL", - )?; + let ttl = normalize_session_affinity_ttl(settings.routing_session_affinity_ttl)?; let previous = gui_config_state.snapshot()?; let mut next = previous.clone(); next.routing_session_affinity = settings.routing_session_affinity; @@ -515,7 +512,7 @@ pub(crate) fn set_core_session_affinity_ttl( gui_config_state: tauri::State<'_, GuiConfigState>, ttl: String, ) -> Result { - let ttl = normalize_optional_config_string(ttl, "Session affinity TTL")?; + let ttl = normalize_session_affinity_ttl(ttl)?; let mut settings = current_core_config_settings(gui_config_state.inner())?; let previous_ttl = settings.routing_session_affinity_ttl.clone(); settings.routing_session_affinity_ttl = ttl; diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index a62a337..fc29759 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -155,10 +155,10 @@ pub(crate) fn is_hashed_management_secret_key(secret_key: &str) -> bool { } pub(crate) fn validate_routing_strategy(strategy: &str) -> Result<(), String> { - if matches!(strategy, "round-robin" | "fill-first") { + if matches!(strategy, "round-robin" | "weighted-round-robin" | "fill-first") { return Ok(()); } - Err("Routing strategy supports only round-robin or fill-first".to_string()) + Err("Routing strategy supports only round-robin, weighted-round-robin or fill-first".to_string()) } pub(crate) fn normalize_optional_config_string( @@ -172,6 +172,34 @@ pub(crate) fn normalize_optional_config_string( Ok(value) } +pub(crate) fn normalize_session_affinity_ttl(value: String) -> Result { + let value = normalize_optional_config_string(value, "Session affinity TTL")?; + if value.is_empty() { + return Ok(value); + } + static PART: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"([0-9]+(?:\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h)").unwrap() + }); + let text = value.strip_prefix('+').unwrap_or(&value); + let mut end = 0; + let mut nanos = 0.0_f64; + for part in PART.captures_iter(text) { + let matched = part.get(0).unwrap(); + if matched.start() != end { break; } + let multiplier = match &part[2] { + "ns" => 1.0, "us" | "µs" | "μs" => 1_000.0, "ms" => 1_000_000.0, + "s" => 1_000_000_000.0, "m" => 60_000_000_000.0, "h" => 3_600_000_000_000.0, + _ => unreachable!(), + }; + nanos += (part[1].parse::().unwrap_or(f64::INFINITY) * multiplier).trunc(); + end = matched.end(); + } + if end != text.len() || nanos <= 0.0 || nanos >= i64::MAX as f64 { + return Err("Session affinity TTL must be a positive duration such as 30m or 1h30m; leave it blank to use the core default".to_string()); + } + Ok(if nanos < 1_000_000_000.0 { "1s".to_string() } else { value }) +} + pub(crate) fn normalize_core_tls_settings( settings: CoreTlsSettings, ) -> Result { diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index 936fdf0..5859386 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -150,11 +150,9 @@ pub(crate) fn core_tls_settings_from_value( let root = document .as_mapping() .ok_or_else(|| "Core configuration must be a YAML mapping".to_string())?; - let tls = nested_yaml_value(root, &["server", "tls"]) - .or_else(|| yaml_mapping_value(root, "tls")) - .and_then(serde_norway::Value::as_mapping); - let enabled = tls - .and_then(|mapping| yaml_mapping_value(mapping, "enable")) + let field = |key: &str| nested_yaml_value(root, &["server", "tls", key]) + .or_else(|| nested_yaml_value(root, &["tls", key])); + let enabled = field("enable") .map(|value| { value .as_bool() @@ -163,7 +161,7 @@ pub(crate) fn core_tls_settings_from_value( .transpose()? .unwrap_or(false); let string_value = |key: &str| -> Result { - tls.and_then(|mapping| yaml_mapping_value(mapping, key)) + field(key) .map(|value| { value .as_str() @@ -253,17 +251,15 @@ pub(crate) fn core_sensitive_words_settings_from_value( .as_mapping() .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; let read_words = |provider: &str| -> Result, String> { - let Some(section) = nested_yaml_value(root, &["oauth", "providers", provider]) - .or_else(|| yaml_mapping_value(root, provider)) else { - return Ok(Vec::new()); - }; - if section.is_null() { - return Ok(Vec::new()); + let current = nested_yaml_value(root, &["oauth", "providers", provider]); + let legacy = yaml_mapping_value(root, provider); + for section in [current, legacy].into_iter().flatten() { + if !section.is_null() && !section.is_mapping() { + return Err(format!("{provider} must be a YAML mapping")); + } } - let section = section - .as_mapping() - .ok_or_else(|| format!("{provider} must be a YAML mapping"))?; - let Some(value) = yaml_mapping_value(section, "sensitive-words") else { + let Some(value) = nested_yaml_value(root, &["oauth", "providers", provider, "sensitive-words"]) + .or_else(|| nested_yaml_value(root, &[provider, "sensitive-words"])) else { return Ok(Vec::new()); }; if value.is_null() { @@ -340,7 +336,6 @@ pub(crate) fn patch_core_sensitive_words_yaml( prepared = replace_top_level_yaml_block(&prepared, provider, &block); } let updated = patch_core_yaml_document(&prepared, |document| { - let v8 = core_config_uses_v8(document); let mut changed = false; for (provider, words) in [ ("antigravity", &settings.antigravity_sensitive_words), @@ -349,37 +344,15 @@ pub(crate) fn patch_core_sensitive_words_yaml( let root = document .as_mapping() .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; - let section = if v8 { - nested_yaml_value(root, &["oauth", "providers", provider]) - } else { - yaml_mapping_value(root, provider) - }; - let current_words = section - .and_then(serde_norway::Value::as_mapping) - .and_then(|mapping| yaml_mapping_value(mapping, "sensitive-words")); + let current_words = nested_yaml_value(root, &["oauth", "providers", provider, "sensitive-words"]) + .or_else(|| nested_yaml_value(root, &[provider, "sensitive-words"])); if words.is_empty() && current_words.is_none_or(serde_norway::Value::is_null) { continue; } - if section.is_none_or(serde_norway::Value::is_null) { - let section_path = if v8 { - vec!["oauth", "providers", provider] - } else { - vec![provider] - }; - changed |= set_core_yaml_path_value( - document, - §ion_path, - serde_norway::Value::Mapping(serde_norway::Mapping::new()), - )?; - } - let sensitive_words_path = if v8 { - vec!["oauth", "providers", provider, "sensitive-words"] - } else { - vec![provider, "sensitive-words"] - }; - changed |= set_core_yaml_path_value( + changed |= set_core_yaml_schema_value( document, - &sensitive_words_path, + &[provider, "sensitive-words"], + &["oauth", "providers", provider, "sensitive-words"], serde_norway::Value::Sequence( words .iter() @@ -1190,20 +1163,7 @@ pub(crate) fn set_core_yaml_nested_value( key: &str, value: serde_norway::Value, ) -> Result { - let root = document - .as_mapping_mut() - .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; - let section = root - .entry(yaml_key(section)) - .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) - .as_mapping_mut() - .ok_or_else(|| "Kernel configuration section must be a YAML mapping".to_string())?; - let key = yaml_key(key); - if section.get(&key) == Some(&value) { - return Ok(false); - } - section.insert(key, value); - Ok(true) + set_core_yaml_path_value(document, &[section, key], value) } pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool { @@ -1241,9 +1201,13 @@ pub(crate) fn set_core_yaml_path_value( .as_mapping_mut() .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; for section in parents { - mapping = mapping + let parent = mapping .entry(yaml_key(section)) - .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + if parent.is_null() { + *parent = serde_norway::Value::Mapping(serde_norway::Mapping::new()); + } + mapping = parent .as_mapping_mut() .ok_or_else(|| format!("Kernel configuration section {section} must be a YAML mapping"))?; } @@ -1485,7 +1449,7 @@ pub(crate) fn core_config_settings_from_value( .ok_or_else(|| "host must be a string".to_string()) }) .transpose()? - .unwrap_or_else(|| "127.0.0.1".to_string()); + .unwrap_or_default(); let port = v8_or_legacy(&["server", "port"], &["port"]) .map(|value| { value @@ -1554,7 +1518,7 @@ pub(crate) fn core_config_settings_from_value( .ok_or_else(|| "usage-statistics-enabled must be a boolean".to_string()) }) .transpose()? - .unwrap_or(true); + .unwrap_or(false); let redis_usage_queue_retention_seconds = v8_or_legacy(&["observability", "usage", "redis-usage-queue-retention-seconds"], &["redis-usage-queue-retention-seconds"]) .map(|value| { @@ -1645,7 +1609,7 @@ pub(crate) fn core_config_settings_from_value( .ok_or_else(|| "request-retry must be a non-negative integer".to_string()) }) .transpose()? - .unwrap_or(DEFAULT_REQUEST_RETRY); + .unwrap_or(0); let max_retry_credentials = v8_or_legacy(&["routing", "retry", "max-retry-credentials"], &["max-retry-credentials"]) .map(|value| { value @@ -1663,7 +1627,7 @@ pub(crate) fn core_config_settings_from_value( .ok_or_else(|| "max-retry-interval must be a non-negative integer".to_string()) }) .transpose()? - .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL); + .unwrap_or(0); let streaming_bootstrap_retries = v8_or_legacy(&["requests", "streaming", "bootstrap-retries"], &["streaming", "bootstrap-retries"]) .map(|value| { value diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 926c3e4..93b85b8 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -435,15 +435,24 @@ pub(crate) async fn read_management_text(response: reqwest::Response) -> Result< fn format_management_error(status: u16, body: &str) -> String { if let Ok(value) = serde_json::from_str::(body) { - if let Some(message) = value - .get("error") - .and_then(|item| item.as_str()) - .or_else(|| value.get("message").and_then(|item| item.as_str())) - { - let message = message.trim(); - if !message.is_empty() { - return format!("Management API error ({status}): {message}"); - } + let field = |key| { + value.get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }; + let error = field("error"); + let message = field("message"); + let detail = match (error, message) { + (Some(error), Some(message)) if error != message => Some(format!("{error}: {message}")), + (Some(text), _) | (_, Some(text)) => Some(text.to_string()), + _ => None, + }; + if let Some(detail) = detail { + return format!( + "Management API error ({status}): {}", + truncate_for_error(&detail) + ); } } let body = body.trim(); @@ -459,6 +468,32 @@ mod tests { use super::*; use std::path::PathBuf; + #[test] + fn management_errors_include_v8_validation_details() { + assert_eq!( + format_management_error(400, r#"{"error":"invalid_config","message":"field name not found in type config.CodexKey"}"#), + "Management API error (400): invalid_config: field name not found in type config.CodexKey" + ); + for body in [ + r#"{"error":"not_found"}"#, + r#"{"error":"not_found","message":"not_found"}"#, + ] { + assert_eq!( + format_management_error(404, body), + "Management API error (404): not_found" + ); + } + assert_eq!( + format_management_error(400, r#"{"error":" ","message":"validation failed"}"#), + "Management API error (400): validation failed" + ); + assert_eq!( + format_management_error(502, "gateway unavailable"), + "Management API error (502): gateway unavailable" + ); + assert_eq!(format_management_error(500, ""), "Management API error (500)"); + } + #[test] fn devin_oauth_uses_the_management_callback_flow() { for provider in ["Devin", " cognition "] { diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index 018d2cb..73a6bff 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -13,6 +13,7 @@ mod app_settings; mod app_update; mod core_config; mod core_runtime; +mod core_v8_contract; mod desktop_alias_routing; #[cfg(windows)] mod file_replace; diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index ea5e2c2..07de110 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -37,6 +37,23 @@ fn non_v8_management_yaml_is_returned_without_reformatting() { assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input); } +#[test] +fn v8_provider_names_belong_to_groups_and_preserve_legacy_view_identity() { + let groups = serde_norway::from_str( + "- name: codex-1\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: my-deepseek}]\n keys:\n - {name: DeepSeek, api-key: first, weight: 2}\n - {api-key: second, priority: 0}\n", + ) + .unwrap(); + let records = flatten_v8_provider_groups("codex", &groups).unwrap(); + let updated = group_legacy_provider_records("codex", &records).unwrap(); + let json = serde_json::to_value(&updated).unwrap(); + assert_eq!(json[0]["name"], "DeepSeek"); + assert_eq!(json[0]["keys"][0]["weight"], 2); + assert!(json[0]["keys"][0].get("name").is_none()); + assert_eq!(json[1]["name"], "codex-2"); + assert!(json[1]["keys"][0].get("name").is_none()); + assert_eq!(flatten_v8_provider_groups("codex", &updated).unwrap(), records); +} + #[test] fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() { let project = |input| yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap()); @@ -49,6 +66,30 @@ fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() { assert_eq!(view["requests"]["proxy-url"], "direct"); } +#[test] +fn v8_provider_fields_use_yaml_names_without_losing_explicit_false_or_extensions() { + for provider in ["codex", "claude", "gemini", "openai-compatibility"] { + let records = serde_norway::from_str( + "- name: provider\n api-key: test-key\n test-model: old\n testModel: old\n disable-cooling: false\n request-retry: 0\n models:\n - name: model\n max-context-length: 65536\n thinking: {zero_allowed: true, zero-allowed: false, dynamic_allowed: true, levels: [low, high]}\n", + ).unwrap(); + let groups = group_legacy_provider_records(provider, &records).unwrap(); + let json = serde_json::to_value(&groups).unwrap(); + assert_eq!(json[0]["disable-cooling"], false); + assert_eq!(json[0]["request-retry"], 0); + assert_eq!(json[0]["models"][0]["max-context-length"], 65536); + assert_eq!(json[0]["models"][0]["thinking"]["zero-allowed"], false); + assert_eq!(json[0]["models"][0]["thinking"]["dynamic-allowed"], true); + assert!(json[0]["models"][0]["thinking"].get("zero_allowed").is_none()); + assert!(json[0]["models"][0]["thinking"].get("dynamic_allowed").is_none()); + if provider == "openai-compatibility" { + assert!(json[0].get("test-model").is_none()); + assert!(json[0].get("testModel").is_none()); + } + let view = flatten_v8_provider_groups(provider, &groups).unwrap(); + assert_eq!(group_legacy_provider_records(provider, &view).unwrap(), groups); + } +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_creates_first_oauth_and_payload_nodes() { let core = MockCore::new_v8("config-version: 8\n", Failure::None); @@ -255,6 +296,13 @@ impl MockCore { .unwrap(); let groups: serde_norway::Value = serde_json::from_slice(request_body).unwrap(); + if provider != "openai-compatibility" { + for group in groups.as_sequence().unwrap() { + for key in group["keys"].as_sequence().unwrap() { + assert!(key.get("name").is_none(), "v8 rejects credential names"); + } + } + } persisted[legacy] = serde_json::to_value( flatten_v8_provider_groups(provider, &groups).unwrap(), ) @@ -391,6 +439,21 @@ async fn provider_alias_edits_round_trip_through_v8_key_groups() { .any(|request| request == "PUT /v8/management/config/api-keys/codex")); } +#[tokio::test(flavor = "current_thread")] +async fn deepseek_alias_edits_keep_the_provider_name_out_of_v8_credentials() { + let initial = "codex-api-key:\n - name: DeepSeek\n api-key: preserved-secret\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: original}]\n"; + let core = MockCore::new_v8(initial, Failure::None); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = current.replace("alias: original", "alias: renamed"); + put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap(); + let (persisted, _) = core.finish(); + let mut expected = yaml_json(initial); + expected["codex-api-key"][0]["models"][0]["alias"] = serde_json::json!("renamed"); + assert_eq!(persisted, expected); +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_recovers_failures_before_and_after_writes() { for failure in [ diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 7396eef..6eb0f84 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1435,7 +1435,7 @@ fn core_config_reads_logging_settings_and_applies_defaults() { DEFAULT_LOGS_MAX_TOTAL_SIZE_MB ); assert_eq!(defaults.error_logs_max_files, DEFAULT_ERROR_LOGS_MAX_FILES); - assert!(defaults.usage_statistics_enabled); + assert!(!defaults.usage_statistics_enabled); assert_eq!( defaults.redis_usage_queue_retention_seconds, DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS @@ -1489,6 +1489,65 @@ fn v8_core_config_reads_and_writes_canonical_nested_fields() { } } +#[test] +fn v8_omitted_fields_use_runtime_defaults_instead_of_gui_presets() { + let settings = core_config_settings_from_value( + &serde_norway::from_str("config-version: 8\nserver: {port: 8317}\n").unwrap(), + ).unwrap(); + assert_eq!(settings.host, ""); + assert!(!settings.usage_statistics_enabled); + assert_eq!(settings.request_retry, 0); + assert_eq!(settings.max_retry_interval, 0); +} + +#[test] +fn v8_settings_can_populate_null_sections_without_losing_siblings() { + let input = "config-version: 8\nserver: null\nmanagement: null\naccess: null\noauth: null\nrequests: null\nrouting: null\nobservability: null\nplugins: null\napi-keys: {codex: [{name: keep, keys: [{api-key: test-key}]}]}\n"; + let updated = apply_gui_managed_settings(input, &GuiConfigFile::default()).unwrap(); + let document: serde_norway::Value = serde_norway::from_str(&updated).unwrap(); + assert_eq!(document["server"]["port"], 8317); + assert_eq!(document["api-keys"]["codex"][0]["name"], "keep"); + assert!(apply_gui_managed_settings("config-version: 8\nrequests: broken\n", &GuiConfigFile::default()).is_err()); +} + +#[test] +fn v8_sensitive_words_fall_back_per_field_and_clear_legacy_values() { + let input = "config-version: 8\nantigravity: {sensitive-words: [old]}\ndevin: {sensitive-words: [old-devin]}\noauth:\n providers:\n antigravity: {signature-cache-enabled: true}\n devin: {sensitive-words: [new-devin]}\n"; + let document: serde_norway::Value = serde_norway::from_str(input).unwrap(); + let settings = core_sensitive_words_settings_from_value(&document).unwrap(); + assert_eq!(settings.antigravity_sensitive_words, vec!["old"]); + assert_eq!(settings.devin_sensitive_words, vec!["new-devin"]); + let updated = patch_core_sensitive_words_yaml(input, &CoreSensitiveWordsSettings::default()).unwrap().unwrap(); + let document: serde_norway::Value = serde_norway::from_str(&updated).unwrap(); + for path in [vec!["antigravity"], vec!["devin"], vec!["oauth", "providers", "antigravity"], vec!["oauth", "providers", "devin"]] { + let section = nested_yaml_value(document.as_mapping().unwrap(), &path).unwrap(); + assert_eq!(section["sensitive-words"].as_sequence().unwrap().len(), 0); + } + assert_eq!(document["oauth"]["providers"]["antigravity"]["signature-cache-enabled"], true); +} + +#[test] +fn v8_tls_reads_mixed_layout_per_field() { + let document = serde_norway::from_str("config-version: 8\ntls: {enable: true, cert: old-cert, key: old-key}\nserver: {tls: {cert: new-cert}}\n").unwrap(); + let settings = core_tls_settings_from_value(&document).unwrap(); + assert!(settings.enabled); + assert_eq!(settings.cert, "new-cert"); + assert_eq!(settings.key, "old-key"); +} + +#[test] +fn session_ttl_rejects_values_that_the_core_silently_ignores() { + for value in ["", "30m", "1h30m", "+2h", "1.5s", ".5h", "1.s"] { + assert_eq!(normalize_session_affinity_ttl(format!(" {value} ")).unwrap(), value); + } + for value in ["500ms", "1ns", "2us", "2µs", "2μs"] { + assert_eq!(normalize_session_affinity_ttl(value.into()).unwrap(), "1s"); + } + for value in ["0", "0s", "-1h", "1d", "30", "1h 30m", "1s junk", "+", "NaNh", "0.1ns", "999999999999h"] { + assert!(normalize_session_affinity_ttl(value.into()).is_err(), "accepted {value}"); + } +} + #[test] fn v8_startup_without_version_marker_preserves_upstream_credentials() { let input = "server: {port: 9527}\noauth: {auth-dir: ../oauth}\naccess: {api-keys: [old-client-key]}\napi-keys:\n codex:\n - name: custom-provider\n base-url: https://example.invalid/v1\n keys: [{api-key: upstream-secret}]\n"; @@ -1550,12 +1609,12 @@ fn core_config_reads_retry_fields_and_uses_core_defaults() { let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap(); assert_eq!(defaults.disable_cooling, DEFAULT_DISABLE_COOLING); - assert_eq!(defaults.request_retry, DEFAULT_REQUEST_RETRY); + assert_eq!(defaults.request_retry, 0); assert_eq!( defaults.max_retry_credentials, DEFAULT_MAX_RETRY_CREDENTIALS ); - assert_eq!(defaults.max_retry_interval, DEFAULT_MAX_RETRY_INTERVAL); + assert_eq!(defaults.max_retry_interval, 0); assert_eq!( defaults.streaming_bootstrap_retries, DEFAULT_STREAMING_BOOTSTRAP_RETRIES @@ -1793,6 +1852,7 @@ fn core_config_validates_keys_and_routing_strategy() { assert!(validate_core_api_key("").is_err()); assert!(validate_core_api_key("contains space").is_err()); assert!(validate_routing_strategy("round-robin").is_ok()); + assert!(validate_routing_strategy("weighted-round-robin").is_ok()); assert!(validate_routing_strategy("fill-first").is_ok()); assert!(validate_routing_strategy("random").is_err()); } diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs new file mode 100644 index 0000000..201804e --- /dev/null +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -0,0 +1,278 @@ +use super::support::agent_test_home; +use super::*; +use std::process::{Child, Stdio}; + +struct TestCore { + child: Option, + directory: PathBuf, + config: PathBuf, + executable: PathBuf, + origin: String, + client: reqwest::Client, +} + +impl Drop for TestCore { + fn drop(&mut self) { + self.stop(); + if self.directory.parent() == Some(std::env::temp_dir().as_path()) + && self + .directory + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("cpa-gui-agent-v8-contract-") + { + let _ = fs::remove_dir_all(&self.directory); + } + } +} + +impl TestCore { + fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } + + async fn start(&mut self) { + let mut command = Command::new(&self.executable); + command + .args(["-config"]) + .arg(&self.config) + .arg("-local-model") + .current_dir(&self.directory) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_command(&mut command); + self.child = Some(command.spawn().unwrap()); + for _ in 0..100 { + assert!( + self.child.as_mut().unwrap().try_wait().unwrap().is_none(), + "test kernel exited during startup" + ); + if self + .client + .get(format!("{}/v8/management/config", self.origin)) + .bearer_auth("isolated-test-secret") + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("test kernel startup timed out"); + } + + async fn config_view(&self) -> serde_json::Value { + self.client + .get(format!("{}/v8/management/config", self.origin)) + .bearer_auth("isolated-test-secret") + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap() + } + + async fn validate_save(&self, yaml: &str) -> serde_json::Value { + let response = self + .client + .put(format!("{}/v8/management/config.yaml", self.origin)) + .bearer_auth("isolated-test-secret") + .header("Content-Type", "application/yaml") + .body(yaml.to_string()) + .send() + .await + .unwrap(); + let status = response.status(); + let body = response.text().await.unwrap(); + assert!( + status.is_success(), + "v8 rejected generated settings: {status} {body}" + ); + self.config_view().await + } + + async fn wait_for_client_key(&self, key: &str, expected: u16) { + for _ in 0..100 { + let status = self + .client + .get(format!("{}/v1/models", self.origin)) + .bearer_auth(key) + .send() + .await + .unwrap() + .status() + .as_u16(); + if status == expected { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("client key did not reach HTTP {expected}"); + } +} + +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v8_accepts_gui_settings_and_reloads_client_keys() { + let executable = + fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")) + .unwrap(); + let directory = agent_test_home("v8-contract"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut core = TestCore { + child: None, + config: directory.join("config.yaml"), + directory, + executable, + origin: format!("http://127.0.0.1:{port}"), + client: reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(2)) + .build() + .unwrap(), + }; + let mut gui = GuiConfigFile { + host: "127.0.0.1".into(), + port, + auth_dir: path_to_string(&core.directory.join("auth")), + management_secret_key: "isolated-test-secret".into(), + api_keys: vec![GuiApiKeyEntry { + key: "client-one".into(), + remark: String::new(), + }], + proxy_url: "direct".into(), + routing_strategy: "weighted-round-robin".into(), + routing_session_affinity: true, + routing_session_affinity_ttl: "2h30m".into(), + request_retry: 2, + max_retry_credentials: 3, + max_retry_interval: 5, + streaming_bootstrap_retries: 2, + disable_cooling: true, + debug: true, + commercial_mode: true, + logging_to_file: true, + logs_max_total_size_mb: 5, + error_logs_max_files: 4, + usage_statistics_enabled: true, + redis_usage_queue_retention_seconds: 90, + request_log: true, + ..GuiConfigFile::default() + }; + let initial = "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\nrequests: null\nobservability: null\noauth: {providers: {codex: {header-defaults: {user-agent: preserve-test}}}}\napi-keys: {openai-compatibility: [{name: preserved, base-url: 'http://127.0.0.1:1/v1', models: [{name: test-model}], keys: [{api-key: upstream-test}]}]}\n"; + let content = apply_gui_managed_settings(initial, &gui).unwrap(); + let content = patch_core_sensitive_words_yaml( + &content, + &CoreSensitiveWordsSettings { + antigravity_sensitive_words: vec!["sample".into()], + devin_sensitive_words: vec!["sample-devin".into()], + }, + ) + .unwrap() + .unwrap(); + fs::write(&core.config, &content).unwrap(); + core.start().await; + let view = core.validate_save(&content).await; + assert_eq!(view["routing"]["strategy"], "weighted-round-robin"); + assert_eq!(view["routing"]["retry"]["request-retry"], 2); + assert_eq!(view["routing"]["retry"]["max-retry-credentials"], 3); + assert_eq!(view["routing"]["retry"]["max-retry-interval"], 5); + assert_eq!(view["requests"]["streaming"]["bootstrap-retries"], 2); + assert_eq!(view["routing"]["cooldown"]["disable-cooling"], true); + assert_eq!( + view["observability"]["usage"]["redis-usage-queue-retention-seconds"], + 90 + ); + assert_eq!( + view["oauth"]["providers"]["devin"]["sensitive-words"][0], + "sample-devin" + ); + + gui.request_retry = 0; + gui.max_retry_credentials = 0; + gui.max_retry_interval = 0; + gui.streaming_bootstrap_retries = 0; + gui.disable_cooling = false; + gui.routing_session_affinity = false; + gui.routing_session_affinity_ttl.clear(); + gui.proxy_url.clear(); + let current = fs::read_to_string(&core.config).unwrap(); + let content = patch_core_retry_yaml(¤t, &gui).unwrap().unwrap(); + let content = patch_core_session_routing_yaml(&content, &gui) + .unwrap() + .unwrap(); + let content = patch_core_network_endpoint_yaml(&content, &gui) + .unwrap() + .unwrap(); + let mut logging = + core_config_settings_from_value(&serde_norway::from_str(&content).unwrap()).unwrap(); + logging.debug = false; + logging.commercial_mode = false; + logging.logging_to_file = false; + logging.logs_max_total_size_mb = 0; + logging.error_logs_max_files = 0; + logging.usage_statistics_enabled = false; + logging.redis_usage_queue_retention_seconds = 1; + let content = + patch_core_yaml_document(&content, |doc| apply_core_logging_settings(doc, &logging)) + .unwrap() + .unwrap(); + let content = patch_core_sensitive_words_yaml(&content, &CoreSensitiveWordsSettings::default()) + .unwrap() + .unwrap(); + let content = patch_core_tls_settings_yaml( + &content, + &CoreTlsSettings { + enabled: false, + cert: "test-cert.pem".into(), + key: "test-key.pem".into(), + }, + ) + .unwrap() + .unwrap(); + let view = core.validate_save(&content).await; + assert_eq!(view["routing"]["retry"]["request-retry"], 0); + assert_eq!(view["routing"]["session-affinity"], false); + assert_eq!(view["routing"]["session-affinity-ttl"], ""); + assert_eq!(view["routing"]["cooldown"]["disable-cooling"], false); + assert_eq!( + view["observability"]["usage"]["usage-statistics-enabled"], + false + ); + assert_eq!(view["observability"]["logs"]["logging-to-file"], false); + assert_eq!(view["observability"]["logs"]["request-log"], true); + assert_eq!(view["server"]["tls"]["cert"], "test-cert.pem"); + assert_eq!(view["server"]["tls"]["enable"], false); + assert_eq!( + view["api-keys"]["openai-compatibility"][0]["name"], + "preserved" + ); + assert_eq!( + view["oauth"]["providers"]["codex"]["header-defaults"]["user-agent"], + "preserve-test" + ); + assert_eq!( + view["oauth"]["providers"]["antigravity"]["sensitive-words"], + serde_json::json!([]) + ); + let current = fs::read_to_string(&core.config).unwrap(); + let content = patch_core_api_keys_yaml(¤t, &["client-two".into()]).unwrap(); + write_yaml_if_changed(&core.config, &content).unwrap(); + core.wait_for_client_key("client-two", 200).await; + core.wait_for_client_key("client-one", 401).await; + core.stop(); + core.start().await; + core.wait_for_client_key("client-two", 200).await; + core.wait_for_client_key("client-one", 401).await; +} diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 4d02500..fd90257 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -683,6 +683,7 @@ export const jaOverrides = { 'aliases.unboundEffort': 'レベル未設定', 'aliases.delete': '{alias} を削除', 'config.routing.roundRobin': 'ラウンドロビン', + 'config.routing.weightedRoundRobin': '重み付きラウンドロビン', 'config.routing.fillFirst': '優先充填', 'config.tabs.label': '詳細設定ページ', 'config.tabs.general': '基本設定', @@ -1387,12 +1388,12 @@ export const jaOverrides = { 'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用', 'apiAccess.thinking.title': '推論レベル', - 'apiAccess.thinking.description': '上流の対応状況に合わせて追加してください', + 'apiAccess.thinking.description': '変更はこの接続の全モデルに適用されます。未変更なら各モデルの設定を保持します', 'apiAccess.thinking.placeholder': '例:low、medium、またはカスタムレベル', 'apiAccess.thinking.add': '追加', 'apiAccess.thinking.delete': '{level} を削除', 'apiAccess.thinking.deleteAria': '推論レベル {level} を削除', - 'apiAccess.thinking.empty': '未追加の場合 thinking.levels は書き込まれません', + 'apiAccess.thinking.empty': 'レベルを編集するまでは、各モデルの推論設定を保持します', 'apiAccess.models.title': 'モデル', 'apiAccess.models.description': '選択したモデルを公開します。選択解除するとモデル一覧から非表示になります', 'apiAccess.models.fetch': 'モデルを取得', @@ -1407,7 +1408,10 @@ export const jaOverrides = { 'apiAccess.prefixPlaceholder': '任意、例:team-a', 'apiAccess.field.headers': 'カスタムリクエストヘッダー(1 行に Name: Value)', 'apiAccess.field.excludedModels': '除外モデル(1 行に 1 件、ワイルドカード対応)', - 'apiAccess.field.testModel': 'テストモデル', + 'apiAccess.option.inherit': 'デフォルトを使用', + 'apiAccess.error.priorityInteger': '優先度は安全な範囲の整数で指定してください。', + 'apiAccess.cooling.disable': 'クールダウンを無効化', + 'apiAccess.cooling.enable': 'クールダウンを有効化', 'apiAccess.cloak.mode': 'Claude 偽装モード', 'apiAccess.cloak.default': '既定(自動)', 'apiAccess.cloak.words': '偽装する機密語(1 行に 1 件)', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 75b7baa..8298d7d 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -680,6 +680,7 @@ export const en: Record = { 'speedAliases.fast.title': 'Fast', 'speedAliases.aliasName.example': 'For example: {model}-fast', 'config.routing.roundRobin': 'Round Robin', + 'config.routing.weightedRoundRobin': 'Weighted Round Robin', 'config.routing.fillFirst': 'Fill First', 'config.tabs.label': 'Advanced features sections', 'config.tabs.general': 'General Settings', @@ -1388,12 +1389,12 @@ export const en: Record = { 'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com', 'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address', 'apiAccess.thinking.title': 'Reasoning Levels', - 'apiAccess.thinking.description': 'Add levels supported by the upstream provider', + 'apiAccess.thinking.description': 'Edits apply to all models in this connection; otherwise each model keeps its settings', 'apiAccess.thinking.placeholder': 'For example: low, medium, or a custom level', 'apiAccess.thinking.add': 'Add', 'apiAccess.thinking.delete': 'Delete {level}', 'apiAccess.thinking.deleteAria': 'Delete reasoning level {level}', - 'apiAccess.thinking.empty': 'thinking.levels is omitted when no levels are added', + 'apiAccess.thinking.empty': 'Each model keeps its reasoning settings until you edit the levels', 'apiAccess.models.title': 'Models', 'apiAccess.models.description': 'Checked models are open; uncheck a model to hide it from the list', 'apiAccess.models.fetch': 'Fetch Models', @@ -1407,7 +1408,10 @@ export const en: Record = { 'apiAccess.prefixPlaceholder': 'Optional, for example team-a', 'apiAccess.field.headers': 'Custom Headers (one Name: Value per line)', 'apiAccess.field.excludedModels': 'Excluded Models (one per line; wildcards supported)', - 'apiAccess.field.testModel': 'Test Model', + 'apiAccess.option.inherit': 'Use default', + 'apiAccess.error.priorityInteger': 'Priority must be a safe integer.', + 'apiAccess.cooling.disable': 'Disable cooldown', + 'apiAccess.cooling.enable': 'Enable cooldown', 'apiAccess.cloak.mode': 'Claude Cloaking Mode', 'apiAccess.cloak.default': 'Default (auto)', 'apiAccess.cloak.words': 'Cloaking Keywords (one per line)', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 444c5ba..f9948ff 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -679,6 +679,7 @@ export const zhCN = { 'speedAliases.fast.title': 'Fast', 'speedAliases.aliasName.example': '例如 {model}-fast', 'config.routing.roundRobin': '轮询', + 'config.routing.weightedRoundRobin': '加权轮询', 'config.routing.fillFirst': '优先填充', 'config.tabs.label': '高级功能子页面', 'config.tabs.general': '基础设置', @@ -1387,12 +1388,12 @@ export const zhCN = { 'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com', 'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址', 'apiAccess.thinking.title': '思考等级', - 'apiAccess.thinking.description': '按上游支持情况自行添加', + 'apiAccess.thinking.description': '修改后应用于此接入的所有模型;未修改时保留各模型配置', 'apiAccess.thinking.placeholder': '例如 low、medium 或自定义等级', 'apiAccess.thinking.add': '添加', 'apiAccess.thinking.delete': '删除 {level}', 'apiAccess.thinking.deleteAria': '删除思考等级 {level}', - 'apiAccess.thinking.empty': '未添加时不写入 thinking.levels', + 'apiAccess.thinking.empty': '未手动修改时保留模型各自的思考能力设置', 'apiAccess.models.title': '模型', 'apiAccess.models.description': '勾选表示当前开放,取消勾选后将从模型列表隐藏', 'apiAccess.models.fetch': '获取模型', @@ -1406,7 +1407,10 @@ export const zhCN = { 'apiAccess.prefixPlaceholder': '可选,例如 team-a', 'apiAccess.field.headers': '自定义请求头(每行 Name: Value)', 'apiAccess.field.excludedModels': '排除模型(每行一个,支持通配符)', - 'apiAccess.field.testModel': '测试模型', + 'apiAccess.option.inherit': '使用默认值', + 'apiAccess.error.priorityInteger': '优先级必须是安全范围内的整数。', + 'apiAccess.cooling.disable': '禁用冷却', + 'apiAccess.cooling.enable': '启用冷却', 'apiAccess.cloak.mode': 'Claude 伪装模式', 'apiAccess.cloak.default': '默认(自动)', 'apiAccess.cloak.words': '伪装敏感词(每行一个)', diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 9dd8364..55cb6c1 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -1,4 +1,5 @@ import { useConfirmation } from '../components/ConfirmationDialog'; +import { normalizeProviderModels } from '../services/providerModels'; import { ModelSelectionPanel } from '../components/ModelSelectionPanel'; import { useDialogFocusTrap } from '../components/useDialogFocusTrap'; import { @@ -219,15 +220,15 @@ export type ProviderDraft = { prefix?: string; headersText?: string; excludedModelsText?: string; - disableCooling?: boolean; + disableCooling?: boolean | null; websockets?: boolean; - testModel?: string; thinkingLevels?: string[]; + thinkingLevelsEdited?: boolean; disabled?: boolean; cloakMode?: string; cloakStrictMode?: boolean; cloakSensitiveWordsText?: string; - cloakCacheUserId?: boolean; + cloakCacheUserId?: boolean | null; }; const providerDefinitions: ProviderDefinition[] = [ @@ -362,7 +363,6 @@ const providerHealthIdentity = (row: ProviderRow) => [ row.name, row.baseUrl, row.authIndex, - readString(row.record, 'test-model', 'testModel'), row.apiKeys.join('\u0000'), row.models.map((model) => model.name).join('\u0000'), ].join('\u0001'); @@ -384,7 +384,9 @@ const providerHeadersFromRecord = (record: Record) => : {}; export const stripResponseFields = (record: Record) => { - const next = { ...record }; + const next = normalizeProviderModels(record); + delete next['test-model']; + delete next.testModel; delete next['auth-index']; delete next.authIndex; delete next.auth_index; @@ -445,7 +447,9 @@ export const hasDuplicateProviderRecord = ( return readString(record, 'api-key', 'apiKey') === readString(candidate, 'api-key', 'apiKey') && readString(record, 'base-url', 'baseUrl') === readString(candidate, 'base-url', 'baseUrl'); } - return providerConfigIdentity(record) === providerConfigIdentity(candidate); + const { name: _recordName, ...recordConfig } = record; + const { name: _candidateName, ...candidateConfig } = candidate; + return providerConfigIdentity(recordConfig) === providerConfigIdentity(candidateConfig); })); const mergeModelRecords = (current: unknown, selected: ModelOption[]) => { @@ -644,12 +648,12 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => { excludedModelsText: Array.isArray(row.record['excluded-models']) ? row.record['excluded-models'].map(String).filter((model) => model.trim() !== '*').join('\n') : '', - disableCooling: readBoolean(row.record, 'disable-cooling', 'disableCooling'), + disableCooling: typeof row.record['disable-cooling'] === 'boolean' ? row.record['disable-cooling'] : null, websockets: readBoolean(row.record, 'websockets'), - testModel: readString(row.record, 'test-model', 'testModel'), thinkingLevels: definition.openAi ? thinkingLevelsFromModels(row.models) : undefined, + thinkingLevelsEdited: false, disabled: row.disabled, cloakMode: isRecord(row.record.cloak) ? readString(row.record.cloak, 'mode') : '', cloakStrictMode: isRecord(row.record.cloak) @@ -659,9 +663,8 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => { isRecord(row.record.cloak) && Array.isArray(row.record.cloak['sensitive-words']) ? row.record.cloak['sensitive-words'].map(String).join('\n') : '', - cloakCacheUserId: isRecord(row.record.cloak) - ? readBoolean(row.record.cloak, 'cache-user-id', 'cacheUserId') - : false, + cloakCacheUserId: isRecord(row.record.cloak) && typeof row.record.cloak['cache-user-id'] === 'boolean' + ? row.record.cloak['cache-user-id'] : null, }; }; @@ -676,19 +679,18 @@ const emptyProviderDraft = (): ProviderDraft => ({ prefix: '', headersText: '', excludedModelsText: '', - disableCooling: false, + disableCooling: null, websockets: false, - testModel: '', disabled: false, cloakMode: '', cloakStrictMode: false, cloakSensitiveWordsText: '', - cloakCacheUserId: false, + cloakCacheUserId: null, }); export const createProviderDraft = (category: ProviderCategory): ProviderDraft => { const draft = emptyProviderDraft(); - if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [] }; + if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [], thinkingLevelsEdited: false }; if (category !== 'deepseek') return draft; return { ...draft, @@ -711,7 +713,7 @@ export const applyProviderPreset = ( category: ProviderCategory, draft: ProviderDraft, ): ProviderDraft => { - if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft; + if (!definitionFor(category).openAi || draft.thinkingLevels === undefined || draft.thinkingLevelsEdited === false) return draft; const levels = draft.thinkingLevels; return { ...draft, @@ -785,17 +787,12 @@ const applyAdvancedFields = ( else delete next['excluded-models']; } if (draft.disableCooling !== undefined) { - if (draft.disableCooling) next['disable-cooling'] = true; - else delete next['disable-cooling']; + if (draft.disableCooling === null) delete next['disable-cooling']; + else next['disable-cooling'] = draft.disableCooling; } if (draft.websockets !== undefined && section === 'codex-api-key') { next.websockets = draft.websockets; } - if (draft.testModel !== undefined && section === 'openai-compatibility') { - const testModel = draft.testModel.trim(); - if (testModel) next['test-model'] = testModel; - else delete next['test-model']; - } if ( section === 'claude-api-key' && ( @@ -806,22 +803,29 @@ const applyAdvancedFields = ( ) ) { const cloak: Record = isRecord(next.cloak) ? { ...next.cloak } : {}; - const mode = draft.cloakMode?.trim(); - if (mode) cloak.mode = mode; - else delete cloak.mode; - delete cloak.strictMode; - if (draft.cloakStrictMode) cloak['strict-mode'] = true; - else delete cloak['strict-mode']; - const sensitiveWords = (draft.cloakSensitiveWordsText ?? '') - .split(/[,\n]/) - .map((value) => value.trim()) - .filter((value, index, values) => value && values.indexOf(value) === index); - if (sensitiveWords.length > 0) cloak['sensitive-words'] = sensitiveWords; - else delete cloak['sensitive-words']; - delete cloak.sensitiveWords; - delete cloak.cacheUserId; - if (draft.cloakCacheUserId) cloak['cache-user-id'] = true; - else delete cloak['cache-user-id']; + if (draft.cloakMode !== undefined) { + const mode = draft.cloakMode.trim(); + if (mode) cloak.mode = mode; + else delete cloak.mode; + } + if (draft.cloakStrictMode !== undefined) { + delete cloak.strictMode; + if (draft.cloakStrictMode) cloak['strict-mode'] = true; + else delete cloak['strict-mode']; + } + if (draft.cloakSensitiveWordsText !== undefined) { + const sensitiveWords = draft.cloakSensitiveWordsText.split(/[,\n]/) + .map((value) => value.trim()) + .filter((value, index, values) => value && values.indexOf(value) === index); + delete cloak.sensitiveWords; + if (sensitiveWords.length > 0) cloak['sensitive-words'] = sensitiveWords; + else delete cloak['sensitive-words']; + } + if (draft.cloakCacheUserId !== undefined) { + delete cloak.cacheUserId; + if (draft.cloakCacheUserId === null) delete cloak['cache-user-id']; + else cloak['cache-user-id'] = draft.cloakCacheUserId; + } if (Object.keys(cloak).length > 0) next.cloak = cloak; else delete next.cloak; } @@ -836,8 +840,20 @@ export const buildProviderRecord = ( const record = current ? stripResponseFields(current) : {}; const priorityText = draft.priority.trim(); const priority = priorityText ? Number(priorityText) : null; + if (priority !== null && !Number.isSafeInteger(priority)) { + throw new Error(translate(getCurrentLocale(), 'apiAccess.error.priorityInteger')); + } const models = mergeModelRecords(record.models, draft.models); if (definitionFor(section).openAi) { + if (draft.thinkingLevels !== undefined && draft.thinkingLevelsEdited !== false) { + for (const model of models) { + const thinking = isRecord(model.thinking) ? { ...model.thinking } : {}; + if (draft.thinkingLevels.length) thinking.levels = [...draft.thinkingLevels]; + else delete thinking.levels; + if (Object.keys(thinking).length) model.thinking = thinking; + else delete model.thinking; + } + } const entries = mergeOpenAiApiKeyEntries(record['api-key-entries'], draft.apiKey.trim()); if (draft.proxyUrlEdited !== false && draft.proxyUrl !== undefined) { const proxyUrl = draft.proxyUrl.trim(); @@ -1842,7 +1858,7 @@ export function ApiProviderDialog({ ), [modelOptions, selectedModelNames]); const updateTextField = ( - field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setFormError(''); @@ -1861,13 +1877,18 @@ export function ApiProviderDialog({ }; const updateBooleanField = ( - field: 'disableCooling' | 'websockets' | 'cloakStrictMode' | 'cloakCacheUserId', + field: 'websockets' | 'cloakStrictMode', value: boolean, ) => { setFormError(''); setDraft((current) => ({ ...current, [field]: value })); }; + const updateOptionalBooleanField = (field: 'disableCooling' | 'cloakCacheUserId', value: string) => { + setFormError(''); + setDraft((current) => ({ ...current, [field]: value === '' ? null : value === 'true' })); + }; + const updateModels = (update: (models: ModelOption[]) => ModelOption[]) => { setDraft((current) => { const models = update(current.models); @@ -1904,6 +1925,7 @@ export function ApiProviderDialog({ return { ...current, thinkingLevels: [...levels, level], + thinkingLevelsEdited: true, }; }); setThinkingLevelInput(''); @@ -1913,6 +1935,7 @@ export function ApiProviderDialog({ setDraft((current) => ({ ...current, thinkingLevels: (current.thinkingLevels ?? []).filter((item) => item !== level), + thinkingLevelsEdited: true, })); }; @@ -2159,7 +2182,7 @@ export function ApiProviderDialog({ {modelError && !modelDiscoveryOpen ? setModelError('')} /> : null} - +
{t('apiAccess.advanced')}
@@ -2174,9 +2197,6 @@ export function ApiProviderDialog({