diff --git a/core-version.txt b/core-version.txt index 9a62cf1..ae9a76b 100644 --- a/core-version.txt +++ b/core-version.txt @@ -1 +1 @@ -7.3.17 +8.0.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 89638c4..932cbf5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -622,7 +622,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.2.97" +version = "0.3.6" dependencies = [ "base64 0.22.1", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 236ba6b..d8e8b4e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cpa-gui" # Single source of truth for the desktop app version. -version = "0.2.97" +version = "0.3.6" description = "CPA desktop management shell" authors = ["lzt404"] edition = "2021" diff --git a/src-tauri/src/core_config/alias_save.rs b/src-tauri/src/core_config/alias_save.rs index 864f571..1799b80 100644 --- a/src-tauri/src/core_config/alias_save.rs +++ b/src-tauri/src/core_config/alias_save.rs @@ -5,6 +5,40 @@ fn alias_config_is_unchanged(current: &str, latest: &str) -> Result Result<(), String> { + let parse = |content: &str| { + serde_norway::from_str::(content) + .map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))? + .as_mapping() + .cloned() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string()) + }; + let mut current = parse(current)?; + let mut updated = parse(updated)?; + let mut latest = parse(latest)?; + for key in ["oauth-model-alias", "payload"] + .into_iter() + .chain(V8_PROVIDER_FAMILIES.iter().map(|(legacy, _)| *legacy)) + { + let current_value = current.remove(yaml_key(key)); + let updated_value = updated.remove(yaml_key(key)); + let latest_value = latest.remove(yaml_key(key)); + if latest_value != current_value && latest_value != updated_value { + return Err( + "Other configuration changes were detected and were not overwritten".to_string(), + ); + } + } + if current != updated || latest != current { + return Err("Other configuration changes were detected and were not overwritten".to_string()); + } + Ok(()) +} + fn validate_alias_api_access_preserved(current: &str, updated: &str) -> Result<(), String> { let parse = |content: &str| { serde_norway::from_str::(content) @@ -97,21 +131,15 @@ async fn restore_management_alias_config( updated: &str, ) -> Result<(), String> { let latest = fetch_management_config_yaml(config).await?; + validate_alias_transaction_state(current, updated, &latest)?; let from_current = management_alias_config_changes(current, &latest)?; - let from_updated = management_alias_config_changes(updated, &latest)?; - if (from_current.update_config_yaml && from_updated.update_config_yaml) - || (from_current.oauth_model_aliases.is_some() - && from_updated.oauth_model_aliases.is_some()) - { - return Err("Other configuration changes were detected and were not overwritten".to_string()); - } let mut errors = Vec::new(); if from_current.update_config_yaml { if let Err(error) = put_management_config_yaml(config, current).await { errors.push(error); } } - if let Some(aliases) = management_alias_config_changes(updated, current)?.oauth_model_aliases { + if let Some(aliases) = management_alias_config_changes(&latest, current)?.oauth_model_aliases { if let Err(error) = put_management_oauth_model_aliases(config, &aliases).await { errors.push(error); } diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index b0c14d2..2d30949 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -146,7 +146,7 @@ pub(crate) async fn fetch_active_oauth_alias_channels( ) -> Result, String> { let client = management_http_client()?; let response = client - .get(management_endpoint(config, "auth-files")?) + .get(management_endpoint(config, "credentials")?) .header("Authorization", management_authorization(config)?) .header(reqwest::header::ACCEPT, "application/json") .send() @@ -237,23 +237,16 @@ pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Resu .send() .await .map_err(|error| format_management_request_error("Failed to read kernel YAML configuration", &error))?; - read_management_text(response).await + let content = read_management_text(response).await?; + management_v8_yaml_to_legacy_view(&content) } pub(crate) async fn put_management_config_yaml( config: &GuiConfigFile, content: &str, ) -> Result<(), String> { - let client = management_http_client()?; - let response = client - .put(management_endpoint(config, "config.yaml")?) - .header("Authorization", management_authorization(config)?) - .header(reqwest::header::CONTENT_TYPE, "application/yaml") - .body(content.to_string()) - .send() - .await - .map_err(|error| format_management_request_error("Failed to save kernel YAML configuration", &error))?; - read_management_value(response).await.map(|_| ()) + let current = fetch_management_config_yaml(config).await?; + put_management_legacy_alias_view_changes(config, ¤t, content).await } pub(crate) async fn put_management_oauth_model_aliases( @@ -262,7 +255,7 @@ pub(crate) async fn put_management_oauth_model_aliases( ) -> Result<(), String> { let client = management_http_client()?; let response = client - .put(management_endpoint(config, "oauth-model-alias")?) + .put(management_endpoint(config, "config/oauth/model-alias")?) .header("Authorization", management_authorization(config)?) .json(aliases) .send() @@ -271,6 +264,302 @@ pub(crate) async fn put_management_oauth_model_aliases( read_management_value(response).await.map(|_| ()) } +pub(crate) const V8_PROVIDER_FAMILIES: [(&str, &str); 8] = [ + ("gemini-api-key", "gemini"), + ("interactions-api-key", "interactions"), + ("vertex-api-key", "vertex"), + ("codex-api-key", "codex"), + ("claude-api-key", "claude"), + ("xai-api-key", "xai"), + ("meta-api-key", "meta"), + ("openai-compatibility", "openai-compatibility"), +]; + +fn is_v8_shared_provider_field(field: &str) -> bool { + matches!( + field, + "priority" + | "prefix" + | "proxy-url" + | "headers" + | "models" + | "excluded-models" + | "disable-cooling" + | "request-retry" + | "request-scoped-errors" + | "base-url" + ) +} + +pub(crate) fn flatten_v8_provider_groups( + provider: &str, + groups: &serde_norway::Value, +) -> Result { + let groups = groups + .as_sequence() + .ok_or_else(|| format!("api-keys.{provider} must be an array"))?; + let mut records = Vec::new(); + for (group_index, group) in groups.iter().enumerate() { + let group = group.as_mapping().ok_or_else(|| { + format!("api-keys.{provider}[{group_index}] must be an object") + })?; + let keys = yaml_mapping_value(group, "keys") + .and_then(serde_norway::Value::as_sequence) + .ok_or_else(|| { + format!("api-keys.{provider}[{group_index}].keys must be an array") + })?; + if provider == "openai-compatibility" { + let mut record = group.clone(); + record.remove(yaml_key("keys")); + if !keys.is_empty() { + record.insert( + yaml_key("api-key-entries"), + serde_norway::Value::Sequence(keys.clone()), + ); + } + records.push(serde_norway::Value::Mapping(record)); + continue; + } + for (key_index, key) in keys.iter().enumerate() { + let key = key.as_mapping().ok_or_else(|| { + format!( + "api-keys.{provider}[{group_index}].keys[{key_index}] must be an object" + ) + })?; + let mut record = serde_norway::Mapping::new(); + for (field, value) in group { + if field + .as_str() + .is_some_and(is_v8_shared_provider_field) + { + record.insert(field.clone(), value.clone()); + } + } + for (field, value) in key { + if !value.is_null() { + record.insert(field.clone(), value.clone()); + } + } + records.push(serde_norway::Value::Mapping(record)); + } + } + Ok(serde_norway::Value::Sequence(records)) +} + +pub(crate) fn group_legacy_provider_records( + provider: &str, + records: &serde_norway::Value, +) -> Result { + let records = records + .as_sequence() + .ok_or_else(|| format!("{provider} provider configuration must be an array"))?; + let mut groups = Vec::new(); + for (index, record) in records.iter().enumerate() { + let record = record + .as_mapping() + .ok_or_else(|| format!("{provider} provider entry {index} must be an object"))?; + if provider == "openai-compatibility" { + let mut group = record.clone(); + let keys = group + .remove(yaml_key("api-key-entries")) + .unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())); + group.insert(yaml_key("keys"), keys); + groups.push(serde_norway::Value::Mapping(group)); + continue; + } + let mut group = serde_norway::Mapping::new(); + group.insert( + yaml_key("name"), + serde_norway::Value::String(format!("{provider}-{}", index + 1)), + ); + let mut key = serde_norway::Mapping::new(); + for (field, value) in record { + if field + .as_str() + .is_some_and(is_v8_shared_provider_field) + { + group.insert(field.clone(), value.clone()); + } else { + key.insert(field.clone(), value.clone()); + } + } + group.insert( + yaml_key("keys"), + serde_norway::Value::Sequence(vec![serde_norway::Value::Mapping(key)]), + ); + groups.push(serde_norway::Value::Mapping(group)); + } + Ok(serde_norway::Value::Sequence(groups)) +} + +pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result { + let mut document = serde_norway::from_str::(content) + .map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?; + let root = document + .as_mapping_mut() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + if yaml_mapping_value(root, "config-version").and_then(serde_norway::Value::as_i64) + != Some(8) + { + return Ok(content.to_string()); + } + + let upstreams = yaml_mapping_value(root, "api-keys") + .and_then(serde_norway::Value::as_mapping) + .cloned() + .unwrap_or_default(); + let client_keys = yaml_mapping_value(root, "access") + .and_then(serde_norway::Value::as_mapping) + .and_then(|access| yaml_mapping_value(access, "api-keys")) + .cloned(); + let oauth_aliases = yaml_mapping_value(root, "oauth") + .and_then(serde_norway::Value::as_mapping) + .and_then(|oauth| yaml_mapping_value(oauth, "model-alias")) + .cloned(); + let payload = yaml_mapping_value(root, "requests") + .and_then(serde_norway::Value::as_mapping) + .and_then(|requests| yaml_mapping_value(requests, "payload")) + .cloned(); + + if let Some(access) = yaml_mapping_value_mut(root, "access") + .and_then(serde_norway::Value::as_mapping_mut) + { + access.remove(yaml_key("api-keys")); + } + if let Some(oauth) = yaml_mapping_value_mut(root, "oauth") + .and_then(serde_norway::Value::as_mapping_mut) + { + oauth.remove(yaml_key("model-alias")); + } + if let Some(requests) = yaml_mapping_value_mut(root, "requests") + .and_then(serde_norway::Value::as_mapping_mut) + { + requests.remove(yaml_key("payload")); + } + root.remove(yaml_key("api-keys")); + // Targeted v8 writes create parents and deletes prune them. Their empty + // shells must not look like unrelated edits to the alias transaction. + for parent in ["access", "oauth", "requests"] { + if yaml_mapping_value(root, parent) + .and_then(serde_norway::Value::as_mapping) + .is_some_and(|mapping| mapping.is_empty()) + { + root.remove(yaml_key(parent)); + } + } + if let Some(client_keys) = client_keys { + root.insert(yaml_key("api-keys"), client_keys); + } + if let Some(oauth_aliases) = oauth_aliases { + root.insert(yaml_key("oauth-model-alias"), oauth_aliases); + } + if let Some(payload) = payload { + root.insert(yaml_key("payload"), payload); + } + for (legacy, provider) in V8_PROVIDER_FAMILIES { + let groups = yaml_mapping_value(&upstreams, provider) + .cloned() + .unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())); + root.insert( + yaml_key(legacy), + flatten_v8_provider_groups(provider, &groups)?, + ); + } + serde_norway::to_string(&document) + .map_err(|error| format!("Failed to serialize kernel YAML configuration: {error}")) +} + +async fn put_management_config_value( + config: &GuiConfigFile, + path: &str, + value: &serde_norway::Value, +) -> Result<(), String> { + let value = serde_json::to_value(value) + .map_err(|error| format!("Failed to serialize kernel configuration value: {error}"))?; + let client = management_http_client()?; + let response = client + .put(management_endpoint(config, &format!("config/{path}"))?) + .header("Authorization", management_authorization(config)?) + .json(&value) + .send() + .await + .map_err(|error| { + format_management_request_error("Failed to save kernel configuration", &error) + })?; + read_management_value(response).await.map(|_| ()) +} + +async fn delete_management_config_value( + config: &GuiConfigFile, + path: &str, +) -> Result<(), String> { + let client = management_http_client()?; + let response = client + .delete(management_endpoint(config, &format!("config/{path}"))?) + .header("Authorization", management_authorization(config)?) + .send() + .await + .map_err(|error| { + format_management_request_error("Failed to delete kernel configuration", &error) + })?; + read_management_value(response).await.map(|_| ()) +} + +async fn put_management_legacy_alias_view_changes( + config: &GuiConfigFile, + current: &str, + updated: &str, +) -> Result<(), String> { + let parse = |content: &str| { + let document = serde_norway::from_str::(content) + .map_err(|error| format!("Failed to parse kernel YAML configuration: {error}"))?; + document + .as_mapping() + .cloned() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string()) + }; + let mut current = parse(current)?; + let mut updated = parse(updated)?; + + // OAuth aliases are written by put_management_oauth_model_aliases so their + // transaction and rollback remain independent from other alias routes. + current.remove(yaml_key("oauth-model-alias")); + updated.remove(yaml_key("oauth-model-alias")); + let current_payload = current.remove(yaml_key("payload")); + let updated_payload = updated.remove(yaml_key("payload")); + let mut provider_changes = Vec::new(); + for (legacy, provider) in V8_PROVIDER_FAMILIES { + let before = current.remove(yaml_key(legacy)); + let after = updated.remove(yaml_key(legacy)); + if before != after { + provider_changes.push((provider, after)); + } + } + if current != updated { + return Err( + "The alias update changed unsupported kernel configuration; write rejected" + .to_string(), + ); + } + + if current_payload != updated_payload { + if let Some(value) = updated_payload.as_ref() { + put_management_config_value(config, "requests/payload", value).await?; + } else { + delete_management_config_value(config, "requests/payload").await?; + } + } + for (provider, records) in provider_changes { + if let Some(records) = records.as_ref() { + let groups = group_legacy_provider_records(provider, records)?; + put_management_config_value(config, &format!("api-keys/{provider}"), &groups).await?; + } else { + delete_management_config_value(config, &format!("api-keys/{provider}")).await?; + } + } + Ok(()) +} + #[derive(Debug, PartialEq)] pub(crate) struct ManagementAliasConfigChanges { pub(crate) oauth_model_aliases: Option, @@ -1126,7 +1415,7 @@ pub(crate) async fn fetch_oauth_channel_model_definitions( let response = client .get(management_endpoint( config, - &format!("model-definitions/{channel}"), + &format!("routing/model-definitions/{channel}"), )?) .header("Authorization", management_authorization(config)?) .header(reqwest::header::ACCEPT, "application/json") diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 75f4a09..74e0a75 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -327,9 +327,10 @@ pub(crate) fn patch_core_network_routing_yaml( patch_core_yaml_document(content, |document| { let original = document.clone(); apply_network_settings(document, config)?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "proxy-url", + &["proxy-url"], + &["requests", "proxy-url"], serde_norway::Value::String(config.proxy_url.clone()), )?; set_core_yaml_nested_value( @@ -344,33 +345,37 @@ pub(crate) fn patch_core_network_routing_yaml( "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "disable-cooling", + &["disable-cooling"], + &["routing", "cooldown", "disable-cooling"], serde_norway::Value::Bool(config.disable_cooling), )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "request-retry", + &["request-retry"], + &["routing", "retry", "request-retry"], serde_norway::to_value(config.request_retry) .map_err(|err| format!("Failed to serialize request retry count: {err}"))?, )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "max-retry-credentials", + &["max-retry-credentials"], + &["routing", "retry", "max-retry-credentials"], serde_norway::to_value(config.max_retry_credentials) .map_err(|err| format!("Failed to serialize maximum retry credentials: {err}"))?, )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "max-retry-interval", + &["max-retry-interval"], + &["routing", "retry", "max-retry-interval"], serde_norway::to_value(config.max_retry_interval) .map_err(|err| format!("Failed to serialize maximum retry interval: {err}"))?, )?; - set_core_yaml_nested_value( + set_core_yaml_schema_value( document, - "streaming", - "bootstrap-retries", + &["streaming", "bootstrap-retries"], + &["requests", "streaming", "bootstrap-retries"], serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| format!("Failed to serialize streaming bootstrap retry count: {err}"))?, )?; @@ -385,9 +390,10 @@ pub(crate) fn patch_core_network_endpoint_yaml( patch_core_yaml_document(content, |document| { let original = document.clone(); apply_network_settings(document, config)?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "proxy-url", + &["proxy-url"], + &["requests", "proxy-url"], serde_norway::Value::String(config.proxy_url.clone()), )?; Ok(*document != original) @@ -400,30 +406,34 @@ pub(crate) fn patch_core_retry_yaml( ) -> Result, String> { patch_core_yaml_document(content, |document| { let original = document.clone(); - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "disable-cooling", + &["disable-cooling"], + &["routing", "cooldown", "disable-cooling"], serde_norway::Value::Bool(config.disable_cooling), )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "request-retry", + &["request-retry"], + &["routing", "retry", "request-retry"], serde_norway::to_value(config.request_retry).map_err(|err| err.to_string())?, )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "max-retry-credentials", + &["max-retry-credentials"], + &["routing", "retry", "max-retry-credentials"], serde_norway::to_value(config.max_retry_credentials).map_err(|err| err.to_string())?, )?; - set_core_yaml_top_level_value( + set_core_yaml_schema_value( document, - "max-retry-interval", + &["max-retry-interval"], + &["routing", "retry", "max-retry-interval"], serde_norway::to_value(config.max_retry_interval).map_err(|err| err.to_string())?, )?; - set_core_yaml_nested_value( + set_core_yaml_schema_value( document, - "streaming", - "bootstrap-retries", + &["streaming", "bootstrap-retries"], + &["requests", "streaming", "bootstrap-retries"], serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| err.to_string())?, )?; @@ -475,19 +485,19 @@ pub(crate) fn apply_network_settings( document: &mut serde_norway::Value, config: &GuiConfigFile, ) -> Result<(), String> { - let mapping = document - .as_mapping_mut() - .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; - let host = config.host.trim(); - mapping.insert( - serde_norway::Value::String("host".to_string()), + set_core_yaml_schema_value( + document, + &["host"], + &["server", "host"], serde_norway::Value::String(host.to_string()), - ); - mapping.insert( - serde_norway::Value::String("port".to_string()), + )?; + set_core_yaml_schema_value( + document, + &["port"], + &["server", "port"], serde_norway::to_value(config.port).map_err(|err| format!("Failed to serialize kernel port: {err}"))?, - ); + )?; Ok(()) } @@ -498,69 +508,80 @@ pub(crate) fn apply_gui_managed_settings( let host = config.host.trim(); let updated = patch_core_yaml_document(content, |document| { let mut changed = false; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "host", + &["host"], + &["server", "host"], serde_norway::Value::String(host.to_string()), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "port", + &["port"], + &["server", "port"], serde_norway::to_value(config.port) .map_err(|err| format!("Failed to serialize kernel port: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "auth-dir", + &["auth-dir"], + &["oauth", "auth-dir"], serde_norway::Value::String(config.auth_dir.clone()), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "debug", + &["debug"], + &["observability", "logs", "debug"], serde_norway::Value::Bool(config.debug), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "commercial-mode", + &["commercial-mode"], + &["server", "commercial-mode"], serde_norway::Value::Bool(config.commercial_mode), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "logging-to-file", + &["logging-to-file"], + &["observability", "logs", "logging-to-file"], serde_norway::Value::Bool(config.logging_to_file), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "logs-max-total-size-mb", + &["logs-max-total-size-mb"], + &["observability", "logs", "logs-max-total-size-mb"], serde_norway::to_value(config.logs_max_total_size_mb) .map_err(|err| format!("Failed to serialize log size limit: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "error-logs-max-files", + &["error-logs-max-files"], + &["observability", "logs", "error-logs-max-files"], serde_norway::to_value(config.error_logs_max_files) .map_err(|err| format!("Failed to serialize error log retention count: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "usage-statistics-enabled", + &["usage-statistics-enabled"], + &["observability", "usage", "usage-statistics-enabled"], serde_norway::Value::Bool(config.usage_statistics_enabled), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "redis-usage-queue-retention-seconds", + &["redis-usage-queue-retention-seconds"], + &["observability", "usage", "redis-usage-queue-retention-seconds"], serde_norway::to_value(config.redis_usage_queue_retention_seconds) .map_err(|err| format!("Failed to serialize Redis usage queue retention: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "request-log", + &["request-log"], + &["observability", "logs", "request-log"], serde_norway::Value::Bool(config.request_log), )?; - changed |= set_core_yaml_nested_value( + changed |= set_core_yaml_schema_value( document, - "remote-management", - "secret-key", + &["remote-management", "secret-key"], + &["management", "secret-key"], serde_norway::Value::String(config.management_secret_key.clone()), )?; changed |= set_core_yaml_nested_value( @@ -575,9 +596,10 @@ pub(crate) fn apply_gui_managed_settings( "strategy", serde_norway::Value::String(config.routing_strategy.clone()), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "proxy-url", + &["proxy-url"], + &["requests", "proxy-url"], serde_norway::Value::String(config.proxy_url.clone()), )?; changed |= set_core_yaml_nested_value( @@ -592,33 +614,37 @@ pub(crate) fn apply_gui_managed_settings( "session-affinity-ttl", serde_norway::Value::String(config.routing_session_affinity_ttl.clone()), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "disable-cooling", + &["disable-cooling"], + &["routing", "cooldown", "disable-cooling"], serde_norway::Value::Bool(config.disable_cooling), )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "request-retry", + &["request-retry"], + &["routing", "retry", "request-retry"], serde_norway::to_value(config.request_retry) .map_err(|err| format!("Failed to serialize request retry count: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "max-retry-credentials", + &["max-retry-credentials"], + &["routing", "retry", "max-retry-credentials"], serde_norway::to_value(config.max_retry_credentials) .map_err(|err| format!("Failed to serialize maximum retry credentials: {err}"))?, )?; - changed |= set_core_yaml_top_level_value( + changed |= set_core_yaml_schema_value( document, - "max-retry-interval", + &["max-retry-interval"], + &["routing", "retry", "max-retry-interval"], serde_norway::to_value(config.max_retry_interval) .map_err(|err| format!("Failed to serialize maximum retry interval: {err}"))?, )?; - changed |= set_core_yaml_nested_value( + changed |= set_core_yaml_schema_value( document, - "streaming", - "bootstrap-retries", + &["streaming", "bootstrap-retries"], + &["requests", "streaming", "bootstrap-retries"], serde_norway::to_value(config.streaming_bootstrap_retries) .map_err(|err| format!("Failed to serialize streaming bootstrap retry count: {err}"))?, )?; diff --git a/src-tauri/src/core_config/yaml.rs b/src-tauri/src/core_config/yaml.rs index eac4739..2e14a99 100644 --- a/src-tauri/src/core_config/yaml.rs +++ b/src-tauri/src/core_config/yaml.rs @@ -150,7 +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 = yaml_mapping_value(root, "tls").and_then(serde_norway::Value::as_mapping); + 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")) .map(|value| { @@ -183,23 +185,21 @@ pub(crate) fn patch_core_tls_settings_yaml( settings: &CoreTlsSettings, ) -> Result, String> { patch_core_yaml_document(content, |document| { + let v8 = core_config_uses_v8(document); let mut changed = false; - changed |= set_core_yaml_nested_value( + changed |= set_core_yaml_path_value( document, - "tls", - "enable", + if v8 { &["server", "tls", "enable"] } else { &["tls", "enable"] }, serde_norway::Value::Bool(settings.enabled), )?; - changed |= set_core_yaml_nested_value( + changed |= set_core_yaml_path_value( document, - "tls", - "cert", + if v8 { &["server", "tls", "cert"] } else { &["tls", "cert"] }, serde_norway::Value::String(settings.cert.clone()), )?; - changed |= set_core_yaml_nested_value( + changed |= set_core_yaml_path_value( document, - "tls", - "key", + if v8 { &["server", "tls", "key"] } else { &["tls", "key"] }, serde_norway::Value::String(settings.key.clone()), )?; Ok(changed) @@ -247,7 +247,8 @@ 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) = yaml_mapping_value(root, provider) else { + 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() { @@ -333,6 +334,7 @@ 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), @@ -341,7 +343,11 @@ 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 = yaml_mapping_value(root, provider); + 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")); @@ -349,16 +355,25 @@ pub(crate) fn patch_core_sensitive_words_yaml( continue; } if section.is_none_or(serde_norway::Value::is_null) { - changed |= set_core_yaml_top_level_value( + let section_path = if v8 { + vec!["oauth", "providers", provider] + } else { + vec![provider] + }; + changed |= set_core_yaml_path_value( document, - provider, + §ion_path, serde_norway::Value::Mapping(serde_norway::Mapping::new()), )?; } - changed |= set_core_yaml_nested_value( + let sensitive_words_path = if v8 { + vec!["oauth", "providers", provider, "sensitive-words"] + } else { + vec![provider, "sensitive-words"] + }; + changed |= set_core_yaml_path_value( document, - provider, - "sensitive-words", + &sensitive_words_path, serde_norway::Value::Sequence( words .iter() @@ -407,10 +422,10 @@ pub(crate) fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> { pub(crate) fn patch_core_management_secret_key(secret_key: &str) -> Result<(), String> { let secret_key = secret_key.to_string(); patch_existing_core_config(move |document| { - set_core_yaml_nested_value( + let v8 = core_config_uses_v8(document); + set_core_yaml_path_value( document, - "remote-management", - "secret-key", + if v8 { &["management", "secret-key"] } else { &["remote-management", "secret-key"] }, serde_norway::Value::String(secret_key), ) }) @@ -420,7 +435,12 @@ pub(crate) fn patch_core_management_secret_key(secret_key: &str) -> Result<(), S pub(crate) fn patch_core_auth_dir(auth_dir: &str) -> Result<(), String> { let auth_dir = auth_dir.to_string(); patch_existing_core_config(move |document| { - set_core_yaml_top_level_value(document, "auth-dir", serde_norway::Value::String(auth_dir)) + let v8 = core_config_uses_v8(document); + set_core_yaml_path_value( + document, + if v8 { &["oauth", "auth-dir"] } else { &["auth-dir"] }, + serde_norway::Value::String(auth_dir), + ) }) } @@ -437,13 +457,19 @@ pub(crate) fn patch_core_plugins_enabled(enabled: bool) -> Result<(), String> { pub(crate) fn patch_core_request_log(enabled: bool) -> Result<(), String> { patch_existing_core_config(|document| { - set_core_yaml_top_level_value(document, "request-log", serde_norway::Value::Bool(enabled)) + let v8 = core_config_uses_v8(document); + set_core_yaml_path_value( + document, + if v8 { &["observability", "logs", "request-log"] } else { &["request-log"] }, + serde_norway::Value::Bool(enabled), + ) }) } pub(crate) fn patch_core_logging_settings(settings: &CoreConfigSettings) -> Result<(), String> { let settings = settings.clone(); patch_existing_core_config(move |document| { + let v8 = core_config_uses_v8(document); let mut changed = false; for (key, value) in [ ("debug", serde_norway::Value::Bool(settings.debug)), @@ -475,7 +501,16 @@ pub(crate) fn patch_core_logging_settings(settings: &CoreConfigSettings) -> Resu .map_err(|err| format!("Failed to serialize Redis usage queue retention: {err}"))?, ), ] { - changed |= set_core_yaml_top_level_value(document, key, value)?; + let path: &[&str] = if v8 { + match key { + "commercial-mode" => &["server", "commercial-mode"], + "usage-statistics-enabled" | "redis-usage-queue-retention-seconds" => &["observability", "usage", key], + _ => &["observability", "logs", key], + } + } else { + &[key] + }; + changed |= set_core_yaml_path_value(document, path, value)?; } Ok(changed) }) @@ -496,9 +531,10 @@ pub(crate) fn patch_core_routing_strategy(strategy: &str) -> Result<(), String> pub(crate) fn patch_core_proxy_url(proxy_url: &str) -> Result<(), String> { let proxy_url = proxy_url.to_string(); patch_existing_core_config(move |document| { - set_core_yaml_top_level_value( + let v8 = core_config_uses_v8(document); + set_core_yaml_path_value( document, - "proxy-url", + if v8 { &["requests", "proxy-url"] } else { &["proxy-url"] }, serde_norway::Value::String(proxy_url), ) }) @@ -1136,6 +1172,50 @@ pub(crate) fn set_core_yaml_nested_value( Ok(true) } +pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool { + document + .as_mapping() + .and_then(|root| yaml_mapping_value(root, "config-version")) + .and_then(serde_norway::Value::as_u64) + .is_some_and(|version| version >= 8) +} + +pub(crate) fn set_core_yaml_path_value( + document: &mut serde_norway::Value, + path: &[&str], + value: serde_norway::Value, +) -> Result { + let Some((key, parents)) = path.split_last() else { + return Err("Kernel configuration path cannot be empty".to_string()); + }; + let mut mapping = document + .as_mapping_mut() + .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + for section in parents { + mapping = mapping + .entry(yaml_key(section)) + .or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| format!("Kernel configuration section {section} must be a YAML mapping"))?; + } + let key = yaml_key(key); + if mapping.get(&key) == Some(&value) { + return Ok(false); + } + mapping.insert(key, value); + Ok(true) +} + +pub(crate) fn set_core_yaml_schema_value( + document: &mut serde_norway::Value, + legacy_path: &[&str], + v8_path: &[&str], + value: serde_norway::Value, +) -> Result { + let path = if core_config_uses_v8(document) { v8_path } else { legacy_path }; + set_core_yaml_path_value(document, path, value) +} + pub(crate) fn patch_core_api_keys_yaml( content: &str, api_keys: &[String], @@ -1145,6 +1225,18 @@ pub(crate) fn patch_core_api_keys_yaml( let root = parsed .as_mapping() .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; + if core_config_uses_v8(&parsed) { + return patch_core_yaml_document(content, |document| { + set_core_yaml_path_value( + document, + &["access", "api-keys"], + serde_norway::Value::Sequence( + api_keys.iter().cloned().map(serde_norway::Value::String).collect(), + ), + ) + }) + .map(|updated| updated.unwrap_or_else(|| content.to_string())); + } let has_legacy_api_keys = nested_yaml_value( root, &["auth", "providers", "config-api-key", "api-key-entries"], @@ -1322,7 +1414,10 @@ pub(crate) fn core_config_settings_from_value( let root = document .as_mapping() .ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?; - let host = yaml_mapping_value(root, "host") + let v8_or_legacy = |v8: &[&str], legacy: &[&str]| { + nested_yaml_value(root, v8).or_else(|| nested_yaml_value(root, legacy)) + }; + let host = v8_or_legacy(&["server", "host"], &["host"]) .map(|value| { value .as_str() @@ -1331,7 +1426,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_else(|| "127.0.0.1".to_string()); - let port = yaml_mapping_value(root, "port") + let port = v8_or_legacy(&["server", "port"], &["port"]) .map(|value| { value .as_u64() @@ -1341,7 +1436,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(8317); - let auth_dir = yaml_mapping_value(root, "auth-dir") + let auth_dir = v8_or_legacy(&["oauth", "auth-dir"], &["auth-dir"]) .map(|value| { value .as_str() @@ -1350,7 +1445,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_else(|| OAUTH_DIR_NAME.to_string()); - let debug = yaml_mapping_value(root, "debug") + let debug = v8_or_legacy(&["observability", "logs", "debug"], &["debug"]) .map(|value| { value .as_bool() @@ -1358,7 +1453,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(false); - let commercial_mode = yaml_mapping_value(root, "commercial-mode") + let commercial_mode = v8_or_legacy(&["server", "commercial-mode"], &["commercial-mode"]) .map(|value| { value .as_bool() @@ -1366,7 +1461,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(false); - let logging_to_file = yaml_mapping_value(root, "logging-to-file") + let logging_to_file = v8_or_legacy(&["observability", "logs", "logging-to-file"], &["logging-to-file"]) .map(|value| { value .as_bool() @@ -1374,7 +1469,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(false); - let logs_max_total_size_mb = yaml_mapping_value(root, "logs-max-total-size-mb") + let logs_max_total_size_mb = v8_or_legacy(&["observability", "logs", "logs-max-total-size-mb"], &["logs-max-total-size-mb"]) .map(|value| { value .as_u64() @@ -1383,7 +1478,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_LOGS_MAX_TOTAL_SIZE_MB); - let error_logs_max_files = yaml_mapping_value(root, "error-logs-max-files") + let error_logs_max_files = v8_or_legacy(&["observability", "logs", "error-logs-max-files"], &["error-logs-max-files"]) .map(|value| { value .as_u64() @@ -1392,7 +1487,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_ERROR_LOGS_MAX_FILES); - let usage_statistics_enabled = yaml_mapping_value(root, "usage-statistics-enabled") + let usage_statistics_enabled = v8_or_legacy(&["observability", "usage", "usage-statistics-enabled"], &["usage-statistics-enabled"]) .map(|value| { value .as_bool() @@ -1401,7 +1496,7 @@ pub(crate) fn core_config_settings_from_value( .transpose()? .unwrap_or(true); let redis_usage_queue_retention_seconds = - yaml_mapping_value(root, "redis-usage-queue-retention-seconds") + v8_or_legacy(&["observability", "usage", "redis-usage-queue-retention-seconds"], &["redis-usage-queue-retention-seconds"]) .map(|value| { value .as_u64() @@ -1415,7 +1510,7 @@ pub(crate) fn core_config_settings_from_value( } else { redis_usage_queue_retention_seconds.min(3600) }; - let request_log = yaml_mapping_value(root, "request-log") + let request_log = v8_or_legacy(&["observability", "logs", "request-log"], &["request-log"]) .map(|value| { value .as_bool() @@ -1445,7 +1540,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_else(|| "round-robin".to_string()); - let proxy_url = yaml_mapping_value(root, "proxy-url") + let proxy_url = v8_or_legacy(&["requests", "proxy-url"], &["proxy-url"]) .map(|value| { value .as_str() @@ -1474,7 +1569,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or_default(); - let disable_cooling = yaml_mapping_value(root, "disable-cooling") + let disable_cooling = v8_or_legacy(&["routing", "cooldown", "disable-cooling"], &["disable-cooling"]) .map(|value| { value .as_bool() @@ -1482,7 +1577,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_DISABLE_COOLING); - let request_retry = yaml_mapping_value(root, "request-retry") + let request_retry = v8_or_legacy(&["routing", "retry", "request-retry"], &["request-retry"]) .map(|value| { value .as_u64() @@ -1491,7 +1586,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_REQUEST_RETRY); - let max_retry_credentials = yaml_mapping_value(root, "max-retry-credentials") + let max_retry_credentials = v8_or_legacy(&["routing", "retry", "max-retry-credentials"], &["max-retry-credentials"]) .map(|value| { value .as_u64() @@ -1500,7 +1595,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_MAX_RETRY_CREDENTIALS); - let max_retry_interval = yaml_mapping_value(root, "max-retry-interval") + let max_retry_interval = v8_or_legacy(&["routing", "retry", "max-retry-interval"], &["max-retry-interval"]) .map(|value| { value .as_u64() @@ -1509,7 +1604,7 @@ pub(crate) fn core_config_settings_from_value( }) .transpose()? .unwrap_or(DEFAULT_MAX_RETRY_INTERVAL); - let streaming_bootstrap_retries = nested_yaml_value(root, &["streaming", "bootstrap-retries"]) + let streaming_bootstrap_retries = v8_or_legacy(&["requests", "streaming", "bootstrap-retries"], &["streaming", "bootstrap-retries"]) .map(|value| { value .as_u64() @@ -1550,7 +1645,13 @@ pub(crate) fn core_config_settings_from_value( } pub(crate) fn extract_core_api_keys(root: &serde_norway::Mapping) -> Result, String> { + if let Some(value) = nested_yaml_value(root, &["access", "api-keys"]) { + return extract_api_key_sequence(value, "access.api-keys"); + } if let Some(value) = yaml_mapping_value(root, "api-keys") { + if value.is_mapping() { + return Ok(Vec::new()); + } return extract_api_key_sequence(value, "api-keys"); } @@ -1609,7 +1710,8 @@ pub(crate) fn extract_api_key_value(value: &serde_norway::Value) -> Option Result, String> { - let Some(value) = nested_yaml_value(root, &["remote-management", "secret-key"]) else { + let Some(value) = nested_yaml_value(root, &["management", "secret-key"]) + .or_else(|| nested_yaml_value(root, &["remote-management", "secret-key"])) else { return Ok(None); }; let value = value diff --git a/src-tauri/src/core_runtime.rs b/src-tauri/src/core_runtime.rs index 91d4f1f..33437d9 100644 --- a/src-tauri/src/core_runtime.rs +++ b/src-tauri/src/core_runtime.rs @@ -1951,7 +1951,21 @@ pub(crate) fn migrate_core_config_for_update( path_to_string(&template_path) ) })?; - let migrated = merge_core_config_fields(&template, Some(&old_config))?; + let old_document = serde_norway::from_str::(&old_config) + .map_err(|error| format!("Failed to parse existing kernel configuration; update canceled to prevent configuration loss: {error}"))?; + if !old_document.is_mapping() { + return Err("Existing kernel configuration root must be a YAML mapping; update canceled".to_string()); + } + let template_document = serde_norway::from_str::(&template) + .map_err(|error| format!("Failed to parse new kernel configuration template: {error}"))?; + let migrated = if core_config_uses_v8(&template_document) && !core_config_uses_v8(&old_document) { + // v8 accepts legacy-only files and migrates them atomically on the first + // successful v8 Management API write. Mixing both layouts here would let + // v8 template defaults override the user's legacy values. + old_config + } else { + merge_core_config_fields(&template, Some(&old_config))? + }; let config_path = target_dir.join(CORE_CONFIG_FILE); fs::write(&config_path, migrated).map_err(|error| { format!( diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index af47b42..0195f6d 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -116,7 +116,7 @@ pub(crate) async fn upload_auth_file( let mut query = HashMap::new(); query.insert("name".to_string(), name); let response = client - .post(management_endpoint(&config, "auth-files")?) + .post(management_endpoint(&config, "credentials")?) .header("Authorization", management_authorization(&config)?) .query(&query) .header("Content-Type", "application/json") @@ -184,11 +184,9 @@ pub(crate) async fn start_oauth_login( let provider_key = normalize_management_oauth_provider(&provider)?; let client = management_http_client()?; let mut request = client - .get(management_endpoint( - &config, - &format!("{provider_key}-auth-url"), - )?) + .get(management_endpoint(&config, "oauth/auth-url")?) .header("Authorization", management_authorization(&config)?); + request = request.query(&[("provider", provider_key.as_str())]); if management_oauth_uses_webui_callback(&provider_key) { request = request.query(&[("is_webui", "true")]); } @@ -239,7 +237,7 @@ pub(crate) async fn get_oauth_status( let config = gui_config_state.snapshot()?; let client = management_http_client()?; let response = client - .get(management_endpoint(&config, "get-auth-status")?) + .get(management_endpoint(&config, "oauth/status")?) .header("Authorization", management_authorization(&config)?) .query(&[("state", state)]) .send() @@ -279,7 +277,7 @@ pub(crate) async fn submit_oauth_callback( "redirect_url": redirect_url, }); let response = client - .post(management_endpoint(&config, "oauth-callback")?) + .post(management_endpoint(&config, "oauth/callback")?) .header("Authorization", management_authorization(&config)?) .json(&body) .send() @@ -354,13 +352,13 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result< config.port, current_core_tls_settings()?.enabled, ); - Ok(format!("{origin}/v0/management/{path}")) + Ok(format!("{origin}/v8/management/{path}")) } fn normalize_management_oauth_provider(provider: &str) -> Result { let key = provider.trim().to_ascii_lowercase().replace('_', "-"); let key = match key.as_str() { - "claude" | "anthropic" => "anthropic".to_string(), + "claude" | "anthropic" => "claude".to_string(), "anti-gravity" => "antigravity".to_string(), "cognition" => "devin".to_string(), "grok" | "x-ai" | "x.ai" => "xai".to_string(), @@ -377,7 +375,7 @@ fn normalize_management_oauth_provider(provider: &str) -> Result } fn management_oauth_uses_webui_callback(provider_key: &str) -> bool { - matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai" | "devin") + matches!(provider_key, "codex" | "claude" | "antigravity" | "xai" | "devin") } async fn read_management_json(response: reqwest::Response) -> Result diff --git a/src-tauri/src/network_proxy.rs b/src-tauri/src/network_proxy.rs index a6c70c6..b28dbcd 100644 --- a/src-tauri/src/network_proxy.rs +++ b/src-tauri/src/network_proxy.rs @@ -583,7 +583,7 @@ pub(crate) async fn verify_core_proxy(config: &GuiConfigFile) -> Result<(), Stri let mut updated = false; for attempt in 0..10 { let response = client - .get(management_api::management_endpoint(config, "proxy-url")?) + .get(management_api::management_endpoint(config, "config/requests/proxy-url")?) .header( "Authorization", management_api::management_authorization(config)?, @@ -594,18 +594,18 @@ pub(crate) async fn verify_core_proxy(config: &GuiConfigFile) -> Result<(), Stri if let Ok(response) = response { if response.status().is_success() { if let Ok(value) = response.json::().await { - if let Some(actual) = value.get("proxy-url").and_then(|v| v.as_str()) { + if let Some(actual) = value.as_str() { if actual == config.proxy_url { return Ok(()); } if attempt >= 2 && !updated { let response = client - .put(management_api::management_endpoint(config, "proxy-url")?) + .put(management_api::management_endpoint(config, "config/requests/proxy-url")?) .header( "Authorization", management_api::management_authorization(config)?, ) - .json(&serde_json::json!({"value": config.proxy_url})) + .json(&config.proxy_url) .timeout(Duration::from_secs(3)) .send() .await diff --git a/src-tauri/src/tests/alias_save.rs b/src-tauri/src/tests/alias_save.rs index c0a9fcf..ea5e2c2 100644 --- a/src-tauri/src/tests/alias_save.rs +++ b/src-tauri/src/tests/alias_save.rs @@ -6,10 +6,81 @@ use std::sync::Arc; const CURRENT: &str = "oauth-model-alias:\n codex:\n - name: gpt-test\n alias: my-alias\n fork: true\npayload:\n override:\n - models: [{name: my-alias, protocol: codex}]\n params: {reasoning.effort: high}\n"; +#[test] +fn v8_management_yaml_is_projected_to_the_legacy_alias_editor_view() { + let input = "config-version: 8\naccess: {api-keys: [client-key]}\noauth:\n model-alias: {codex: [{name: upstream, alias: public, fork: true}]}\nrequests:\n payload: {override: [{models: [{name: public}], params: {reasoning.effort: high}}]}\napi-keys:\n codex:\n - name: codex-group\n base-url: https://codex.example/v1\n models: [{name: model-a}]\n keys:\n - {api-key: first, priority: 7}\n - {api-key: second, priority: null}\n openai-compatibility:\n - name: openai-group\n base-url: https://openai.example/v1\n keys: [{api-key: third}]\n - name: empty-openai-group\n base-url: https://empty.example/v1\n keys: []\n"; + let view = yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap()); + assert_eq!(view["api-keys"], serde_json::json!(["client-key"])); + assert_eq!(view["oauth-model-alias"]["codex"][0]["alias"], "public"); + assert_eq!(view["payload"]["override"][0]["params"]["reasoning.effort"], "high"); + assert!(view["access"].get("api-keys").is_none()); + assert!(view["oauth"].get("model-alias").is_none()); + assert!(view["requests"].get("payload").is_none()); + assert_eq!(view["codex-api-key"][0]["api-key"], "first"); + assert_eq!(view["codex-api-key"][0]["priority"], 7); + assert_eq!(view["codex-api-key"][0]["models"][0]["name"], "model-a"); + assert_eq!(view["codex-api-key"][1]["api-key"], "second"); + assert!(view["codex-api-key"][1].get("priority").is_none()); + assert_eq!( + view["openai-compatibility"][0]["api-key-entries"][0]["api-key"], + "third" + ); + assert!(view["openai-compatibility"][1] + .get("api-key-entries") + .is_none()); + assert_eq!(view["gemini-api-key"], serde_json::json!([])); +} + +#[test] +fn non_v8_management_yaml_is_returned_without_reformatting() { + let input = "# mock legacy config\nport: 8317\n"; + assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input); +} + +#[test] +fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() { + let project = |input| yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap()); + assert_eq!( + project("config-version: 8\n"), + project("config-version: 8\naccess: {}\noauth: {}\nrequests: {}\n") + ); + let view = project("config-version: 8\noauth: {auth-dir: credentials, model-alias: {}}\nrequests: {proxy-url: direct, payload: {}}\n"); + assert_eq!(view["oauth"]["auth-dir"], "credentials"); + assert_eq!(view["requests"]["proxy-url"], "direct"); +} + +#[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); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = format!("{current}{CURRENT}"); + put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap(); + let (persisted, _) = core.finish(); + assert_eq!(persisted["oauth-model-alias"], yaml_json(CURRENT)["oauth-model-alias"]); + assert_eq!(persisted["payload"], yaml_json(CURRENT)["payload"]); +} + +#[tokio::test(flavor = "current_thread")] +async fn alias_save_rolls_back_a_new_payload_parent() { + let initial = "oauth-model-alias: {codex: [{name: gpt-test, alias: my-alias, fork: true}]}\n"; + let core = MockCore::new_v8(initial, Failure::YamlAfterWrite); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = format!("{}payload: {{override: []}}\n", current.replace("my-alias", "renamed")); + let error = put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap_err(); + assert!(error.contains("Original configuration was restored"), "{error}"); + let (persisted, _) = core.finish(); + assert_eq!(persisted, yaml_json(initial)); +} + #[derive(Clone, Copy, PartialEq, Eq)] enum Failure { None, YamlBeforeWrite, + SecondYamlBeforeWrite, YamlAfterWrite, OauthAfterWrite, Rollback, @@ -60,6 +131,14 @@ struct MockCore { impl MockCore { fn new(initial: &str, failure: Failure) -> Self { + Self::with_response_layout(initial, failure, false) + } + + fn new_v8(initial: &str, failure: Failure) -> Self { + Self::with_response_layout(initial, failure, true) + } + + fn with_response_layout(initial: &str, failure: Failure, v8_response: bool) -> Self { assert!(!current_core_tls_settings().unwrap().enabled); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); @@ -120,9 +199,13 @@ impl MockCore { let mut status = "200 OK"; let mut response = "{\"status\":\"ok\"}".to_string(); if method == "GET" { - assert_eq!(path, "/v0/management/config.yaml"); - response = serde_norway::to_string(&persisted).unwrap(); - } else if path == "/v0/management/oauth-model-alias" { + assert_eq!(path, "/v8/management/config.yaml"); + response = if v8_response { + v8_yaml_from_legacy_json(&persisted) + } else { + serde_norway::to_string(&persisted).unwrap() + }; + } else if path == "/v8/management/config/oauth/model-alias" { oauth_writes += 1; if failure == Failure::Rollback && oauth_writes == 2 { status = "500 Internal Server Error"; @@ -134,20 +217,65 @@ impl MockCore { } } } else { - assert_eq!(path, "/v0/management/config.yaml"); yaml_writes += 1; - if yaml_writes == 1 + if (yaml_writes == 1 && matches!( failure, Failure::YamlBeforeWrite | Failure::Rollback | Failure::ForeignChange - ) + )) + || (yaml_writes == 2 && failure == Failure::SecondYamlBeforeWrite) { status = "500 Internal Server Error"; if failure == Failure::ForeignChange { persisted["debug"] = serde_json::json!(true); } } else { - persisted = yaml_json(std::str::from_utf8(request_body).unwrap()); + let suffix = path + .strip_prefix("/v8/management/config/") + .unwrap_or_else(|| panic!("unexpected management path: {path}")); + match (method, suffix) { + ("PUT", "requests/payload") => { + persisted["payload"] = + serde_json::from_slice(request_body).unwrap(); + } + ("DELETE", "requests/payload") => { + persisted.as_object_mut().unwrap().remove("payload"); + } + ("PUT", provider_path) + if provider_path.starts_with("api-keys/") => + { + let provider = provider_path + .strip_prefix("api-keys/") + .unwrap(); + let legacy = V8_PROVIDER_FAMILIES + .iter() + .find_map(|(legacy, candidate)| { + (*candidate == provider).then_some(*legacy) + }) + .unwrap(); + let groups: serde_norway::Value = + serde_json::from_slice(request_body).unwrap(); + persisted[legacy] = serde_json::to_value( + flatten_v8_provider_groups(provider, &groups).unwrap(), + ) + .unwrap(); + } + ("DELETE", provider_path) + if provider_path.starts_with("api-keys/") => + { + let provider = provider_path + .strip_prefix("api-keys/") + .unwrap(); + let legacy = V8_PROVIDER_FAMILIES + .iter() + .find_map(|(legacy, candidate)| { + (*candidate == provider).then_some(*legacy) + }) + .unwrap(); + persisted.as_object_mut().unwrap().remove(legacy); + } + _ => panic!("unexpected management request: {method} {path}"), + } if yaml_writes == 1 && failure == Failure::YamlAfterWrite { status = "500 Internal Server Error"; } @@ -190,6 +318,79 @@ fn yaml_json(content: &str) -> serde_json::Value { serde_json::to_value(serde_norway::from_str::(content).unwrap()).unwrap() } +fn v8_yaml_from_legacy_json(legacy: &serde_json::Value) -> String { + let mut root = legacy.as_object().unwrap().clone(); + root.insert("config-version".into(), serde_json::json!(8)); + if let Some(client_keys) = root.remove("api-keys") { + root.entry("access") + .or_insert_with(|| serde_json::json!({}))["api-keys"] = client_keys; + } + if let Some(aliases) = root.remove("oauth-model-alias") { + root.entry("oauth") + .or_insert_with(|| serde_json::json!({}))["model-alias"] = aliases; + } + if let Some(payload) = root.remove("payload") { + root.entry("requests") + .or_insert_with(|| serde_json::json!({}))["payload"] = payload; + } + let mut upstreams = serde_json::Map::new(); + for (legacy, provider) in V8_PROVIDER_FAMILIES { + let records: serde_norway::Value = serde_json::from_value( + root.remove(legacy).unwrap_or_else(|| serde_json::json!([])), + ) + .unwrap(); + upstreams.insert( + provider.into(), + serde_json::to_value(group_legacy_provider_records(provider, &records).unwrap()) + .unwrap(), + ); + } + root.insert("api-keys".into(), serde_json::Value::Object(upstreams)); + serde_norway::to_string(&serde_json::Value::Object(root)).unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn alias_save_uses_only_targeted_writes_with_a_real_v8_config_view() { + let core = MockCore::new_v8(CURRENT, Failure::None); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = current.replace("my-alias", "renamed"); + put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap(); + let (persisted, requests) = core.finish(); + assert_eq!(persisted, yaml_json(&CURRENT.replace("my-alias", "renamed"))); + assert!(requests + .iter() + .any(|request| request == "PUT /v8/management/config/oauth/model-alias")); + assert!(requests + .iter() + .any(|request| request == "PUT /v8/management/config/requests/payload")); + assert!(!requests + .iter() + .any(|request| request == "PUT /v8/management/config.yaml")); +} + +#[tokio::test(flavor = "current_thread")] +async fn provider_alias_edits_round_trip_through_v8_key_groups() { + let initial = format!( + "{CURRENT}codex-api-key:\n - api-key: preserved-secret\n base-url: https://codex.example/v1\n models: [{{name: model-a}}]\n" + ); + let core = MockCore::new_v8(&initial, Failure::None); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = current.replace("name: model-a\n", "name: model-b\n"); + put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap(); + let (persisted, requests) = core.finish(); + let mut expected = yaml_json(&initial); + expected["codex-api-key"][0]["models"][0]["name"] = serde_json::json!("model-b"); + assert_eq!(persisted, expected); + assert_eq!(persisted["codex-api-key"][0]["api-key"], "preserved-secret"); + assert!(requests + .iter() + .any(|request| request == "PUT /v8/management/config/api-keys/codex")); +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_recovers_failures_before_and_after_writes() { for failure in [ @@ -206,13 +407,35 @@ async fn alias_save_recovers_failures_before_and_after_writes() { assert_eq!( requests .iter() - .filter(|request| request.ends_with("oauth-model-alias")) + .filter(|request| request.ends_with("oauth/model-alias")) .count(), 2 ); } } +#[tokio::test(flavor = "current_thread")] +async fn alias_save_restores_a_partial_v8_multi_path_write() { + let initial = format!( + "{CURRENT}codex-api-key:\n - api-key: preserved-secret\n models: [{{name: my-alias}}]\n" + ); + let core = MockCore::new_v8(&initial, Failure::SecondYamlBeforeWrite); + let current = fetch_management_config_yaml(&core.config).await.unwrap(); + let updated = current.replace("my-alias", "renamed"); + assert!(put_management_alias_config_changes(&core.config, ¤t, &updated) + .await + .unwrap_err() + .contains("Original configuration was restored")); + let (persisted, requests) = core.finish(); + assert_eq!(persisted, yaml_json(&initial)); + assert!(requests + .iter() + .any(|request| request == "PUT /v8/management/config/requests/payload")); + assert!(requests + .iter() + .any(|request| request == "PUT /v8/management/config/api-keys/codex")); +} + #[tokio::test(flavor = "current_thread")] async fn alias_save_reports_failed_rollback_without_claiming_restoration() { let core = MockCore::new(CURRENT, Failure::Rollback); @@ -267,7 +490,7 @@ async fn alias_save_rejects_stale_snapshot_before_any_write() { .await; assert!(result.unwrap_err().contains("Configuration changed")); let (_, requests) = core.finish(); - assert_eq!(requests, ["GET /v0/management/config.yaml"]); + assert_eq!(requests, ["GET /v8/management/config.yaml"]); } #[tokio::test(flavor = "current_thread")] @@ -566,7 +789,9 @@ async fn desktop_backup_restore_rejects_core_changes_since_preview() { let preview = prepare_restore_plan(&core.config, "claude-desktop", &fixture.home, &fixture.id) .await .unwrap(); - let changed = format!("{}debug: true\n", fixture.core_b); + let changed = fixture + .core_b + .replace("model-b", "externally-changed-model"); put_management_alias_config_changes(&core.config, &fixture.core_b, &changed) .await .unwrap(); diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index 7e98c90..cb05c56 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -910,6 +910,42 @@ fn core_config_reads_logging_settings_and_applies_defaults() { ); } +#[test] +fn v8_core_config_reads_and_writes_canonical_nested_fields() { + let input = "config-version: 8\nserver:\n host: 0.0.0.0\n port: 9527\n commercial-mode: true\n tls: {enable: true, cert: cert.pem, key: key.pem}\nmanagement: {secret-key: management-key}\naccess: {api-keys: [client-key]}\noauth: {auth-dir: oauth-data}\nrequests:\n proxy-url: direct\n streaming: {bootstrap-retries: 4}\nrouting:\n strategy: fill-first\n retry: {request-retry: 5, max-retry-credentials: 6, max-retry-interval: 7}\n cooldown: {disable-cooling: true}\nobservability:\n logs: {debug: true, logging-to-file: true, logs-max-total-size-mb: 128, error-logs-max-files: 12, request-log: true}\n usage: {usage-statistics-enabled: false, redis-usage-queue-retention-seconds: 90}\nplugins: {enabled: true}\n"; + let document = serde_norway::from_str::(input).unwrap(); + let settings = core_config_settings_from_value(&document).unwrap(); + assert_eq!(settings.host, "0.0.0.0"); + assert_eq!(settings.port, 9527); + assert_eq!(settings.api_keys, vec!["client-key"]); + assert_eq!(settings.management_secret_key.as_deref(), Some("management-key")); + assert_eq!(settings.proxy_url, "direct"); + assert_eq!(settings.request_retry, 5); + assert_eq!(settings.max_retry_credentials, 6); + assert_eq!(settings.max_retry_interval, 7); + assert_eq!(settings.streaming_bootstrap_retries, 4); + assert!(settings.debug && settings.commercial_mode && settings.request_log); + + let mut config = GuiConfigFile::default(); + config.host = "127.0.0.1".into(); + config.port = 8318; + config.management_secret_key = "new-management-key".into(); + config.api_keys = vec![GuiApiKeyEntry { key: "new-client-key".into(), remark: String::new() }]; + config.proxy_url = "socks5://127.0.0.1:1080".into(); + config.request_retry = 2; + let updated = apply_gui_managed_settings(input, &config).unwrap(); + let updated = serde_norway::from_str::(&updated).unwrap(); + assert_eq!(updated["server"]["host"], "127.0.0.1"); + assert_eq!(updated["server"]["port"], 8318); + assert_eq!(updated["management"]["secret-key"], "new-management-key"); + assert_eq!(updated["access"]["api-keys"][0], "new-client-key"); + assert_eq!(updated["requests"]["proxy-url"], "socks5://127.0.0.1:1080"); + assert_eq!(updated["routing"]["retry"]["request-retry"], 2); + for legacy in ["host", "port", "remote-management", "proxy-url", "request-retry"] { + assert!(updated.get(legacy).is_none(), "legacy field leaked into v8 config: {legacy}"); + } +} + #[test] fn core_config_reads_proxy_and_session_affinity_fields() { let canonical = serde_norway::from_str::( diff --git a/src-tauri/src/tests/core_runtime.rs b/src-tauri/src/tests/core_runtime.rs index 06e4425..171ee6d 100644 --- a/src-tauri/src/tests/core_runtime.rs +++ b/src-tauri/src/tests/core_runtime.rs @@ -593,6 +593,24 @@ fn replacing_a_core_migrates_old_fields_into_the_new_template() { fs::remove_dir_all(root).unwrap(); } +#[test] +fn replacing_v7_with_v8_preserves_the_legacy_config_byte_for_byte() { + let root = agent_test_home("core-config-v8-preserve"); + let source = root.join("source"); + let target = root.join("target"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&target).unwrap(); + let old_config = "# User v7 config\nhost: 127.0.0.1\nport: 9527\nremote-management:\n secret-key: user-secret\napi-keys: [client-key]\nproxy-url: direct\n"; + let v8_template = "config-version: 8\nserver: {host: '', port: 8317}\nmanagement: {secret-key: ''}\naccess: {api-keys: [template-key]}\nrequests: {proxy-url: ''}\n"; + fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap(); + fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), v8_template).unwrap(); + + migrate_core_config_for_update(&source, &target).unwrap(); + + assert_eq!(fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap(), old_config); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn replacing_a_core_rejects_invalid_config_without_overwriting_files() { let root = agent_test_home("core-config-migrate-invalid"); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 72eb247..9a8b2fb 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -2132,7 +2132,7 @@ async fn wait_or_cancel(token: &CancellationToken, seconds: u64) { async fn fetch_usage_queue(config: &GuiConfigFile) -> Result, String> { let client = management_http_client()?; let response = client - .get(management_endpoint(config, "usage-queue")?) + .get(management_endpoint(config, "observability/usage/queue")?) .header("Authorization", management_authorization(config)?) .query(&[("count", USAGE_QUEUE_BATCH_SIZE)]) .send() diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts index f4aec89..52a4633 100644 --- a/src/mocks/browserMockRuntime.ts +++ b/src/mocks/browserMockRuntime.ts @@ -21,6 +21,16 @@ const PROVIDER_SECTIONS = [ 'claude-api-key', 'openai-compatibility', ] as const; +const V8_PROVIDER_BY_SECTION = { + 'gemini-api-key': 'gemini', + 'codex-api-key': 'codex', + 'claude-api-key': 'claude', + 'openai-compatibility': 'openai-compatibility', +} as const; +const SHARED_PROVIDER_FIELDS = new Set([ + 'priority', 'prefix', 'proxy-url', 'headers', 'models', 'excluded-models', + 'disable-cooling', 'request-retry', 'request-scoped-errors', +]); const clone = (value: Value): Value => structuredClone(value); const sleep = (delayMs: number) => delayMs > 0 @@ -640,8 +650,53 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { const query = asObject(request.query); const body = request.body; + const v8Provider = Object.entries(V8_PROVIDER_BY_SECTION).find( + ([, provider]) => path === `/config/api-keys/${provider}`, + ); + const groupRecords = (section: (typeof PROVIDER_SECTIONS)[number], records: JsonObject[]) => records.map((record, index) => { + if (section === 'openai-compatibility') { + const group: JsonObject = { ...record, keys: asArray(record['api-key-entries']).map((item) => clone(asObject(item))) }; + delete group['api-key-entries']; + return group; + } + const group: JsonObject = { name: `${V8_PROVIDER_BY_SECTION[section]}-${index + 1}` }; + const key: JsonObject = {}; + Object.entries(record).forEach(([field, value]) => { + if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value; + else key[field] = value; + }); + group.keys = [key]; + return group; + }); + const flattenGroups = (section: (typeof PROVIDER_SECTIONS)[number], groups: unknown) => asArray(groups).flatMap((item) => { + const group = asObject(item); + const keys = asArray(group.keys).map(asObject); + if (section === 'openai-compatibility') { + const record: JsonObject = { ...group }; + delete record.keys; + if (keys.length > 0) record['api-key-entries'] = keys; + return [record]; + } + const shared = Object.fromEntries(Object.entries(group).filter(([field]) => field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field))); + return keys.map((key) => ({ ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) })); + }); + if (method === 'GET' && path === '/config') { - return clone(state.providerConfig); + return { + 'config-version': 8, + 'api-keys': Object.fromEntries(PROVIDER_SECTIONS.map((section) => [ + V8_PROVIDER_BY_SECTION[section], + groupRecords(section, state.providerConfig[section]), + ])), + }; + } + if (v8Provider) { + const section = v8Provider[0] as (typeof PROVIDER_SECTIONS)[number]; + if (method === 'GET') return groupRecords(section, state.providerConfig[section]); + if (method === 'PUT') { + state.providerConfig[section] = flattenGroups(section, body).map((item) => clone(asObject(item))); + return { status: 'ok', 'config-version': 8 }; + } } if (PROVIDER_SECTIONS.some((section) => path === `/${section}`)) { const section = path.slice(1) as (typeof PROVIDER_SECTIONS)[number]; @@ -658,15 +713,24 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { return { [section]: clone(state.providerConfig[section]) }; } } - if (method === 'GET' && path === '/auth-files') { + if (method === 'GET' && path === '/credentials') { return { files: clone(state.authFiles), observed_at: new Date().toISOString() }; } - if (method === 'GET' && path === '/auth-files/download') { + if (method === 'GET' && path === '/credentials/download') { const file = findAuthFile(state, readString(query.name)); return clone(file ?? { name: query.name, excluded_models: [] }); } - if (method === 'GET' && path === '/auth-files/models') return mockModelDefinitions(); - if (method === 'GET' && path.startsWith('/model-definitions/')) return mockModelDefinitions(); + if (method === 'GET' && path === '/credentials/models') return mockModelDefinitions(); + if (method === 'GET' && path.startsWith('/routing/model-definitions/')) return mockModelDefinitions(); + if (method === 'GET' && path === '/config/oauth/excluded-models') { + return clone(state.oauthExcludedModels); + } + if (path.startsWith('/config/oauth/excluded-models/')) { + const provider = decodeURIComponent(path.slice('/config/oauth/excluded-models/'.length)); + if (method === 'PUT') state.oauthExcludedModels[provider] = asArray(body).map(String); + if (method === 'DELETE') delete state.oauthExcludedModels[provider]; + return { status: 'ok', 'config-version': 8 }; + } if (method === 'GET' && path === '/oauth-excluded-models') { return { 'oauth-excluded-models': clone(state.oauthExcludedModels) }; } @@ -679,19 +743,19 @@ function managementResponse(state: BrowserMockState, payload: JsonObject) { delete state.oauthExcludedModels[readString(query.provider)]; return null; } - if (method === 'PATCH' && (path === '/auth-files/fields' || path === '/auth-files/status')) { + if (method === 'PATCH' && (path === '/credentials/fields' || path === '/credentials/status')) { const patch = asObject(body); const file = findAuthFile(state, readString(patch.name)); if (file) Object.assign(file, patch); return clone(file ?? null); } - if (method === 'DELETE' && path === '/auth-files') { + if (method === 'DELETE' && path === '/credentials') { const index = state.authFiles.findIndex((file) => file.name === query.name); if (index >= 0) state.authFiles.splice(index, 1); return null; } - if (method === 'POST' && path === '/api-call') return mockApiCall(asObject(body)); - if (method === 'DELETE' && path === '/oauth-session') return null; + if (method === 'POST' && path === '/requests/api-call') return mockApiCall(asObject(body)); + if (method === 'DELETE' && path === '/oauth/session') return null; return {}; } diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index dd2fddf..7cb0fa6 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -9,6 +9,108 @@ type ManagementRequestOptions = { timeoutMs?: number; }; +const PROVIDER_PATHS = { + '/gemini-api-key': { provider: 'gemini', legacy: 'gemini-api-key' }, + '/interactions-api-key': { provider: 'interactions', legacy: 'interactions-api-key' }, + '/vertex-api-key': { provider: 'vertex', legacy: 'vertex-api-key' }, + '/codex-api-key': { provider: 'codex', legacy: 'codex-api-key' }, + '/claude-api-key': { provider: 'claude', legacy: 'claude-api-key' }, + '/xai-api-key': { provider: 'xai', legacy: 'xai-api-key' }, + '/meta-api-key': { provider: 'meta', legacy: 'meta-api-key' }, + '/openai-compatibility': { provider: 'openai-compatibility', legacy: 'openai-compatibility' }, +} as const; + +type ProviderPath = keyof typeof PROVIDER_PATHS; + +const SHARED_PROVIDER_FIELDS = new Set([ + 'priority', + 'prefix', + 'proxy-url', + 'headers', + 'models', + 'excluded-models', + 'disable-cooling', + 'request-retry', + 'request-scoped-errors', +]); + +const providerDefinition = (path: string) => PROVIDER_PATHS[path as ProviderPath]; + +async function optionalConfigValue( + path: string, fallback: ManagementJson, options: ManagementRequestOptions, +): Promise { + try { + return await invoke('management_request', { + request: { method: 'GET', path, query: normalizeQuery(options.query), timeoutMs: options.timeoutMs }, + }); + } catch (error) { + // Only an absent v8 config node is optional, not an unavailable endpoint or server. + const message = error instanceof Error ? error.message : error; + if (message === 'Management API error (404): not_found') return fallback; + throw error; + } +} + +export function flattenV8ProviderGroups(provider: string, payload: unknown): Record[] { + if (!Array.isArray(payload)) return []; + return payload.filter(isRecord).flatMap((group) => { + const keys = Array.isArray(group.keys) ? group.keys.filter(isRecord) : []; + if (provider === 'openai-compatibility') { + const record: Record = { ...group }; + delete record.keys; + if (keys.length > 0) record['api-key-entries'] = keys.map((key) => ({ ...key })); + return [record]; + } + const shared = Object.fromEntries( + Object.entries(group).filter(([key]) => key === 'base-url' || SHARED_PROVIDER_FIELDS.has(key)), + ); + return keys.map((key) => { + const overrides = Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)); + return { ...shared, ...overrides }; + }); + }); +} + +export function groupLegacyProviderRecords(provider: string, payload: unknown): Record[] { + if (!Array.isArray(payload)) return []; + return payload.filter(isRecord).map((record, index) => { + if (provider === 'openai-compatibility') { + const group: Record = { + ...record, + keys: Array.isArray(record['api-key-entries']) + ? record['api-key-entries'].filter(isRecord).map((key) => ({ ...key })) + : [], + }; + delete group['api-key-entries']; + return group; + } + const group: Record = { name: `${provider}-${index + 1}` }; + const key: Record = {}; + Object.entries(record).forEach(([field, value]) => { + if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value; + else key[field] = value; + }); + group.keys = [key]; + return group; + }); +} + +export function legacyManagementConfigView(payload: unknown): unknown { + if (!isRecord(payload)) return payload; + const legacy: Record = { ...payload }; + const upstream = isRecord(payload['api-keys']) ? payload['api-keys'] : {}; + Object.values(PROVIDER_PATHS).forEach(({ provider, legacy: legacyKey }) => { + legacy[legacyKey] = flattenV8ProviderGroups(provider, upstream[provider]); + }); + const access = isRecord(payload.access) ? payload.access : null; + if (access && Array.isArray(access['api-keys'])) legacy['api-keys'] = access['api-keys']; + const oauth = isRecord(payload.oauth) ? payload.oauth : null; + if (oauth && oauth['model-alias'] !== undefined) legacy['oauth-model-alias'] = oauth['model-alias']; + const requests = isRecord(payload.requests) ? payload.requests : null; + if (requests && requests.payload !== undefined) legacy.payload = requests.payload; + return legacy; +} + const normalizeQuery = ( query?: Record, ): Record | undefined => { @@ -29,15 +131,93 @@ async function request( path: string, options: ManagementRequestOptions = {}, ): Promise { - return invoke('management_request', { + if (path === '/oauth-excluded-models') { + if (method === 'PATCH' && isRecord(options.body)) { + const provider = readString(options.body, 'provider'); + if (!provider || !Array.isArray(options.body.models)) { + throw new Error('Invalid OAuth model exclusion update'); + } + return invoke('management_request', { + request: { + method: 'PUT', + path: `/config/oauth/excluded-models/${encodeURIComponent(provider)}`, + body: options.body.models, + }, + }); + } + if (method === 'DELETE') { + const provider = options.query?.provider; + if (typeof provider !== 'string' || !provider.trim()) { + throw new Error('Invalid OAuth model exclusion delete'); + } + return invoke('management_request', { + request: { + method: 'DELETE', + path: `/config/oauth/excluded-models/${encodeURIComponent(provider.trim())}`, + }, + }); + } + if (method === 'GET') { + const exclusions = await optionalConfigValue('/config/oauth/excluded-models', {}, options); + return { 'oauth-excluded-models': exclusions } as T; + } + } + const definition = providerDefinition(path); + if (method === 'GET' && definition) { + const groups = await optionalConfigValue(`/config/api-keys/${definition.provider}`, [], options); + return { [definition.legacy]: flattenV8ProviderGroups(definition.provider, groups) } as T; + } + if (method === 'PATCH' && path === '/openai-compatibility' && isRecord(options.body)) { + const index = Number(options.body.index); + const value = options.body.value; + if (!Number.isInteger(index) || index < 0 || !isRecord(value)) { + throw new Error('Invalid OpenAI compatibility update'); + } + const groups = await invoke('management_request', { + request: { method: 'GET', path: '/config/api-keys/openai-compatibility' }, + }); + const records = flattenV8ProviderGroups('openai-compatibility', groups); + if (!records[index]) throw new Error('OpenAI compatibility entry no longer exists'); + records[index] = { ...records[index], ...value }; + return invoke('management_request', { + request: { + method: 'PUT', + path: '/config/api-keys/openai-compatibility', + body: groupLegacyProviderRecords('openai-compatibility', records), + }, + }); + } + + let apiPath = path; + let body = options.body; + if (definition) { + apiPath = `/config/api-keys/${definition.provider}`; + if (method === 'PUT' || method === 'PATCH') { + body = groupLegacyProviderRecords(definition.provider, body); + } + } else if (path === '/api-call') { + apiPath = '/requests/api-call'; + } else if (path === '/oauth-session') { + apiPath = '/oauth/session'; + } else if (path.startsWith('/auth-files')) { + apiPath = `/credentials${path.slice('/auth-files'.length)}`; + } else if (path.startsWith('/model-definitions/')) { + apiPath = `/routing${path}`; + } + + const payload = await invoke('management_request', { request: { method, - path, + path: apiPath, query: normalizeQuery(options.query), - body: options.body, + body, timeoutMs: options.timeoutMs, }, }); + if (method === 'GET' && path === '/config') { + return legacyManagementConfigView(payload) as T; + } + return payload as T; } export const managementApi = { diff --git a/tests/fixtures/auth-file-requests.tsx b/tests/fixtures/auth-file-requests.tsx index 0a4405a..c7b734c 100644 --- a/tests/fixtures/auth-file-requests.tsx +++ b/tests/fixtures/auth-file-requests.tsx @@ -39,15 +39,15 @@ mockIPC(async (cmd, args) => { if (cmd === 'set_app_locale') return null; if (cmd === 'management_request') { const request = args?.request as { path: string; method: string; query?: Record; body?: Record }; - if (request.path === '/auth-files/download') { + if (request.path === '/credentials/download') { if (params.has('metadataError')) throw new Error('Fixture metadata read failed'); return { ...settingsFor(request.query?.name ?? '') }; } - if (request.path === '/auth-files/models') { + if (request.path === '/credentials/models') { if (params.has('catalogError')) throw new Error('Fixture catalog read failed'); return { models: [{ id: 'gpt-example' }, { id: 'gpt-old-example' }, { id: 'claude-example' }] }; } - if (request.path === '/auth-files/fields' && request.method === 'PATCH') { + if (request.path === '/credentials/fields' && request.method === 'PATCH') { if (params.has('saveError')) throw new Error('Fixture settings save failed'); const { name, headers, ...patch } = request.body ?? {}; const target = settingsFor(String(name)); @@ -64,7 +64,7 @@ mockIPC(async (cmd, args) => { if (output) output.textContent = JSON.stringify(request.body, null, 2); return { status: 'ok' }; } - if (request.path === '/api-call') { + if (request.path === '/requests/api-call') { await new Promise((resolve) => setTimeout(resolve, 800)); if (params.has('quotaError')) throw new Error('Fixture quota unavailable'); if (String(request.body?.url).endsWith('/consume')) { @@ -77,7 +77,7 @@ mockIPC(async (cmd, args) => { ? { available_count: Math.max(0, 2 - resetCount), applicable_available_count: 0, credits: [{ reset_type: 'codex_rate_limits', status: 'available', expires_at: '2030-01-01T00:00:00Z' }] } : { plan_type: 'Plus', rate_limit: { primary_window: { used_percent: resetCount ? 0 : 16, limit_window_seconds: 18000, reset_after_seconds: 3600 }, secondary_window: { used_percent: resetCount ? 0 : 72, limit_window_seconds: 604800, reset_after_seconds: 86400 } } }) }; } - if (request.path === '/auth-files' && request.method === 'GET') { + if (request.path === '/credentials' && request.method === 'GET') { refreshCount += 1; if (params.has('cards')) return { files: cardFiles.map((file) => ({ source: 'file', size: 2048, updated_at: '2026-09-20T01:20:00Z', success: 1200, failed: 28, recent_requests: buckets, ...file, ...settingsFor(file.name) })) }; if (params.has('health')) { diff --git a/tests/fixtures/devin.tsx b/tests/fixtures/devin.tsx index 56ed19c..1920b66 100644 --- a/tests/fixtures/devin.tsx +++ b/tests/fixtures/devin.tsx @@ -45,14 +45,14 @@ mockIPC(async (cmd, args: any) => { if (cmd === 'open_oauth_url') return null; if (cmd === 'management_request') { const request = args.request; - if (request.path === '/auth-files') return { files: fixture.files }; + if (request.path === '/credentials') return { files: fixture.files }; if (request.path === '/config') return {}; - if (request.path === '/auth-files/fields') return {}; - if (request.path === '/oauth-session') { + if (request.path === '/credentials/fields') return {}; + if (request.path === '/oauth/session') { if (fixture.cancelError) throw new Error('Cancellation failed'); return { status: 'ok', cancelled: true }; } - if (request.path === '/api-call') return fixture.quotaError + if (request.path === '/requests/api-call') return fixture.quotaError ? { status_code: 401, body: { error: 'Session expired' } } : { status_code: 200, body: { userStatus: { planStatus: { dailyQuotaRemainingPercent: 75, weeklyQuotaRemainingPercent: 0, diff --git a/tests/fixtures/model-selection.tsx b/tests/fixtures/model-selection.tsx index 0e115a9..852d88d 100644 --- a/tests/fixtures/model-selection.tsx +++ b/tests/fixtures/model-selection.tsx @@ -31,7 +31,7 @@ fixture.fixtureSaved = null; mockIPC(async (cmd, rawArgs) => { if (cmd === 'set_app_locale') return null; const args = rawArgs as { request?: { path?: string } }; - if (cmd === 'management_request' && args.request?.path === '/api-call') { + if (cmd === 'management_request' && args.request?.path === '/requests/api-call') { const response = fixture.fixtureFailFetch ? { status_code: 503, body: 'Fixture discovery failed' } : { status_code: 200, body: { data: fixture.fixtureCatalog.slice() } }; diff --git a/tests/managementApi.test.ts b/tests/managementApi.test.ts index e93f7e3..3eff8d4 100644 --- a/tests/managementApi.test.ts +++ b/tests/managementApi.test.ts @@ -1,5 +1,44 @@ -import { describe, expect, it } from 'bun:test'; -import { apiCallErrorMessage } from '../src/services/managementApi'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { clearMocks, mockIPC } from '@tauri-apps/api/mocks'; +import { + apiCallErrorMessage, + flattenV8ProviderGroups, + groupLegacyProviderRecords, + legacyManagementConfigView, + managementApi, +} from '../src/services/managementApi'; + +describe('optional v8 config reads', () => { + let originalWindow: PropertyDescriptor | undefined; + beforeEach(() => { + originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true }); + }); + afterEach(() => { + clearMocks(); + if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow); + else Reflect.deleteProperty(globalThis, 'window'); + }); + + it('defaults absent exclusions and provider groups without swallowing other errors', async () => { + for (const [path, key, empty] of [ + ['/oauth-excluded-models', 'oauth-excluded-models', {}], + ['/codex-api-key', 'codex-api-key', []], + ['/openai-compatibility', 'openai-compatibility', []], + ] as const) { + mockIPC(() => { throw 'Management API error (404): not_found'; }); + expect(await managementApi.get(path)).toEqual({ [key]: empty }); + for (const message of [ + 'Management API error (401): unauthorized', + 'Management API error (404): 404 page not found', + 'Management API request failed: connection refused', + ]) { + mockIPC(() => { throw new Error(message); }); + await expect(managementApi.get(path)).rejects.toThrow(message); + } + } + }); +}); describe('apiCallErrorMessage', () => { it('从对象和 JSON 字符串错误体中提取可读消息', () => { @@ -18,3 +57,62 @@ describe('apiCallErrorMessage', () => { expect(apiCallErrorMessage({ status_code: 429 })).toBe('Upstream returned HTTP 429'); }); }); + +describe('v8 Management API compatibility view', () => { + it('flattens grouped credentials and preserves per-key overrides', () => { + const groups = [{ + name: 'production', + 'base-url': 'https://example.test/v1', + priority: 10, + models: [{ name: 'model-a' }], + keys: [ + { 'api-key': 'first', priority: null, 'proxy-url': 'direct' }, + { 'api-key': 'second', priority: 0 }, + ], + }]; + expect(flattenV8ProviderGroups('codex', groups)).toEqual([ + { + 'base-url': 'https://example.test/v1', priority: 10, + models: [{ name: 'model-a' }], 'api-key': 'first', 'proxy-url': 'direct', + }, + { + 'base-url': 'https://example.test/v1', priority: 0, + models: [{ name: 'model-a' }], 'api-key': 'second', + }, + ]); + }); + + it('groups legacy records without moving key-specific fields into shared policy', () => { + expect(groupLegacyProviderRecords('claude', [{ + name: 'delegated-key', + 'api-key': 'secret', + 'base-url': 'https://claude.test', + models: [{ name: 'claude-test' }], + cloak: { mode: 'always' }, + }])).toEqual([{ + name: 'claude-1', + 'base-url': 'https://claude.test', + models: [{ name: 'claude-test' }], + keys: [{ name: 'delegated-key', 'api-key': 'secret', cloak: { mode: 'always' } }], + }]); + }); + + it('projects the v8 config tree into the legacy semantic view used by the UI', () => { + const view = legacyManagementConfigView({ + access: { 'api-keys': ['client-key'] }, + 'api-keys': { + codex: [{ name: 'codex-1', keys: [{ 'api-key': 'upstream-key' }] }], + 'openai-compatibility': [{ name: 'openrouter', keys: [{ 'api-key': 'openrouter-key' }] }], + }, + oauth: { 'model-alias': { codex: [{ name: 'gpt', alias: 'custom' }] } }, + requests: { payload: { override: [] } }, + }) as Record; + expect(view['api-keys']).toEqual(['client-key']); + expect(view['codex-api-key']).toEqual([{ 'api-key': 'upstream-key' }]); + expect(view['openai-compatibility']).toEqual([{ + name: 'openrouter', 'api-key-entries': [{ 'api-key': 'openrouter-key' }], + }]); + expect(view['oauth-model-alias']).toEqual({ codex: [{ name: 'gpt', alias: 'custom' }] }); + expect(view.payload).toEqual({ override: [] }); + }); +}); diff --git a/tests/quotaConfirmation.test.ts b/tests/quotaConfirmation.test.ts index 46013c2..45e64a6 100644 --- a/tests/quotaConfirmation.test.ts +++ b/tests/quotaConfirmation.test.ts @@ -26,7 +26,7 @@ beforeEach(() => { mockIPC((command, payload) => { if (command !== 'management_request') throw new Error('Unexpected IPC command: ' + command); const request = (payload as { request: { path: string; body: { url: string; method: string } } }).request; - expect(request.path).toBe('/api-call'); + expect(request.path).toBe('/requests/api-call'); upstreamCalls.push(request.body); if (request.body.url.endsWith('/consume') && consumeError) return { status_code: 409, body: 'reset denied' }; if (request.body.url.endsWith('/usage') && refreshError) return { status_code: 503, body: 'usage unavailable' };