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:
1 parent
02d5a2d469
commit
c52039dfed
21 files changed
+1271
-193
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
7.3.17
|
||||
8.0.0
|
||||
Generated
+1
-1
@@ -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,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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, ¤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<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")
|
||||
|
||||
@@ -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}"))?,
|
||||
)?;
|
||||
|
||||
@@ -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,
|
||||
§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<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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<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, ¤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();
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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')) {
|
||||
|
||||
Vendored
+4
-4
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
|
||||
Reference in new issue
Block a user