fix: handle missing v8 config nodes and normalize alias transaction views

- Default absent optional config nodes to empty values without hiding other errors
- Normalize empty parent mappings to prevent alias save and rollback conflicts
- Add regression tests for missing config, first-time alias creation, and rollback
This commit is contained in:
lzt404 committed 2026-09-27 20:03:33 +08:00
1 parent 02d5a2d469
commit c52039dfed
21 files changed
+1271 -193

No files matched your search

+1 -1
View File
@@ -1 +1 @@
7.3.17
8.0.0
+1 -1
View File
@@ -622,7 +622,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.2.97"
version = "0.3.6"
dependencies = [
"base64 0.22.1",
"chrono",
+1 -1
View File
@@ -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"
+36 -8
View File
@@ -5,6 +5,40 @@ fn alias_config_is_unchanged(current: &str, latest: &str) -> Result<bool, String
Ok(changes.oauth_model_aliases.is_none() && !changes.update_config_yaml)
}
fn validate_alias_transaction_state(
current: &str,
updated: &str,
latest: &str,
) -> Result<(), String> {
let parse = |content: &str| {
serde_norway::from_str::<serde_norway::Value>(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::<serde_norway::Value>(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);
}
+303 -14
View File
@@ -146,7 +146,7 @@ pub(crate) async fn fetch_active_oauth_alias_channels(
) -> Result<std::collections::HashSet<String>, 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, &current, 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<serde_norway::Value, String> {
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<serde_norway::Value, String> {
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<String, String> {
let mut document = serde_norway::from_str::<serde_norway::Value>(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::<serde_norway::Value>(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<serde_json::Value>,
@@ -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")
+100 -74
View File
@@ -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<Option<String>, 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}"))?,
)?;
+145 -43
View File
@@ -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<Option<String>, 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<Vec<String>, 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,
&section_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<bool, String> {
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<bool, String> {
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<Vec<String>, 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<Resul
pub(crate) fn extract_core_management_secret_key(
root: &serde_norway::Mapping,
) -> Result<Option<String>, 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
+15 -1
View File
@@ -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::<serde_norway::Value>(&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::<serde_norway::Value>(&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!(
+8 -10
View File
@@ -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<String, String> {
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<String, String>
}
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<T>(response: reqwest::Response) -> Result<T, String>
+4 -4
View File
@@ -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::<serde_json::Value>().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
+235 -10
View File
@@ -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, &current, &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, &current, &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::<serde_norway::Value>(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, &current, &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, &current, &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, &current, &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();
+36
View File
@@ -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::<serde_norway::Value>(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::<serde_norway::Value>(&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::<serde_norway::Value>(
+18
View File
@@ -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");
+1 -1
View File
@@ -2132,7 +2132,7 @@ async fn wait_or_cancel(token: &CancellationToken, seconds: u64) {
async fn fetch_usage_queue(config: &GuiConfigFile) -> Result<Vec<Value>, 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()
+73 -9
View File
@@ -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): 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 {};
}
+183 -3
View File
@@ -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<unknown> {
try {
return await invoke<unknown>('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<string, unknown>[] {
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<string, unknown> = { ...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<string, unknown>[] {
if (!Array.isArray(payload)) return [];
return payload.filter(isRecord).map((record, index) => {
if (provider === 'openai-compatibility') {
const group: Record<string, unknown> = {
...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<string, unknown> = { name: `${provider}-${index + 1}` };
const key: Record<string, unknown> = {};
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<string, unknown> = { ...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<string, string | number | boolean | undefined>,
): Record<string, string> | undefined => {
@@ -29,15 +131,93 @@ async function request<T = ManagementJson>(
path: string,
options: ManagementRequestOptions = {},
): Promise<T> {
return invoke<T>('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<T>('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<T>('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<unknown>('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<T>('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<unknown>('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 = {
+5 -5
View File
@@ -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<string, string>; body?: Record<string, unknown> };
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')) {
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -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() } };
+100 -2
View File
@@ -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<string, unknown>;
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: [] });
});
});
+1 -1
View File
@@ -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' };