feat(config): support CLIProxyAPI v8.0.10 template settings

- Add structured editors for request, OAuth, plugin, and concurrency settings
- Expand advanced configuration for all eight provider families and credentials
- Preserve YAML comments, secrets, unknown fields, and explicit overrides
- Add validation, conflict detection, default restoration, and restart notices
- Improve responsive layouts and localize configuration controls
- Fix provider health checks and extend mocks and integration tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 4d1afc82ef
commit c89e2fb020
46 files changed
+3839 -235

No files matched your search

+2
View File
@@ -5,6 +5,7 @@ mod alias_edit;
mod alias_save;
mod aliases;
mod commands;
mod extended;
mod provider_groups;
mod settings;
mod yaml;
@@ -12,6 +13,7 @@ pub(crate) use alias_edit::*;
pub(crate) use alias_save::*;
pub(crate) use aliases::*;
pub(crate) use commands::*;
pub(crate) use extended::*;
pub(crate) use provider_groups::*;
pub(crate) use settings::*;
pub(crate) use yaml::*;
+11 -1
View File
@@ -12,7 +12,7 @@ pub(crate) struct OAuthAliasChannel {
pub(crate) force_mapping: bool,
}
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [
pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 9] = [
OAuthAliasChannel {
key: "vertex",
provider: "Vertex OAuth",
@@ -85,6 +85,15 @@ pub(crate) const OAUTH_ALIAS_CHANNELS: [OAuthAliasChannel; 8] = [
supports_fast: false,
force_mapping: false,
},
OAuthAliasChannel {
key: "meta",
provider: "Meta OAuth",
kind: "meta-oauth",
protocol: "openai",
supports_reasoning: false,
supports_fast: false,
force_mapping: false,
},
];
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -137,6 +146,7 @@ pub(crate) fn normalize_oauth_alias_channel(value: &str) -> Option<&'static str>
"kimi" | "moonshot" => Some("kimi"),
"devin" | "cognition" => Some("devin"),
"xai" | "x-ai" | "grok" => Some("xai"),
"meta" => Some("meta"),
_ => None,
}
}
+2 -2
View File
@@ -166,8 +166,8 @@ pub(crate) fn save_network_endpoint_settings(
return Err("Port must be between 1 and 65535".to_string());
}
let host = normalize_optional_config_string(settings.host, "Listen IP")?;
if host.parse::<IpAddr>().is_err() {
return Err("Listen IP must be a valid IPv4 or IPv6 address".to_string());
if !host.is_empty() && !host.eq_ignore_ascii_case("localhost") && host.parse::<IpAddr>().is_err() {
return Err("Listen address must be empty, localhost, or a valid IPv4 or IPv6 address".to_string());
}
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
File diff suppressed because it is too large. Load diff
+26 -24
View File
@@ -26,7 +26,8 @@ pub(crate) fn validate_api_key_remark(remark: &str) -> Result<(), String> {
pub(crate) fn validate_api_access_provider_section(section: &str) -> Result<(), String> {
if matches!(
section,
"gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility"
"gemini-api-key" | "interactions-api-key" | "vertex-api-key" | "codex-api-key"
| "claude-api-key" | "xai-api-key" | "meta-api-key" | "openai-compatibility"
) {
Ok(())
} else {
@@ -44,6 +45,10 @@ pub(crate) fn usage_provider_section(provider: &str) -> Option<&'static str> {
"codex" => Some("codex-api-key"),
"claude" => Some("claude-api-key"),
"gemini" | "aistudio" => Some("gemini-api-key"),
"interactions" => Some("interactions-api-key"),
"vertex" | "vertex-ai" => Some("vertex-api-key"),
"xai" | "grok" => Some("xai-api-key"),
"meta" => Some("meta-api-key"),
"openai" | "openai-compatibility" => Some("openai-compatibility"),
_ => None,
}
@@ -113,7 +118,8 @@ pub(crate) fn generate_management_secret_key() -> Result<String, String> {
pub(crate) fn management_secret_requires_rotation(secret_key: &str) -> bool {
let secret_key = secret_key.trim();
secret_key.is_empty() || is_hashed_management_secret_key(secret_key)
// Empty is an intentional disabled Management API, including after restart.
is_hashed_management_secret_key(secret_key)
}
pub(crate) fn ensure_strong_management_secret(config: &mut GuiConfigFile) -> Result<bool, String> {
@@ -150,10 +156,10 @@ pub(crate) fn is_hashed_management_secret_key(secret_key: &str) -> bool {
}
pub(crate) fn validate_routing_strategy(strategy: &str) -> Result<(), String> {
if matches!(strategy, "round-robin" | "fill-first") {
if matches!(strategy, "round-robin" | "weighted-round-robin" | "fill-first") {
return Ok(());
}
Err("Routing strategy supports only round-robin or fill-first".to_string())
Err("Routing strategy supports round-robin, weighted-round-robin, or fill-first".to_string())
}
pub(crate) fn normalize_optional_config_string(
@@ -1423,8 +1429,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
config.management_secret_key = core_settings
.management_secret_key
.as_deref()
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
.unwrap_or_default()
.unwrap_or(&config.management_secret_key)
.to_string();
}
if presence.routing_strategy.is_none() {
@@ -1464,8 +1469,7 @@ pub(crate) fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
config.management_secret_key = read_installed_core_config_settings()
.ok()
.and_then(|settings| settings.management_secret_key)
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
.unwrap_or_default();
.unwrap_or_else(|| config.management_secret_key.clone());
changed = true;
}
if presence.host.is_none() || presence.auth_dir.is_none() {
@@ -1783,15 +1787,6 @@ pub(crate) fn sanitize_gui_config_at(
changed = true;
}
let host = config.host.trim();
let host = if host.is_empty() {
if config.allow_lan {
"0.0.0.0"
} else {
"127.0.0.1"
}
} else {
host
};
if config.host != host {
config.host = host.to_string();
changed = true;
@@ -1801,14 +1796,16 @@ pub(crate) fn sanitize_gui_config_at(
config.allow_lan = allow_lan;
changed = true;
}
match migrate_auth_dir_at(config, install_dir, persistent_auth_dir) {
Ok(migrated) => changed |= migrated,
Err(error) => {
eprintln!("Failed to recover OAuth directory; keeping existing configuration: {error}");
if !config.auth_dir_user_selected {
match migrate_auth_dir_at(config, install_dir, persistent_auth_dir) {
Ok(migrated) => changed |= migrated,
Err(error) => {
eprintln!("Failed to recover OAuth directory; keeping existing configuration: {error}");
}
}
}
if config.auth_dir.trim().is_empty()
|| Path::new(config.auth_dir.trim()) == persistent_auth_dir
|| (!config.auth_dir_user_selected && Path::new(config.auth_dir.trim()) == persistent_auth_dir)
{
config.auth_dir = DEFAULT_AUTH_DIR.to_string();
changed = true;
@@ -1934,6 +1931,7 @@ pub(crate) fn write_gui_config_to_path(
("close-behavior", value(config.close_behavior.as_str())),
("default-terminal", value(config.default_terminal.as_str())),
("auth-dir", value(config.auth_dir.as_str())),
("auth-dir-user-selected", value(config.auth_dir_user_selected)),
(
"management-secret-key",
value(config.management_secret_key.as_str()),
@@ -2066,7 +2064,7 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
if config.port == 0 {
return Err("GUI configuration port must be between 1 and 65535".to_string());
}
if config.host.trim().is_empty() || config.host.chars().any(char::is_control) {
if config.host.chars().any(char::is_control) {
return Err("Invalid GUI configuration host".to_string());
}
if config.auth_dir.trim().is_empty() || config.auth_dir.chars().any(char::is_control) {
@@ -2104,7 +2102,11 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
}
validate_api_key_remark(&entry.remark)?;
}
validate_strong_management_secret_key(&config.management_secret_key)?;
if config.management_secret_key.is_empty() {
validate_management_secret_key(&config.management_secret_key)?;
} else {
validate_strong_management_secret_key(&config.management_secret_key)?;
}
validate_routing_strategy(config.routing_strategy.trim())?;
if config.proxy_override {
network_proxy::normalize_optional_proxy_url(&config.proxy_url)?;
+3 -7
View File
@@ -1468,7 +1468,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "auth-dir must be a string".to_string())
})
.transpose()?
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string());
.unwrap_or_else(|| "~/.cli-proxy-api".to_string());
let debug = v8_or_legacy(&["observability", "logs", "debug"], &["debug"])
.map(|value| {
value
@@ -1622,9 +1622,8 @@ pub(crate) fn core_config_settings_from_value(
let max_retry_interval = v8_or_legacy(&["routing", "retry", "max-retry-interval"], &["max-retry-interval"])
.map(|value| {
value
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.ok_or_else(|| "max-retry-interval must be a non-negative integer".to_string())
.as_i64()
.ok_or_else(|| "max-retry-interval must be an integer".to_string())
})
.transpose()?
.unwrap_or(0);
@@ -1743,9 +1742,6 @@ pub(crate) fn extract_core_management_secret_key(
.ok_or_else(|| "remote-management.secret-key must be a string".to_string())?
.trim()
.to_string();
if value.is_empty() {
return Ok(None);
}
Ok(Some(value))
}
+11 -8
View File
@@ -154,7 +154,7 @@ const DEFAULT_AGENT_TERMINAL: &str = "auto";
const DEFAULT_API_KEY_INITIAL_REMARK: &str = "Default key";
const DEFAULT_REQUEST_RETRY: u32 = 3;
const DEFAULT_MAX_RETRY_CREDENTIALS: u32 = 0;
const DEFAULT_MAX_RETRY_INTERVAL: u32 = 30;
const DEFAULT_MAX_RETRY_INTERVAL: i64 = 30;
const DEFAULT_STREAMING_BOOTSTRAP_RETRIES: u32 = 0;
const DEFAULT_DISABLE_COOLING: bool = false;
const DEFAULT_LOGS_MAX_TOTAL_SIZE_MB: u32 = 0;
@@ -603,6 +603,7 @@ struct GuiConfigFile {
window_width: Option<u32>,
window_height: Option<u32>,
auth_dir: String,
auth_dir_user_selected: bool,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
@@ -628,7 +629,7 @@ struct GuiConfigFile {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -909,6 +910,7 @@ impl Default for GuiConfigFile {
window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH),
window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT),
auth_dir: DEFAULT_AUTH_DIR.to_string(),
auth_dir_user_selected: false,
api_keys: vec![default_api_key_entry()],
api_access_remarks: Vec::new(),
management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -975,7 +977,7 @@ struct GuiConfigPresence {
disable_cooling: Option<bool>,
request_retry: Option<u32>,
max_retry_credentials: Option<u32>,
max_retry_interval: Option<u32>,
max_retry_interval: Option<i64>,
streaming_bootstrap_retries: Option<u32>,
}
@@ -1467,7 +1469,7 @@ struct GuiNetworkRoutingSettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -1489,7 +1491,7 @@ struct GuiRetrySettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -1554,7 +1556,7 @@ struct CoreConfigSettings {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
#[allow(dead_code)]
#[serde(skip_serializing)]
@@ -1593,7 +1595,7 @@ struct CoreConfigView {
disable_cooling: bool,
request_retry: u32,
max_retry_credentials: u32,
max_retry_interval: u32,
max_retry_interval: i64,
streaming_bootstrap_retries: u32,
}
@@ -2208,7 +2210,6 @@ impl GuiConfigState {
if let Some(secret_key) = settings
.management_secret_key
.as_deref()
.filter(|secret_key| !secret_key.is_empty())
.filter(|secret_key| !is_hashed_management_secret_key(secret_key))
{
config.management_secret_key = secret_key.to_string();
@@ -2675,6 +2676,8 @@ fn main() {
get_core_sensitive_words_settings,
save_core_sensitive_words_settings,
get_core_config_settings,
get_extended_core_config,
save_extended_core_config,
save_core_logging_settings,
set_core_request_log,
add_core_api_key,
+21 -2
View File
@@ -19,6 +19,8 @@ pub(crate) struct ProviderHealthProbeRequest {
header: HashMap<String, String>,
data: String,
protocol: String,
#[serde(default)]
source_provider: Option<String>,
timeout_ms: Option<u64>,
#[serde(default)]
model: String,
@@ -125,6 +127,15 @@ fn provider_health_json_has_text(protocol: &str, value: &serde_json::Value) -> b
})
})
}),
"interactions" => {
let event = value.get("event_type").or_else(|| value.get("type")).and_then(serde_json::Value::as_str);
(event == Some("content.delta")
&& value.pointer("/delta/type").and_then(serde_json::Value::as_str) == Some("text")
&& provider_health_value_has_text(value.pointer("/delta/text")))
|| (event == Some("interaction.complete")
&& value.pointer("/interaction/status").and_then(serde_json::Value::as_str) == Some("completed")
&& provider_health_value_has_text(value.pointer("/interaction/outputs")))
}
_ => false,
}
}
@@ -144,6 +155,11 @@ pub(crate) fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> b
}
fn provider_health_json_has_terminal_success(protocol: &str, value: &serde_json::Value) -> bool {
if protocol == "interactions" {
let event = value.get("event_type").or_else(|| value.get("type")).and_then(serde_json::Value::as_str);
return event == Some("interaction.complete")
&& value.pointer("/interaction/status").and_then(serde_json::Value::as_str) == Some("completed");
}
if protocol != "gemini" {
return false;
}
@@ -238,6 +254,7 @@ fn provider_health_usage_provider(protocol: &str) -> &str {
"openai-chat" => "openai",
"claude" => "claude",
"gemini" => "gemini",
"interactions" => "interactions",
_ => "unknown",
}
}
@@ -258,7 +275,9 @@ fn persist_provider_health_success(
"source": request.source.as_str(),
"auth_index": request.auth_index.as_str(),
"failed": false,
"provider": provider_health_usage_provider(&request.protocol),
"provider": request.source_provider.as_deref()
.filter(|provider| matches!(*provider, "interactions" | "vertex" | "xai" | "meta"))
.unwrap_or_else(|| provider_health_usage_provider(&request.protocol)),
"model": request.model.as_str(),
"executor_type": "DesktopProviderHealthCheck",
"endpoint": endpoint,
@@ -296,7 +315,7 @@ pub(crate) async fn provider_health_probe(
}
if !matches!(
request.protocol.as_str(),
"openai-chat" | "openai-responses" | "claude" | "gemini"
"openai-chat" | "openai-responses" | "claude" | "gemini" | "interactions"
) {
return Err("Unsupported health check protocol".to_string());
}
+1 -1
View File
@@ -94,7 +94,7 @@ fn gui_config_defaults_are_stable() {
assert!(content.contains("[[api-keys]]"));
assert!(content.contains("key = \"123456\""));
assert!(content.contains("remark = \"Default key\""));
assert!(content.contains("management-secret-key = \"\""));
assert!(content.contains(&format!("management-secret-key = \"{LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY}\"")));
assert!(content.contains("plugins-enabled = false"));
assert!(content.contains("routing-strategy = \"round-robin\""));
assert!(content.contains("download-source = \"github\""));
+56 -5
View File
@@ -116,7 +116,7 @@ fn webui_management_secret_requires_a_non_empty_plaintext_value() {
}
#[test]
fn management_secret_rotation_replaces_empty_values_and_preserves_custom_values() {
fn management_secret_rotation_preserves_disabled_and_custom_values() {
let fresh = GuiConfigFile::default();
assert_eq!(
fresh.management_secret_key,
@@ -130,9 +130,9 @@ fn management_secret_rotation_replaces_empty_values_and_preserves_custom_values(
management_secret_key: String::new(),
..GuiConfigFile::default()
};
assert!(ensure_strong_management_secret(&mut empty).unwrap());
assert!(empty.management_secret_key.starts_with("wui-Aa9_"));
assert!(empty.management_secret_key.len() >= 50);
assert!(!ensure_strong_management_secret(&mut empty).unwrap());
assert!(empty.management_secret_key.is_empty());
assert!(validate_gui_config(&empty).is_ok());
let mut legacy = GuiConfigFile {
management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -207,6 +207,7 @@ fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() {
let mut config = GuiConfigFile {
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
auth_dir_user_selected: false,
..GuiConfigFile::default()
};
assert!(sanitize_gui_config(&mut config).unwrap());
@@ -1886,7 +1887,7 @@ fn core_config_validates_keys_and_routing_strategy() {
assert!(validate_core_api_key("").is_err());
assert!(validate_core_api_key("contains space").is_err());
assert!(validate_routing_strategy("round-robin").is_ok());
assert!(validate_routing_strategy("weighted-round-robin").is_err());
assert!(validate_routing_strategy("weighted-round-robin").is_ok());
assert!(validate_routing_strategy("fill-first").is_ok());
assert!(validate_routing_strategy("random").is_err());
}
@@ -1949,6 +1950,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() {
window_width: None,
window_height: None,
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
auth_dir_user_selected: false,
api_keys: vec![
default_api_key_entry(),
GuiApiKeyEntry {
@@ -2119,3 +2121,52 @@ fn startup_merge_can_shrink_template_api_key_sequence() {
config.management_secret_key
);
}
#[test]
fn v8_signed_retry_weighted_routing_and_empty_host_roundtrip() {
let content = "config-version: 8\nserver: {host: '', port: 8317}\nrouting: {strategy: weighted-round-robin, retry: {max-retry-interval: -1}}\nmanagement: {secret-key: ''}\n";
let settings = core_config_settings_from_value(&serde_norway::from_str(content).unwrap()).unwrap();
let mut config = GuiConfigFile::default();
apply_core_settings_to_gui_config(&mut config, &settings);
sanitize_gui_config(&mut config).unwrap();
validate_gui_config(&config).unwrap();
assert_eq!(config.host, "");
assert_eq!(config.max_retry_interval, -1);
assert_eq!(config.routing_strategy, "weighted-round-robin");
assert!(!ensure_strong_management_secret(&mut config).unwrap());
assert!(config.management_secret_key.is_empty());
let serialized = toml::to_string(&config).unwrap();
let mut restored: GuiConfigFile = toml::from_str(&serialized).unwrap();
assert!(!ensure_strong_management_secret(&mut restored).unwrap());
assert_eq!(restored.max_retry_interval, -1);
let saved = apply_gui_managed_settings(content, &restored).unwrap();
let document: serde_norway::Value = serde_norway::from_str(&saved).unwrap();
assert_eq!(document["server"]["host"], "");
assert_eq!(document["management"]["secret-key"], "");
assert_eq!(document["routing"]["retry"]["max-retry-interval"], -1);
}
#[test]
fn explicitly_selected_auth_directory_is_not_moved_on_restart() {
let root = agent_test_home("selected-auth-no-migration");
let install_dir = root.join("cpa-core");
let selected = install_dir.join("oauth");
let persistent = root.join("oauth");
fs::create_dir_all(&selected).unwrap();
fs::write(selected.join("account.json"), "credential").unwrap();
let mut config = GuiConfigFile { auth_dir: "oauth".into(), auth_dir_user_selected: true, ..GuiConfigFile::default() };
sanitize_gui_config_at(&mut config, &install_dir, &persistent).unwrap();
assert_eq!(config.auth_dir, "oauth");
assert!(selected.join("account.json").exists());
assert!(!persistent.exists());
let config_path = root.join("selected.toml");
write_gui_config_to_path(&config, &config_path).unwrap();
let mut restored: GuiConfigFile = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(restored.auth_dir_user_selected);
sanitize_gui_config_at(&mut restored, &install_dir, &persistent).unwrap();
assert_eq!(restored.auth_dir, "oauth");
assert!(selected.join("account.json").exists());
fs::remove_dir_all(root).unwrap();
}
+1
View File
@@ -49,6 +49,7 @@ fn oauth_auth_provider_names_map_to_alias_channels() {
Some("aistudio")
);
assert_eq!(normalize_oauth_alias_channel("grok"), Some("xai"));
assert_eq!(normalize_oauth_alias_channel("meta"), Some("meta"));
}
#[test]
+15
View File
@@ -97,3 +97,18 @@ data: {"delta":{"type":"text_delta","text":"H"}}
gemini_thought_only
));
}
#[test]
fn interactions_health_probe_recognizes_content_and_completed_events() {
assert!(provider_health_stream_has_text("interactions", br#"data: {"event_type":"content.delta","delta":{"type":"text","text":"Hello"}}
"#));
assert!(provider_health_stream_has_text("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"completed","outputs":[{"type":"text","text":"Hello"}]}}
"#));
assert!(provider_health_stream_has_terminal_success("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"completed"}}
"#));
assert!(!provider_health_stream_has_terminal_success("interactions", br#"data: {"event_type":"interaction.complete","interaction":{"status":"failed"}}
"#));
assert!(!provider_health_stream_has_text("interactions", br#"data: {"event_type":"content.delta","delta":{"type":"image","text":"metadata"}}
"#));
}
+1
View File
@@ -5133,6 +5133,7 @@ mod tests {
window_width: None,
window_height: None,
auth_dir: String::new(),
auth_dir_user_selected: false,
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),