Enhance support for the v8 kernel

This commit is contained in:
lzt404 committed 2026-09-28 16:42:22 +08:00
1 parent 9991aea818
commit 59b05e9281
25 files changed
+1463 -185

No files matched your search

+209 -26
View File
@@ -94,41 +94,64 @@ fn api_access_locator_identity(
Some((sha256_bytes(&identity), api_key_hashes))
}
fn find_api_access_record_remark(
config: &GuiConfigFile,
provider_section: &str,
locator: &ApiAccessRemarkLocator,
) -> Option<String> {
let (record_hash, api_key_hashes) = api_access_locator_identity(provider_section, locator)?;
let exact_entries = config.api_access_remarks.iter().filter(|entry| {
entry.provider_section == provider_section
&& entry.record_hash == record_hash
&& api_key_hashes.contains(&entry.api_key_hash)
});
let mut exact_remark = None;
for entry in exact_entries {
exact_remark = Some(entry.remark.clone());
if !entry.remark.is_empty() {
break;
}
}
exact_remark
}
fn resolve_api_access_remark(
config: &GuiConfigFile,
query: &ApiAccessRemarkQuery,
) -> Result<String, String> {
validate_api_access_provider_section(&query.provider_section)?;
let Some((record_hash, api_key_hashes)) =
let mut locators = vec![query.locator.clone()];
if matches!(
query.provider_section.as_str(),
"gemini-api-key" | "codex-api-key" | "claude-api-key"
) && !query.locator.provider_name.trim().is_empty()
{
let mut unnamed = query.locator.clone();
unnamed.provider_name.clear();
locators.push(unnamed);
}
for mut locator in locators {
if let Some(remark) =
find_api_access_record_remark(config, &query.provider_section, &locator)
{
return Ok(remark);
}
if !locator.config_identity.is_empty() {
locator.config_identity.clear();
if let Some(remark) =
find_api_access_record_remark(config, &query.provider_section, &locator)
{
return Ok(remark);
}
}
}
let Some((_, api_key_hashes)) =
api_access_locator_identity(&query.provider_section, &query.locator)
else {
return Ok(String::new());
};
let exact_entries = config.api_access_remarks.iter().filter(|entry| {
entry.provider_section == query.provider_section
&& entry.record_hash == record_hash
&& api_key_hashes.contains(&entry.api_key_hash)
});
let mut exact_found = false;
let mut exact_remark = String::new();
for entry in exact_entries {
exact_found = true;
if !entry.remark.is_empty() {
exact_remark = entry.remark.clone();
break;
}
}
if exact_found {
return Ok(exact_remark);
}
if !query.locator.config_identity.is_empty() {
let mut legacy_query = query.clone();
legacy_query.locator.config_identity.clear();
return resolve_api_access_remark(config, &legacy_query);
}
Ok(api_key_hashes
.iter()
.find_map(|hash| {
@@ -274,6 +297,166 @@ mod tests {
}
}
fn store_record_remark(
config: &mut GuiConfigFile,
provider_section: &str,
locator: &ApiAccessRemarkLocator,
remark: &str,
) {
let (record_hash, api_key_hashes) =
api_access_locator_identity(provider_section, locator).unwrap();
for api_key_hash in api_key_hashes {
config.api_access_remarks.push(GuiApiAccessRemark {
provider_section: provider_section.to_string(),
api_key_hash,
record_hash: record_hash.clone(),
remark: remark.to_string(),
});
}
}
#[test]
fn v8_group_names_preserve_and_migrate_unnamed_record_remarks() {
for provider_section in ["gemini-api-key", "codex-api-key", "claude-api-key"] {
for config_identity in ["", r#"{"models":[{"name":"a"}]}"#] {
let mut config = GuiConfigFile::default();
let mut legacy = locator("https://api.example/v1", &["shared-key"]);
legacy.config_identity = config_identity.into();
store_record_remark(&mut config, provider_section, &legacy, "original");
let legacy_hash = config.api_access_remarks[0].record_hash.clone();
let mut current = legacy.clone();
current.provider_name = "production".into();
current.config_identity = r#"{"models":[{"name":"a"}]}"#.into();
let current_query = ApiAccessRemarkQuery {
provider_section: provider_section.into(),
locator: current.clone(),
};
assert_eq!(
resolve_api_access_remark(&config, &current_query).unwrap(),
"original",
"{provider_section}, legacy config identity: {config_identity}"
);
let mut other = current.clone();
other.provider_name = "staging".into();
other.config_identity = r#"{"models":[{"name":"b"}]}"#.into();
let mut other_update = update(
Vec::new(),
vec![other.clone()],
vec![current.clone(), other.clone()],
"other",
);
other_update.provider_section = provider_section.into();
apply_api_access_remark_update(&mut config, other_update).unwrap();
assert_eq!(config.api_access_remarks.len(), 2);
let current_hash = api_access_locator_identity(provider_section, &current)
.unwrap()
.0;
assert!(config.api_access_remarks.iter().any(|entry| {
entry.record_hash == current_hash && entry.remark == "original"
}));
assert!(config
.api_access_remarks
.iter()
.all(|entry| entry.record_hash != legacy_hash));
assert_eq!(
resolve_api_access_remark(&config, &current_query).unwrap(),
"original"
);
assert_eq!(
resolve_api_access_remark(
&config,
&ApiAccessRemarkQuery {
provider_section: provider_section.into(),
locator: other,
}
)
.unwrap(),
"other"
);
}
}
}
#[test]
fn named_record_remarks_override_unnamed_fallbacks_including_empty_notes() {
for named_config_identity in ["", r#"{"models":[{"name":"a"}]}"#] {
for remark in ["updated", ""] {
let mut config = GuiConfigFile::default();
let mut legacy = locator("https://api.example/v1", &["shared-key"]);
legacy.config_identity = r#"{"models":[{"name":"a"}]}"#.into();
store_record_remark(&mut config, "codex-api-key", &legacy, "legacy");
let mut current = legacy.clone();
current.provider_name = "production".into();
let mut saved = current.clone();
saved.config_identity = named_config_identity.into();
store_record_remark(&mut config, "codex-api-key", &saved, remark);
assert_eq!(
resolve_api_access_remark(&config, &query(current)).unwrap(),
remark
);
}
}
}
#[test]
fn unnamed_fallback_preserves_config_scope_and_explicitly_empty_notes() {
let mut config = GuiConfigFile::default();
let legacy = locator("https://api.example/v1", &["shared-key"]);
store_record_remark(&mut config, "codex-api-key", &legacy, "unscoped");
for (model, remark) in [("a", "first"), ("b", "")] {
let mut scoped = legacy.clone();
scoped.config_identity = format!(r#"{{"models":[{{"name":"{model}"}}]}}"#);
store_record_remark(&mut config, "codex-api-key", &scoped, remark);
}
for (model, expected) in [("a", "first"), ("b", ""), ("c", "unscoped")] {
let mut current = legacy.clone();
current.provider_name = "production".into();
current.config_identity = format!(r#"{{"models":[{{"name":"{model}"}}]}}"#);
assert_eq!(
resolve_api_access_remark(&config, &query(current)).unwrap(),
expected
);
}
}
#[test]
fn group_name_fallback_does_not_cross_openai_names_or_record_boundaries() {
let mut config = GuiConfigFile::default();
let legacy = locator("https://api.example/v1", &["shared-key"]);
for provider_section in ["codex-api-key", "openai-compatibility"] {
store_record_remark(&mut config, provider_section, &legacy, "legacy");
}
let mut named = legacy.clone();
named.provider_name = "production".into();
let mut different_url = named.clone();
different_url.base_url = "https://other.example/v1".into();
let mut different_key = named.clone();
different_key.api_keys = vec!["other-key".into()];
for (provider_section, locator) in [
("openai-compatibility", named.clone()),
("claude-api-key", named),
("codex-api-key", different_url),
("codex-api-key", different_key),
] {
assert_eq!(
resolve_api_access_remark(
&config,
&ApiAccessRemarkQuery {
provider_section: provider_section.into(),
locator,
}
)
.unwrap(),
""
);
}
}
#[test]
fn remarks_are_scoped_to_provider_records_that_share_an_api_key() {
let mut config = GuiConfigFile::default();
+66 -7
View File
@@ -291,6 +291,40 @@ fn is_v8_shared_provider_field(field: &str) -> bool {
)
}
fn normalize_v8_provider_record(
provider: &str,
record: &serde_norway::Mapping,
) -> serde_norway::Mapping {
let mut record = record.clone();
if provider == "openai-compatibility" {
record.remove(yaml_key("test-model"));
record.remove(yaml_key("testModel"));
}
if let Some(models) = record
.get_mut(yaml_key("models"))
.and_then(serde_norway::Value::as_sequence_mut)
{
for model in models {
let Some(thinking) = model
.as_mapping_mut()
.and_then(|model| model.get_mut(yaml_key("thinking")))
.and_then(serde_norway::Value::as_mapping_mut)
else {
continue;
};
for (json, yaml) in [
("zero_allowed", "zero-allowed"),
("dynamic_allowed", "dynamic-allowed"),
] {
if let Some(value) = thinking.remove(yaml_key(json)) {
thinking.entry(yaml_key(yaml)).or_insert(value);
}
}
}
}
record
}
pub(crate) fn flatten_v8_provider_groups(
provider: &str,
groups: &serde_norway::Value,
@@ -317,7 +351,9 @@ pub(crate) fn flatten_v8_provider_groups(
serde_norway::Value::Sequence(keys.clone()),
);
}
records.push(serde_norway::Value::Mapping(record));
records.push(serde_norway::Value::Mapping(normalize_v8_provider_record(
provider, &record,
)));
continue;
}
for (key_index, key) in keys.iter().enumerate() {
@@ -327,6 +363,20 @@ pub(crate) fn flatten_v8_provider_groups(
)
})?;
let mut record = serde_norway::Mapping::new();
if let Some(name) = yaml_mapping_value(group, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
{
let generated = name
.strip_prefix(&format!("{provider}-"))
.is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
});
if !generated {
record.insert(yaml_key("name"), yaml_key(name));
}
}
for (field, value) in group {
if field
.as_str()
@@ -340,7 +390,9 @@ pub(crate) fn flatten_v8_provider_groups(
record.insert(field.clone(), value.clone());
}
}
records.push(serde_norway::Value::Mapping(record));
records.push(serde_norway::Value::Mapping(normalize_v8_provider_record(
provider, &record,
)));
}
}
Ok(serde_norway::Value::Sequence(records))
@@ -358,6 +410,7 @@ pub(crate) fn group_legacy_provider_records(
let record = record
.as_mapping()
.ok_or_else(|| format!("{provider} provider entry {index} must be an object"))?;
let record = normalize_v8_provider_record(provider, record);
if provider == "openai-compatibility" {
let mut group = record.clone();
let keys = group
@@ -368,12 +421,18 @@ pub(crate) fn group_legacy_provider_records(
continue;
}
let mut group = serde_norway::Mapping::new();
group.insert(
yaml_key("name"),
serde_norway::Value::String(format!("{provider}-{}", index + 1)),
);
let name = yaml_mapping_value(&record, "name")
.and_then(serde_norway::Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("{provider}-{}", index + 1));
group.insert(yaml_key("name"), serde_norway::Value::String(name));
let mut key = serde_norway::Mapping::new();
for (field, value) in record {
for (field, value) in &record {
if field.as_str() == Some("name") {
continue;
}
if field
.as_str()
.is_some_and(is_v8_shared_provider_field)
+3 -6
View File
@@ -113,7 +113,7 @@ pub(crate) fn save_network_routing_settings(
return Err("Port must be between 1 and 65535".to_string());
}
let routing_session_affinity_ttl =
normalize_optional_config_string(settings.routing_session_affinity_ttl, "Session affinity TTL")?;
normalize_session_affinity_ttl(settings.routing_session_affinity_ttl)?;
let _refresh_guard = cache
.refresh_lock
@@ -235,10 +235,7 @@ pub(crate) fn save_session_routing_settings(
gui_config_state: tauri::State<'_, GuiConfigState>,
settings: GuiSessionRoutingSettings,
) -> Result<CoreConfigView, String> {
let ttl = normalize_optional_config_string(
settings.routing_session_affinity_ttl,
"Session affinity TTL",
)?;
let ttl = normalize_session_affinity_ttl(settings.routing_session_affinity_ttl)?;
let previous = gui_config_state.snapshot()?;
let mut next = previous.clone();
next.routing_session_affinity = settings.routing_session_affinity;
@@ -515,7 +512,7 @@ pub(crate) fn set_core_session_affinity_ttl(
gui_config_state: tauri::State<'_, GuiConfigState>,
ttl: String,
) -> Result<CoreConfigView, String> {
let ttl = normalize_optional_config_string(ttl, "Session affinity TTL")?;
let ttl = normalize_session_affinity_ttl(ttl)?;
let mut settings = current_core_config_settings(gui_config_state.inner())?;
let previous_ttl = settings.routing_session_affinity_ttl.clone();
settings.routing_session_affinity_ttl = ttl;
+30 -2
View File
@@ -155,10 +155,10 @@ pub(crate) fn is_hashed_management_secret_key(secret_key: &str) -> bool {
}
pub(crate) fn validate_routing_strategy(strategy: &str) -> Result<(), String> {
if matches!(strategy, "round-robin" | "fill-first") {
if matches!(strategy, "round-robin" | "weighted-round-robin" | "fill-first") {
return Ok(());
}
Err("Routing strategy supports only round-robin or fill-first".to_string())
Err("Routing strategy supports only round-robin, weighted-round-robin or fill-first".to_string())
}
pub(crate) fn normalize_optional_config_string(
@@ -172,6 +172,34 @@ pub(crate) fn normalize_optional_config_string(
Ok(value)
}
pub(crate) fn normalize_session_affinity_ttl(value: String) -> Result<String, String> {
let value = normalize_optional_config_string(value, "Session affinity TTL")?;
if value.is_empty() {
return Ok(value);
}
static PART: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
regex::Regex::new(r"([0-9]+(?:\.[0-9]*)?|\.[0-9]+)(ns|us|µs|μs|ms|s|m|h)").unwrap()
});
let text = value.strip_prefix('+').unwrap_or(&value);
let mut end = 0;
let mut nanos = 0.0_f64;
for part in PART.captures_iter(text) {
let matched = part.get(0).unwrap();
if matched.start() != end { break; }
let multiplier = match &part[2] {
"ns" => 1.0, "us" | "µs" | "μs" => 1_000.0, "ms" => 1_000_000.0,
"s" => 1_000_000_000.0, "m" => 60_000_000_000.0, "h" => 3_600_000_000_000.0,
_ => unreachable!(),
};
nanos += (part[1].parse::<f64>().unwrap_or(f64::INFINITY) * multiplier).trunc();
end = matched.end();
}
if end != text.len() || nanos <= 0.0 || nanos >= i64::MAX as f64 {
return Err("Session affinity TTL must be a positive duration such as 30m or 1h30m; leave it blank to use the core default".to_string());
}
Ok(if nanos < 1_000_000_000.0 { "1s".to_string() } else { value })
}
pub(crate) fn normalize_core_tls_settings(
settings: CoreTlsSettings,
) -> Result<CoreTlsSettings, String> {
+28 -64
View File
@@ -150,11 +150,9 @@ pub(crate) fn core_tls_settings_from_value(
let root = document
.as_mapping()
.ok_or_else(|| "Core configuration must be a YAML mapping".to_string())?;
let tls = nested_yaml_value(root, &["server", "tls"])
.or_else(|| yaml_mapping_value(root, "tls"))
.and_then(serde_norway::Value::as_mapping);
let enabled = tls
.and_then(|mapping| yaml_mapping_value(mapping, "enable"))
let field = |key: &str| nested_yaml_value(root, &["server", "tls", key])
.or_else(|| nested_yaml_value(root, &["tls", key]));
let enabled = field("enable")
.map(|value| {
value
.as_bool()
@@ -163,7 +161,7 @@ pub(crate) fn core_tls_settings_from_value(
.transpose()?
.unwrap_or(false);
let string_value = |key: &str| -> Result<String, String> {
tls.and_then(|mapping| yaml_mapping_value(mapping, key))
field(key)
.map(|value| {
value
.as_str()
@@ -253,17 +251,15 @@ pub(crate) fn core_sensitive_words_settings_from_value(
.as_mapping()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
let read_words = |provider: &str| -> Result<Vec<String>, String> {
let Some(section) = nested_yaml_value(root, &["oauth", "providers", provider])
.or_else(|| yaml_mapping_value(root, provider)) else {
return Ok(Vec::new());
};
if section.is_null() {
return Ok(Vec::new());
let current = nested_yaml_value(root, &["oauth", "providers", provider]);
let legacy = yaml_mapping_value(root, provider);
for section in [current, legacy].into_iter().flatten() {
if !section.is_null() && !section.is_mapping() {
return Err(format!("{provider} must be a YAML mapping"));
}
}
let section = section
.as_mapping()
.ok_or_else(|| format!("{provider} must be a YAML mapping"))?;
let Some(value) = yaml_mapping_value(section, "sensitive-words") else {
let Some(value) = nested_yaml_value(root, &["oauth", "providers", provider, "sensitive-words"])
.or_else(|| nested_yaml_value(root, &[provider, "sensitive-words"])) else {
return Ok(Vec::new());
};
if value.is_null() {
@@ -340,7 +336,6 @@ pub(crate) fn patch_core_sensitive_words_yaml(
prepared = replace_top_level_yaml_block(&prepared, provider, &block);
}
let updated = patch_core_yaml_document(&prepared, |document| {
let v8 = core_config_uses_v8(document);
let mut changed = false;
for (provider, words) in [
("antigravity", &settings.antigravity_sensitive_words),
@@ -349,37 +344,15 @@ pub(crate) fn patch_core_sensitive_words_yaml(
let root = document
.as_mapping()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
let section = if v8 {
nested_yaml_value(root, &["oauth", "providers", provider])
} else {
yaml_mapping_value(root, provider)
};
let current_words = section
.and_then(serde_norway::Value::as_mapping)
.and_then(|mapping| yaml_mapping_value(mapping, "sensitive-words"));
let current_words = nested_yaml_value(root, &["oauth", "providers", provider, "sensitive-words"])
.or_else(|| nested_yaml_value(root, &[provider, "sensitive-words"]));
if words.is_empty() && current_words.is_none_or(serde_norway::Value::is_null) {
continue;
}
if section.is_none_or(serde_norway::Value::is_null) {
let section_path = if v8 {
vec!["oauth", "providers", provider]
} else {
vec![provider]
};
changed |= set_core_yaml_path_value(
document,
&section_path,
serde_norway::Value::Mapping(serde_norway::Mapping::new()),
)?;
}
let sensitive_words_path = if v8 {
vec!["oauth", "providers", provider, "sensitive-words"]
} else {
vec![provider, "sensitive-words"]
};
changed |= set_core_yaml_path_value(
changed |= set_core_yaml_schema_value(
document,
&sensitive_words_path,
&[provider, "sensitive-words"],
&["oauth", "providers", provider, "sensitive-words"],
serde_norway::Value::Sequence(
words
.iter()
@@ -1190,20 +1163,7 @@ pub(crate) fn set_core_yaml_nested_value(
key: &str,
value: serde_norway::Value,
) -> Result<bool, String> {
let root = document
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
let section = root
.entry(yaml_key(section))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration section must be a YAML mapping".to_string())?;
let key = yaml_key(key);
if section.get(&key) == Some(&value) {
return Ok(false);
}
section.insert(key, value);
Ok(true)
set_core_yaml_path_value(document, &[section, key], value)
}
pub(crate) fn core_config_uses_v8(document: &serde_norway::Value) -> bool {
@@ -1241,9 +1201,13 @@ pub(crate) fn set_core_yaml_path_value(
.as_mapping_mut()
.ok_or_else(|| "Kernel configuration root must be a YAML mapping".to_string())?;
for section in parents {
mapping = mapping
let parent = mapping
.entry(yaml_key(section))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()))
.or_insert_with(|| serde_norway::Value::Mapping(serde_norway::Mapping::new()));
if parent.is_null() {
*parent = serde_norway::Value::Mapping(serde_norway::Mapping::new());
}
mapping = parent
.as_mapping_mut()
.ok_or_else(|| format!("Kernel configuration section {section} must be a YAML mapping"))?;
}
@@ -1485,7 +1449,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "host must be a string".to_string())
})
.transpose()?
.unwrap_or_else(|| "127.0.0.1".to_string());
.unwrap_or_default();
let port = v8_or_legacy(&["server", "port"], &["port"])
.map(|value| {
value
@@ -1554,7 +1518,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "usage-statistics-enabled must be a boolean".to_string())
})
.transpose()?
.unwrap_or(true);
.unwrap_or(false);
let redis_usage_queue_retention_seconds =
v8_or_legacy(&["observability", "usage", "redis-usage-queue-retention-seconds"], &["redis-usage-queue-retention-seconds"])
.map(|value| {
@@ -1645,7 +1609,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "request-retry must be a non-negative integer".to_string())
})
.transpose()?
.unwrap_or(DEFAULT_REQUEST_RETRY);
.unwrap_or(0);
let max_retry_credentials = v8_or_legacy(&["routing", "retry", "max-retry-credentials"], &["max-retry-credentials"])
.map(|value| {
value
@@ -1663,7 +1627,7 @@ pub(crate) fn core_config_settings_from_value(
.ok_or_else(|| "max-retry-interval must be a non-negative integer".to_string())
})
.transpose()?
.unwrap_or(DEFAULT_MAX_RETRY_INTERVAL);
.unwrap_or(0);
let streaming_bootstrap_retries = v8_or_legacy(&["requests", "streaming", "bootstrap-retries"], &["streaming", "bootstrap-retries"])
.map(|value| {
value
+44 -9
View File
@@ -435,15 +435,24 @@ pub(crate) async fn read_management_text(response: reqwest::Response) -> Result<
fn format_management_error(status: u16, body: &str) -> String {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(message) = value
.get("error")
.and_then(|item| item.as_str())
.or_else(|| value.get("message").and_then(|item| item.as_str()))
{
let message = message.trim();
if !message.is_empty() {
return format!("Management API error ({status}): {message}");
}
let field = |key| {
value.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
};
let error = field("error");
let message = field("message");
let detail = match (error, message) {
(Some(error), Some(message)) if error != message => Some(format!("{error}: {message}")),
(Some(text), _) | (_, Some(text)) => Some(text.to_string()),
_ => None,
};
if let Some(detail) = detail {
return format!(
"Management API error ({status}): {}",
truncate_for_error(&detail)
);
}
}
let body = body.trim();
@@ -459,6 +468,32 @@ mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn management_errors_include_v8_validation_details() {
assert_eq!(
format_management_error(400, r#"{"error":"invalid_config","message":"field name not found in type config.CodexKey"}"#),
"Management API error (400): invalid_config: field name not found in type config.CodexKey"
);
for body in [
r#"{"error":"not_found"}"#,
r#"{"error":"not_found","message":"not_found"}"#,
] {
assert_eq!(
format_management_error(404, body),
"Management API error (404): not_found"
);
}
assert_eq!(
format_management_error(400, r#"{"error":" ","message":"validation failed"}"#),
"Management API error (400): validation failed"
);
assert_eq!(
format_management_error(502, "gateway unavailable"),
"Management API error (502): gateway unavailable"
);
assert_eq!(format_management_error(500, ""), "Management API error (500)");
}
#[test]
fn devin_oauth_uses_the_management_callback_flow() {
for provider in ["Devin", " cognition "] {
+1
View File
@@ -13,6 +13,7 @@ mod app_settings;
mod app_update;
mod core_config;
mod core_runtime;
mod core_v8_contract;
mod desktop_alias_routing;
#[cfg(windows)]
mod file_replace;
+63
View File
@@ -37,6 +37,23 @@ fn non_v8_management_yaml_is_returned_without_reformatting() {
assert_eq!(management_v8_yaml_to_legacy_view(input).unwrap(), input);
}
#[test]
fn v8_provider_names_belong_to_groups_and_preserve_legacy_view_identity() {
let groups = serde_norway::from_str(
"- name: codex-1\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: my-deepseek}]\n keys:\n - {name: DeepSeek, api-key: first, weight: 2}\n - {api-key: second, priority: 0}\n",
)
.unwrap();
let records = flatten_v8_provider_groups("codex", &groups).unwrap();
let updated = group_legacy_provider_records("codex", &records).unwrap();
let json = serde_json::to_value(&updated).unwrap();
assert_eq!(json[0]["name"], "DeepSeek");
assert_eq!(json[0]["keys"][0]["weight"], 2);
assert!(json[0]["keys"][0].get("name").is_none());
assert_eq!(json[1]["name"], "codex-2");
assert!(json[1]["keys"][0].get("name").is_none());
assert_eq!(flatten_v8_provider_groups("codex", &updated).unwrap(), records);
}
#[test]
fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() {
let project = |input| yaml_json(&management_v8_yaml_to_legacy_view(input).unwrap());
@@ -49,6 +66,30 @@ fn v8_alias_projection_normalizes_empty_parents_but_preserves_siblings() {
assert_eq!(view["requests"]["proxy-url"], "direct");
}
#[test]
fn v8_provider_fields_use_yaml_names_without_losing_explicit_false_or_extensions() {
for provider in ["codex", "claude", "gemini", "openai-compatibility"] {
let records = serde_norway::from_str(
"- name: provider\n api-key: test-key\n test-model: old\n testModel: old\n disable-cooling: false\n request-retry: 0\n models:\n - name: model\n max-context-length: 65536\n thinking: {zero_allowed: true, zero-allowed: false, dynamic_allowed: true, levels: [low, high]}\n",
).unwrap();
let groups = group_legacy_provider_records(provider, &records).unwrap();
let json = serde_json::to_value(&groups).unwrap();
assert_eq!(json[0]["disable-cooling"], false);
assert_eq!(json[0]["request-retry"], 0);
assert_eq!(json[0]["models"][0]["max-context-length"], 65536);
assert_eq!(json[0]["models"][0]["thinking"]["zero-allowed"], false);
assert_eq!(json[0]["models"][0]["thinking"]["dynamic-allowed"], true);
assert!(json[0]["models"][0]["thinking"].get("zero_allowed").is_none());
assert!(json[0]["models"][0]["thinking"].get("dynamic_allowed").is_none());
if provider == "openai-compatibility" {
assert!(json[0].get("test-model").is_none());
assert!(json[0].get("testModel").is_none());
}
let view = flatten_v8_provider_groups(provider, &groups).unwrap();
assert_eq!(group_legacy_provider_records(provider, &view).unwrap(), groups);
}
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_creates_first_oauth_and_payload_nodes() {
let core = MockCore::new_v8("config-version: 8\n", Failure::None);
@@ -255,6 +296,13 @@ impl MockCore {
.unwrap();
let groups: serde_norway::Value =
serde_json::from_slice(request_body).unwrap();
if provider != "openai-compatibility" {
for group in groups.as_sequence().unwrap() {
for key in group["keys"].as_sequence().unwrap() {
assert!(key.get("name").is_none(), "v8 rejects credential names");
}
}
}
persisted[legacy] = serde_json::to_value(
flatten_v8_provider_groups(provider, &groups).unwrap(),
)
@@ -391,6 +439,21 @@ async fn provider_alias_edits_round_trip_through_v8_key_groups() {
.any(|request| request == "PUT /v8/management/config/api-keys/codex"));
}
#[tokio::test(flavor = "current_thread")]
async fn deepseek_alias_edits_keep_the_provider_name_out_of_v8_credentials() {
let initial = "codex-api-key:\n - name: DeepSeek\n api-key: preserved-secret\n base-url: https://gateway.example/v1\n models: [{name: deepseek-chat, alias: original}]\n";
let core = MockCore::new_v8(initial, Failure::None);
let current = fetch_management_config_yaml(&core.config).await.unwrap();
let updated = current.replace("alias: original", "alias: renamed");
put_management_alias_config_changes(&core.config, &current, &updated)
.await
.unwrap();
let (persisted, _) = core.finish();
let mut expected = yaml_json(initial);
expected["codex-api-key"][0]["models"][0]["alias"] = serde_json::json!("renamed");
assert_eq!(persisted, expected);
}
#[tokio::test(flavor = "current_thread")]
async fn alias_save_recovers_failures_before_and_after_writes() {
for failure in [
+63 -3
View File
@@ -1435,7 +1435,7 @@ fn core_config_reads_logging_settings_and_applies_defaults() {
DEFAULT_LOGS_MAX_TOTAL_SIZE_MB
);
assert_eq!(defaults.error_logs_max_files, DEFAULT_ERROR_LOGS_MAX_FILES);
assert!(defaults.usage_statistics_enabled);
assert!(!defaults.usage_statistics_enabled);
assert_eq!(
defaults.redis_usage_queue_retention_seconds,
DEFAULT_REDIS_USAGE_QUEUE_RETENTION_SECONDS
@@ -1489,6 +1489,65 @@ fn v8_core_config_reads_and_writes_canonical_nested_fields() {
}
}
#[test]
fn v8_omitted_fields_use_runtime_defaults_instead_of_gui_presets() {
let settings = core_config_settings_from_value(
&serde_norway::from_str("config-version: 8\nserver: {port: 8317}\n").unwrap(),
).unwrap();
assert_eq!(settings.host, "");
assert!(!settings.usage_statistics_enabled);
assert_eq!(settings.request_retry, 0);
assert_eq!(settings.max_retry_interval, 0);
}
#[test]
fn v8_settings_can_populate_null_sections_without_losing_siblings() {
let input = "config-version: 8\nserver: null\nmanagement: null\naccess: null\noauth: null\nrequests: null\nrouting: null\nobservability: null\nplugins: null\napi-keys: {codex: [{name: keep, keys: [{api-key: test-key}]}]}\n";
let updated = apply_gui_managed_settings(input, &GuiConfigFile::default()).unwrap();
let document: serde_norway::Value = serde_norway::from_str(&updated).unwrap();
assert_eq!(document["server"]["port"], 8317);
assert_eq!(document["api-keys"]["codex"][0]["name"], "keep");
assert!(apply_gui_managed_settings("config-version: 8\nrequests: broken\n", &GuiConfigFile::default()).is_err());
}
#[test]
fn v8_sensitive_words_fall_back_per_field_and_clear_legacy_values() {
let input = "config-version: 8\nantigravity: {sensitive-words: [old]}\ndevin: {sensitive-words: [old-devin]}\noauth:\n providers:\n antigravity: {signature-cache-enabled: true}\n devin: {sensitive-words: [new-devin]}\n";
let document: serde_norway::Value = serde_norway::from_str(input).unwrap();
let settings = core_sensitive_words_settings_from_value(&document).unwrap();
assert_eq!(settings.antigravity_sensitive_words, vec!["old"]);
assert_eq!(settings.devin_sensitive_words, vec!["new-devin"]);
let updated = patch_core_sensitive_words_yaml(input, &CoreSensitiveWordsSettings::default()).unwrap().unwrap();
let document: serde_norway::Value = serde_norway::from_str(&updated).unwrap();
for path in [vec!["antigravity"], vec!["devin"], vec!["oauth", "providers", "antigravity"], vec!["oauth", "providers", "devin"]] {
let section = nested_yaml_value(document.as_mapping().unwrap(), &path).unwrap();
assert_eq!(section["sensitive-words"].as_sequence().unwrap().len(), 0);
}
assert_eq!(document["oauth"]["providers"]["antigravity"]["signature-cache-enabled"], true);
}
#[test]
fn v8_tls_reads_mixed_layout_per_field() {
let document = serde_norway::from_str("config-version: 8\ntls: {enable: true, cert: old-cert, key: old-key}\nserver: {tls: {cert: new-cert}}\n").unwrap();
let settings = core_tls_settings_from_value(&document).unwrap();
assert!(settings.enabled);
assert_eq!(settings.cert, "new-cert");
assert_eq!(settings.key, "old-key");
}
#[test]
fn session_ttl_rejects_values_that_the_core_silently_ignores() {
for value in ["", "30m", "1h30m", "+2h", "1.5s", ".5h", "1.s"] {
assert_eq!(normalize_session_affinity_ttl(format!(" {value} ")).unwrap(), value);
}
for value in ["500ms", "1ns", "2us", "2µs", "2μs"] {
assert_eq!(normalize_session_affinity_ttl(value.into()).unwrap(), "1s");
}
for value in ["0", "0s", "-1h", "1d", "30", "1h 30m", "1s junk", "+", "NaNh", "0.1ns", "999999999999h"] {
assert!(normalize_session_affinity_ttl(value.into()).is_err(), "accepted {value}");
}
}
#[test]
fn v8_startup_without_version_marker_preserves_upstream_credentials() {
let input = "server: {port: 9527}\noauth: {auth-dir: ../oauth}\naccess: {api-keys: [old-client-key]}\napi-keys:\n codex:\n - name: custom-provider\n base-url: https://example.invalid/v1\n keys: [{api-key: upstream-secret}]\n";
@@ -1550,12 +1609,12 @@ fn core_config_reads_retry_fields_and_uses_core_defaults() {
let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap();
assert_eq!(defaults.disable_cooling, DEFAULT_DISABLE_COOLING);
assert_eq!(defaults.request_retry, DEFAULT_REQUEST_RETRY);
assert_eq!(defaults.request_retry, 0);
assert_eq!(
defaults.max_retry_credentials,
DEFAULT_MAX_RETRY_CREDENTIALS
);
assert_eq!(defaults.max_retry_interval, DEFAULT_MAX_RETRY_INTERVAL);
assert_eq!(defaults.max_retry_interval, 0);
assert_eq!(
defaults.streaming_bootstrap_retries,
DEFAULT_STREAMING_BOOTSTRAP_RETRIES
@@ -1793,6 +1852,7 @@ fn core_config_validates_keys_and_routing_strategy() {
assert!(validate_core_api_key("").is_err());
assert!(validate_core_api_key("contains space").is_err());
assert!(validate_routing_strategy("round-robin").is_ok());
assert!(validate_routing_strategy("weighted-round-robin").is_ok());
assert!(validate_routing_strategy("fill-first").is_ok());
assert!(validate_routing_strategy("random").is_err());
}
+278
View File
@@ -0,0 +1,278 @@
use super::support::agent_test_home;
use super::*;
use std::process::{Child, Stdio};
struct TestCore {
child: Option<Child>,
directory: PathBuf,
config: PathBuf,
executable: PathBuf,
origin: String,
client: reqwest::Client,
}
impl Drop for TestCore {
fn drop(&mut self) {
self.stop();
if self.directory.parent() == Some(std::env::temp_dir().as_path())
&& self
.directory
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("cpa-gui-agent-v8-contract-")
{
let _ = fs::remove_dir_all(&self.directory);
}
}
}
impl TestCore {
fn stop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
async fn start(&mut self) {
let mut command = Command::new(&self.executable);
command
.args(["-config"])
.arg(&self.config)
.arg("-local-model")
.current_dir(&self.directory)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
configure_background_command(&mut command);
self.child = Some(command.spawn().unwrap());
for _ in 0..100 {
assert!(
self.child.as_mut().unwrap().try_wait().unwrap().is_none(),
"test kernel exited during startup"
);
if self
.client
.get(format!("{}/v8/management/config", self.origin))
.bearer_auth("isolated-test-secret")
.send()
.await
.is_ok_and(|r| r.status().is_success())
{
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("test kernel startup timed out");
}
async fn config_view(&self) -> serde_json::Value {
self.client
.get(format!("{}/v8/management/config", self.origin))
.bearer_auth("isolated-test-secret")
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap()
}
async fn validate_save(&self, yaml: &str) -> serde_json::Value {
let response = self
.client
.put(format!("{}/v8/management/config.yaml", self.origin))
.bearer_auth("isolated-test-secret")
.header("Content-Type", "application/yaml")
.body(yaml.to_string())
.send()
.await
.unwrap();
let status = response.status();
let body = response.text().await.unwrap();
assert!(
status.is_success(),
"v8 rejected generated settings: {status} {body}"
);
self.config_view().await
}
async fn wait_for_client_key(&self, key: &str, expected: u16) {
for _ in 0..100 {
let status = self
.client
.get(format!("{}/v1/models", self.origin))
.bearer_auth(key)
.send()
.await
.unwrap()
.status()
.as_u16();
if status == expected {
return;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
panic!("client key did not reach HTTP {expected}");
}
}
#[tokio::test]
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
async fn v8_accepts_gui_settings_and_reloads_client_keys() {
let executable =
fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE"))
.unwrap();
let directory = agent_test_home("v8-contract");
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let mut core = TestCore {
child: None,
config: directory.join("config.yaml"),
directory,
executable,
origin: format!("http://127.0.0.1:{port}"),
client: reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(2))
.build()
.unwrap(),
};
let mut gui = GuiConfigFile {
host: "127.0.0.1".into(),
port,
auth_dir: path_to_string(&core.directory.join("auth")),
management_secret_key: "isolated-test-secret".into(),
api_keys: vec![GuiApiKeyEntry {
key: "client-one".into(),
remark: String::new(),
}],
proxy_url: "direct".into(),
routing_strategy: "weighted-round-robin".into(),
routing_session_affinity: true,
routing_session_affinity_ttl: "2h30m".into(),
request_retry: 2,
max_retry_credentials: 3,
max_retry_interval: 5,
streaming_bootstrap_retries: 2,
disable_cooling: true,
debug: true,
commercial_mode: true,
logging_to_file: true,
logs_max_total_size_mb: 5,
error_logs_max_files: 4,
usage_statistics_enabled: true,
redis_usage_queue_retention_seconds: 90,
request_log: true,
..GuiConfigFile::default()
};
let initial = "config-version: 8\nmanagement: {disable-control-panel: true, disable-auto-update-panel: true}\nrequests: null\nobservability: null\noauth: {providers: {codex: {header-defaults: {user-agent: preserve-test}}}}\napi-keys: {openai-compatibility: [{name: preserved, base-url: 'http://127.0.0.1:1/v1', models: [{name: test-model}], keys: [{api-key: upstream-test}]}]}\n";
let content = apply_gui_managed_settings(initial, &gui).unwrap();
let content = patch_core_sensitive_words_yaml(
&content,
&CoreSensitiveWordsSettings {
antigravity_sensitive_words: vec!["sample".into()],
devin_sensitive_words: vec!["sample-devin".into()],
},
)
.unwrap()
.unwrap();
fs::write(&core.config, &content).unwrap();
core.start().await;
let view = core.validate_save(&content).await;
assert_eq!(view["routing"]["strategy"], "weighted-round-robin");
assert_eq!(view["routing"]["retry"]["request-retry"], 2);
assert_eq!(view["routing"]["retry"]["max-retry-credentials"], 3);
assert_eq!(view["routing"]["retry"]["max-retry-interval"], 5);
assert_eq!(view["requests"]["streaming"]["bootstrap-retries"], 2);
assert_eq!(view["routing"]["cooldown"]["disable-cooling"], true);
assert_eq!(
view["observability"]["usage"]["redis-usage-queue-retention-seconds"],
90
);
assert_eq!(
view["oauth"]["providers"]["devin"]["sensitive-words"][0],
"sample-devin"
);
gui.request_retry = 0;
gui.max_retry_credentials = 0;
gui.max_retry_interval = 0;
gui.streaming_bootstrap_retries = 0;
gui.disable_cooling = false;
gui.routing_session_affinity = false;
gui.routing_session_affinity_ttl.clear();
gui.proxy_url.clear();
let current = fs::read_to_string(&core.config).unwrap();
let content = patch_core_retry_yaml(&current, &gui).unwrap().unwrap();
let content = patch_core_session_routing_yaml(&content, &gui)
.unwrap()
.unwrap();
let content = patch_core_network_endpoint_yaml(&content, &gui)
.unwrap()
.unwrap();
let mut logging =
core_config_settings_from_value(&serde_norway::from_str(&content).unwrap()).unwrap();
logging.debug = false;
logging.commercial_mode = false;
logging.logging_to_file = false;
logging.logs_max_total_size_mb = 0;
logging.error_logs_max_files = 0;
logging.usage_statistics_enabled = false;
logging.redis_usage_queue_retention_seconds = 1;
let content =
patch_core_yaml_document(&content, |doc| apply_core_logging_settings(doc, &logging))
.unwrap()
.unwrap();
let content = patch_core_sensitive_words_yaml(&content, &CoreSensitiveWordsSettings::default())
.unwrap()
.unwrap();
let content = patch_core_tls_settings_yaml(
&content,
&CoreTlsSettings {
enabled: false,
cert: "test-cert.pem".into(),
key: "test-key.pem".into(),
},
)
.unwrap()
.unwrap();
let view = core.validate_save(&content).await;
assert_eq!(view["routing"]["retry"]["request-retry"], 0);
assert_eq!(view["routing"]["session-affinity"], false);
assert_eq!(view["routing"]["session-affinity-ttl"], "");
assert_eq!(view["routing"]["cooldown"]["disable-cooling"], false);
assert_eq!(
view["observability"]["usage"]["usage-statistics-enabled"],
false
);
assert_eq!(view["observability"]["logs"]["logging-to-file"], false);
assert_eq!(view["observability"]["logs"]["request-log"], true);
assert_eq!(view["server"]["tls"]["cert"], "test-cert.pem");
assert_eq!(view["server"]["tls"]["enable"], false);
assert_eq!(
view["api-keys"]["openai-compatibility"][0]["name"],
"preserved"
);
assert_eq!(
view["oauth"]["providers"]["codex"]["header-defaults"]["user-agent"],
"preserve-test"
);
assert_eq!(
view["oauth"]["providers"]["antigravity"]["sensitive-words"],
serde_json::json!([])
);
let current = fs::read_to_string(&core.config).unwrap();
let content = patch_core_api_keys_yaml(&current, &["client-two".into()]).unwrap();
write_yaml_if_changed(&core.config, &content).unwrap();
core.wait_for_client_key("client-two", 200).await;
core.wait_for_client_key("client-one", 401).await;
core.stop();
core.start().await;
core.wait_for_client_key("client-two", 200).await;
core.wait_for_client_key("client-one", 401).await;
}
+7 -3
View File
@@ -683,6 +683,7 @@ export const jaOverrides = {
'aliases.unboundEffort': 'レベル未設定',
'aliases.delete': '{alias} を削除',
'config.routing.roundRobin': 'ラウンドロビン',
'config.routing.weightedRoundRobin': '重み付きラウンドロビン',
'config.routing.fillFirst': '優先充填',
'config.tabs.label': '詳細設定ページ',
'config.tabs.general': '基本設定',
@@ -1387,12 +1388,12 @@ export const jaOverrides = {
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
'apiAccess.thinking.title': '推論レベル',
'apiAccess.thinking.description': '上流の対応状況に合わせて追加してください',
'apiAccess.thinking.description': '変更はこの接続の全モデルに適用されます。未変更なら各モデルの設定を保持します',
'apiAccess.thinking.placeholder': '例:low、medium、またはカスタムレベル',
'apiAccess.thinking.add': '追加',
'apiAccess.thinking.delete': '{level} を削除',
'apiAccess.thinking.deleteAria': '推論レベル {level} を削除',
'apiAccess.thinking.empty': '未追加の場合 thinking.levels は書き込まれません',
'apiAccess.thinking.empty': 'レベルを編集するまでは、各モデルの推論設定を保持します',
'apiAccess.models.title': 'モデル',
'apiAccess.models.description': '選択したモデルを公開します。選択解除するとモデル一覧から非表示になります',
'apiAccess.models.fetch': 'モデルを取得',
@@ -1407,7 +1408,10 @@ export const jaOverrides = {
'apiAccess.prefixPlaceholder': '任意、例:team-a',
'apiAccess.field.headers': 'カスタムリクエストヘッダー(1 行に Name: Value)',
'apiAccess.field.excludedModels': '除外モデル(1 行に 1 件、ワイルドカード対応)',
'apiAccess.field.testModel': 'テストモデル',
'apiAccess.option.inherit': 'デフォルトを使用',
'apiAccess.error.priorityInteger': '優先度は安全な範囲の整数で指定してください。',
'apiAccess.cooling.disable': 'クールダウンを無効化',
'apiAccess.cooling.enable': 'クールダウンを有効化',
'apiAccess.cloak.mode': 'Claude 偽装モード',
'apiAccess.cloak.default': '既定(自動)',
'apiAccess.cloak.words': '偽装する機密語(1 行に 1 件)',
+7 -3
View File
@@ -680,6 +680,7 @@ export const en: Record<MessageKey, string> = {
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': 'For example: {model}-fast',
'config.routing.roundRobin': 'Round Robin',
'config.routing.weightedRoundRobin': 'Weighted Round Robin',
'config.routing.fillFirst': 'Fill First',
'config.tabs.label': 'Advanced features sections',
'config.tabs.general': 'General Settings',
@@ -1388,12 +1389,12 @@ export const en: Record<MessageKey, string> = {
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
'apiAccess.thinking.title': 'Reasoning Levels',
'apiAccess.thinking.description': 'Add levels supported by the upstream provider',
'apiAccess.thinking.description': 'Edits apply to all models in this connection; otherwise each model keeps its settings',
'apiAccess.thinking.placeholder': 'For example: low, medium, or a custom level',
'apiAccess.thinking.add': 'Add',
'apiAccess.thinking.delete': 'Delete {level}',
'apiAccess.thinking.deleteAria': 'Delete reasoning level {level}',
'apiAccess.thinking.empty': 'thinking.levels is omitted when no levels are added',
'apiAccess.thinking.empty': 'Each model keeps its reasoning settings until you edit the levels',
'apiAccess.models.title': 'Models',
'apiAccess.models.description': 'Checked models are open; uncheck a model to hide it from the list',
'apiAccess.models.fetch': 'Fetch Models',
@@ -1407,7 +1408,10 @@ export const en: Record<MessageKey, string> = {
'apiAccess.prefixPlaceholder': 'Optional, for example team-a',
'apiAccess.field.headers': 'Custom Headers (one Name: Value per line)',
'apiAccess.field.excludedModels': 'Excluded Models (one per line; wildcards supported)',
'apiAccess.field.testModel': 'Test Model',
'apiAccess.option.inherit': 'Use default',
'apiAccess.error.priorityInteger': 'Priority must be a safe integer.',
'apiAccess.cooling.disable': 'Disable cooldown',
'apiAccess.cooling.enable': 'Enable cooldown',
'apiAccess.cloak.mode': 'Claude Cloaking Mode',
'apiAccess.cloak.default': 'Default (auto)',
'apiAccess.cloak.words': 'Cloaking Keywords (one per line)',
+7 -3
View File
@@ -679,6 +679,7 @@ export const zhCN = {
'speedAliases.fast.title': 'Fast',
'speedAliases.aliasName.example': '例如 {model}-fast',
'config.routing.roundRobin': '轮询',
'config.routing.weightedRoundRobin': '加权轮询',
'config.routing.fillFirst': '优先填充',
'config.tabs.label': '高级功能子页面',
'config.tabs.general': '基础设置',
@@ -1387,12 +1388,12 @@ export const zhCN = {
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
'apiAccess.thinking.title': '思考等级',
'apiAccess.thinking.description': '按上游支持情况自行添加',
'apiAccess.thinking.description': '修改后应用于此接入的所有模型;未修改时保留各模型配置',
'apiAccess.thinking.placeholder': '例如 low、medium 或自定义等级',
'apiAccess.thinking.add': '添加',
'apiAccess.thinking.delete': '删除 {level}',
'apiAccess.thinking.deleteAria': '删除思考等级 {level}',
'apiAccess.thinking.empty': '未添加时不写入 thinking.levels',
'apiAccess.thinking.empty': '未手动修改时保留模型各自的思考能力设置',
'apiAccess.models.title': '模型',
'apiAccess.models.description': '勾选表示当前开放,取消勾选后将从模型列表隐藏',
'apiAccess.models.fetch': '获取模型',
@@ -1406,7 +1407,10 @@ export const zhCN = {
'apiAccess.prefixPlaceholder': '可选,例如 team-a',
'apiAccess.field.headers': '自定义请求头(每行 Name: Value)',
'apiAccess.field.excludedModels': '排除模型(每行一个,支持通配符)',
'apiAccess.field.testModel': '测试模型',
'apiAccess.option.inherit': '使用默认值',
'apiAccess.error.priorityInteger': '优先级必须是安全范围内的整数。',
'apiAccess.cooling.disable': '禁用冷却',
'apiAccess.cooling.enable': '启用冷却',
'apiAccess.cloak.mode': 'Claude 伪装模式',
'apiAccess.cloak.default': '默认(自动)',
'apiAccess.cloak.words': '伪装敏感词(每行一个)',
+75 -47
View File
@@ -1,4 +1,5 @@
import { useConfirmation } from '../components/ConfirmationDialog';
import { normalizeProviderModels } from '../services/providerModels';
import { ModelSelectionPanel } from '../components/ModelSelectionPanel';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
@@ -219,15 +220,15 @@ export type ProviderDraft = {
prefix?: string;
headersText?: string;
excludedModelsText?: string;
disableCooling?: boolean;
disableCooling?: boolean | null;
websockets?: boolean;
testModel?: string;
thinkingLevels?: string[];
thinkingLevelsEdited?: boolean;
disabled?: boolean;
cloakMode?: string;
cloakStrictMode?: boolean;
cloakSensitiveWordsText?: string;
cloakCacheUserId?: boolean;
cloakCacheUserId?: boolean | null;
};
const providerDefinitions: ProviderDefinition[] = [
@@ -362,7 +363,6 @@ const providerHealthIdentity = (row: ProviderRow) => [
row.name,
row.baseUrl,
row.authIndex,
readString(row.record, 'test-model', 'testModel'),
row.apiKeys.join('\u0000'),
row.models.map((model) => model.name).join('\u0000'),
].join('\u0001');
@@ -384,7 +384,9 @@ const providerHeadersFromRecord = (record: Record<string, unknown>) =>
: {};
export const stripResponseFields = (record: Record<string, unknown>) => {
const next = { ...record };
const next = normalizeProviderModels(record);
delete next['test-model'];
delete next.testModel;
delete next['auth-index'];
delete next.authIndex;
delete next.auth_index;
@@ -445,7 +447,9 @@ export const hasDuplicateProviderRecord = (
return readString(record, 'api-key', 'apiKey') === readString(candidate, 'api-key', 'apiKey')
&& readString(record, 'base-url', 'baseUrl') === readString(candidate, 'base-url', 'baseUrl');
}
return providerConfigIdentity(record) === providerConfigIdentity(candidate);
const { name: _recordName, ...recordConfig } = record;
const { name: _candidateName, ...candidateConfig } = candidate;
return providerConfigIdentity(recordConfig) === providerConfigIdentity(candidateConfig);
}));
const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
@@ -644,12 +648,12 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
excludedModelsText: Array.isArray(row.record['excluded-models'])
? row.record['excluded-models'].map(String).filter((model) => model.trim() !== '*').join('\n')
: '',
disableCooling: readBoolean(row.record, 'disable-cooling', 'disableCooling'),
disableCooling: typeof row.record['disable-cooling'] === 'boolean' ? row.record['disable-cooling'] : null,
websockets: readBoolean(row.record, 'websockets'),
testModel: readString(row.record, 'test-model', 'testModel'),
thinkingLevels: definition.openAi
? thinkingLevelsFromModels(row.models)
: undefined,
thinkingLevelsEdited: false,
disabled: row.disabled,
cloakMode: isRecord(row.record.cloak) ? readString(row.record.cloak, 'mode') : '',
cloakStrictMode: isRecord(row.record.cloak)
@@ -659,9 +663,8 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
isRecord(row.record.cloak) && Array.isArray(row.record.cloak['sensitive-words'])
? row.record.cloak['sensitive-words'].map(String).join('\n')
: '',
cloakCacheUserId: isRecord(row.record.cloak)
? readBoolean(row.record.cloak, 'cache-user-id', 'cacheUserId')
: false,
cloakCacheUserId: isRecord(row.record.cloak) && typeof row.record.cloak['cache-user-id'] === 'boolean'
? row.record.cloak['cache-user-id'] : null,
};
};
@@ -676,19 +679,18 @@ const emptyProviderDraft = (): ProviderDraft => ({
prefix: '',
headersText: '',
excludedModelsText: '',
disableCooling: false,
disableCooling: null,
websockets: false,
testModel: '',
disabled: false,
cloakMode: '',
cloakStrictMode: false,
cloakSensitiveWordsText: '',
cloakCacheUserId: false,
cloakCacheUserId: null,
});
export const createProviderDraft = (category: ProviderCategory): ProviderDraft => {
const draft = emptyProviderDraft();
if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [] };
if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [], thinkingLevelsEdited: false };
if (category !== 'deepseek') return draft;
return {
...draft,
@@ -711,7 +713,7 @@ export const applyProviderPreset = (
category: ProviderCategory,
draft: ProviderDraft,
): ProviderDraft => {
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined) return draft;
if (!definitionFor(category).openAi || draft.thinkingLevels === undefined || draft.thinkingLevelsEdited === false) return draft;
const levels = draft.thinkingLevels;
return {
...draft,
@@ -785,17 +787,12 @@ const applyAdvancedFields = (
else delete next['excluded-models'];
}
if (draft.disableCooling !== undefined) {
if (draft.disableCooling) next['disable-cooling'] = true;
else delete next['disable-cooling'];
if (draft.disableCooling === null) delete next['disable-cooling'];
else next['disable-cooling'] = draft.disableCooling;
}
if (draft.websockets !== undefined && section === 'codex-api-key') {
next.websockets = draft.websockets;
}
if (draft.testModel !== undefined && section === 'openai-compatibility') {
const testModel = draft.testModel.trim();
if (testModel) next['test-model'] = testModel;
else delete next['test-model'];
}
if (
section === 'claude-api-key'
&& (
@@ -806,22 +803,29 @@ const applyAdvancedFields = (
)
) {
const cloak: Record<string, unknown> = isRecord(next.cloak) ? { ...next.cloak } : {};
const mode = draft.cloakMode?.trim();
if (mode) cloak.mode = mode;
else delete cloak.mode;
delete cloak.strictMode;
if (draft.cloakStrictMode) cloak['strict-mode'] = true;
else delete cloak['strict-mode'];
const sensitiveWords = (draft.cloakSensitiveWordsText ?? '')
.split(/[,\n]/)
.map((value) => value.trim())
.filter((value, index, values) => value && values.indexOf(value) === index);
if (sensitiveWords.length > 0) cloak['sensitive-words'] = sensitiveWords;
else delete cloak['sensitive-words'];
delete cloak.sensitiveWords;
delete cloak.cacheUserId;
if (draft.cloakCacheUserId) cloak['cache-user-id'] = true;
else delete cloak['cache-user-id'];
if (draft.cloakMode !== undefined) {
const mode = draft.cloakMode.trim();
if (mode) cloak.mode = mode;
else delete cloak.mode;
}
if (draft.cloakStrictMode !== undefined) {
delete cloak.strictMode;
if (draft.cloakStrictMode) cloak['strict-mode'] = true;
else delete cloak['strict-mode'];
}
if (draft.cloakSensitiveWordsText !== undefined) {
const sensitiveWords = draft.cloakSensitiveWordsText.split(/[,\n]/)
.map((value) => value.trim())
.filter((value, index, values) => value && values.indexOf(value) === index);
delete cloak.sensitiveWords;
if (sensitiveWords.length > 0) cloak['sensitive-words'] = sensitiveWords;
else delete cloak['sensitive-words'];
}
if (draft.cloakCacheUserId !== undefined) {
delete cloak.cacheUserId;
if (draft.cloakCacheUserId === null) delete cloak['cache-user-id'];
else cloak['cache-user-id'] = draft.cloakCacheUserId;
}
if (Object.keys(cloak).length > 0) next.cloak = cloak;
else delete next.cloak;
}
@@ -836,8 +840,20 @@ export const buildProviderRecord = (
const record = current ? stripResponseFields(current) : {};
const priorityText = draft.priority.trim();
const priority = priorityText ? Number(priorityText) : null;
if (priority !== null && !Number.isSafeInteger(priority)) {
throw new Error(translate(getCurrentLocale(), 'apiAccess.error.priorityInteger'));
}
const models = mergeModelRecords(record.models, draft.models);
if (definitionFor(section).openAi) {
if (draft.thinkingLevels !== undefined && draft.thinkingLevelsEdited !== false) {
for (const model of models) {
const thinking = isRecord(model.thinking) ? { ...model.thinking } : {};
if (draft.thinkingLevels.length) thinking.levels = [...draft.thinkingLevels];
else delete thinking.levels;
if (Object.keys(thinking).length) model.thinking = thinking;
else delete model.thinking;
}
}
const entries = mergeOpenAiApiKeyEntries(record['api-key-entries'], draft.apiKey.trim());
if (draft.proxyUrlEdited !== false && draft.proxyUrl !== undefined) {
const proxyUrl = draft.proxyUrl.trim();
@@ -1842,7 +1858,7 @@ export function ApiProviderDialog({
), [modelOptions, selectedModelNames]);
const updateTextField = (
field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setFormError('');
@@ -1861,13 +1877,18 @@ export function ApiProviderDialog({
};
const updateBooleanField = (
field: 'disableCooling' | 'websockets' | 'cloakStrictMode' | 'cloakCacheUserId',
field: 'websockets' | 'cloakStrictMode',
value: boolean,
) => {
setFormError('');
setDraft((current) => ({ ...current, [field]: value }));
};
const updateOptionalBooleanField = (field: 'disableCooling' | 'cloakCacheUserId', value: string) => {
setFormError('');
setDraft((current) => ({ ...current, [field]: value === '' ? null : value === 'true' }));
};
const updateModels = (update: (models: ModelOption[]) => ModelOption[]) => {
setDraft((current) => {
const models = update(current.models);
@@ -1904,6 +1925,7 @@ export function ApiProviderDialog({
return {
...current,
thinkingLevels: [...levels, level],
thinkingLevelsEdited: true,
};
});
setThinkingLevelInput('');
@@ -1913,6 +1935,7 @@ export function ApiProviderDialog({
setDraft((current) => ({
...current,
thinkingLevels: (current.thinkingLevels ?? []).filter((item) => item !== level),
thinkingLevelsEdited: true,
}));
};
@@ -2159,7 +2182,7 @@ export function ApiProviderDialog({
</div>
{modelError && !modelDiscoveryOpen ? <MessageNotice message={modelError} onDismiss={() => setModelError('')} /> : null}
</div>
<label><span>{t('apiAccess.field.priority')}</span><input inputMode="numeric" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value.replace(/\D/g, ''))} placeholder={t('common.optional')} /></label>
<label><span>{t('apiAccess.field.priority')}</span><input type="number" step="1" value={draft.priority} onChange={(event) => updateTextField('priority', event.currentTarget.value)} placeholder={t('common.optional')} /></label>
<details className="provider-advanced-settings">
<summary>{t('apiAccess.advanced')}</summary>
<div className="provider-advanced-fields">
@@ -2174,9 +2197,6 @@ export function ApiProviderDialog({
<textarea value={draft.excludedModelsText ?? ''} onChange={(event) => updateTextField('excludedModelsText', event.currentTarget.value)} rows={3} placeholder={'model-old-*\nmodel-preview'} />
</label>
) : null}
{activeSection === 'openai-compatibility' ? (
<label><span>{t('apiAccess.field.testModel')}</span><input value={draft.testModel ?? ''} onChange={(event) => updateTextField('testModel', event.currentTarget.value)} placeholder={t('common.optional')} /></label>
) : null}
{activeSection === 'claude-api-key' ? (
<div className="provider-cloak-settings">
<label>
@@ -2198,7 +2218,11 @@ export function ApiProviderDialog({
</div>
<div className="provider-advanced-toggle">
<div><strong>{t('apiAccess.cloak.cacheUser')}</strong><span>{t('apiAccess.cloak.cacheUserDescription')}</span></div>
<label className="switch-control" title={t('apiAccess.cloak.cacheUser')}><input type="checkbox" checked={Boolean(draft.cloakCacheUserId)} onChange={(event) => updateBooleanField('cloakCacheUserId', event.currentTarget.checked)} /><span className="switch-track" /></label>
<select aria-label={t('apiAccess.cloak.cacheUser')} value={draft.cloakCacheUserId == null ? '' : String(draft.cloakCacheUserId)} onChange={(event) => updateOptionalBooleanField('cloakCacheUserId', event.currentTarget.value)}>
<option value="">{t('apiAccess.option.inherit')}</option>
<option value="true">{t('common.enabled')}</option>
<option value="false">{t('common.disabled')}</option>
</select>
</div>
</div>
) : null}
@@ -2210,7 +2234,11 @@ export function ApiProviderDialog({
) : null}
<div className="provider-advanced-toggle">
<div><strong>{t('apiAccess.cooling.title')}</strong><span>{t('apiAccess.cooling.description')}</span></div>
<label className="switch-control" title={t('apiAccess.cooling.title')}><input type="checkbox" checked={Boolean(draft.disableCooling)} onChange={(event) => updateBooleanField('disableCooling', event.currentTarget.checked)} /><span className="switch-track" /></label>
<select aria-label={t('apiAccess.cooling.title')} value={draft.disableCooling == null ? '' : String(draft.disableCooling)} onChange={(event) => updateOptionalBooleanField('disableCooling', event.currentTarget.value)}>
<option value="">{t('apiAccess.option.inherit')}</option>
<option value="true">{t('apiAccess.cooling.disable')}</option>
<option value="false">{t('apiAccess.cooling.enable')}</option>
</select>
</div>
</div>
</details>
+1
View File
@@ -136,6 +136,7 @@ const cleanNetworkDraft = (): NetworkDraftDirty => ({
const ROUTING_OPTIONS = [
{ value: 'round-robin', labelKey: 'config.routing.roundRobin' },
{ value: 'weighted-round-robin', labelKey: 'config.routing.weightedRoundRobin' },
{ value: 'fill-first', labelKey: 'config.routing.fillFirst' },
] as const;
+14 -4
View File
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { getCurrentLocale, translate } from '../i18n';
import { normalizeProviderModels } from './providerModels';
export type ManagementJson = Record<string, unknown> | unknown[] | string | number | boolean | null;
@@ -58,22 +59,28 @@ export function flattenV8ProviderGroups(provider: string, payload: unknown): Rec
if (provider === 'openai-compatibility') {
const record: Record<string, unknown> = { ...group };
delete record.keys;
delete record['test-model'];
delete record.testModel;
if (keys.length > 0) record['api-key-entries'] = keys.map((key) => ({ ...key }));
return [record];
return [normalizeProviderModels(record)];
}
const shared = Object.fromEntries(
Object.entries(group).filter(([key]) => key === 'base-url' || SHARED_PROVIDER_FIELDS.has(key)),
);
const name = readString(group, 'name');
const generatedName = name.startsWith(`${provider}-`) && /^[0-9]+$/.test(name.slice(provider.length + 1));
if (name && !generatedName) shared.name = name;
return keys.map((key) => {
const overrides = Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null));
return { ...shared, ...overrides };
return normalizeProviderModels({ ...shared, ...overrides });
});
});
}
export function groupLegacyProviderRecords(provider: string, payload: unknown): Record<string, unknown>[] {
if (!Array.isArray(payload)) return [];
return payload.filter(isRecord).map((record, index) => {
return payload.filter(isRecord).map((input, index) => {
const record = normalizeProviderModels(input);
if (provider === 'openai-compatibility') {
const group: Record<string, unknown> = {
...record,
@@ -82,11 +89,14 @@ export function groupLegacyProviderRecords(provider: string, payload: unknown):
: [],
};
delete group['api-key-entries'];
delete group['test-model'];
delete group.testModel;
return group;
}
const group: Record<string, unknown> = { name: `${provider}-${index + 1}` };
const group: Record<string, unknown> = { name: readString(record, 'name') || `${provider}-${index + 1}` };
const key: Record<string, unknown> = {};
Object.entries(record).forEach(([field, value]) => {
if (field === 'name') return;
if (field === 'base-url' || SHARED_PROVIDER_FIELDS.has(field)) group[field] = value;
else key[field] = value;
});
+3 -2
View File
@@ -1,5 +1,6 @@
import { apiCallErrorMessage, isRecord, managementApi, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
import { normalizeThinkingConfig } from './providerModels';
const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrentLocale(), key);
@@ -144,13 +145,13 @@ const normalizeModelList = (payload: unknown, preserveExistingAlias = false): Mo
const alias = preserveExistingAlias ? rawAlias : usableModelAlias(rawAlias);
const displayName = record ? readString(record, 'display-name', 'display_name', 'displayName') : '';
const thinking = record && isRecord(record.thinking)
? { ...record.thinking }
? normalizeThinkingConfig(record.thinking, !preserveExistingAlias)
: undefined;
return {
name,
...(alias && alias !== name ? { alias } : {}),
...(displayName && displayName !== name ? { displayName } : {}),
...(thinking ? { thinking } : {}),
...(thinking && (preserveExistingAlias || Object.keys(thinking).length > 0) ? { thinking } : {}),
};
}).filter((item): item is ModelOption => item !== null);
};
+25
View File
@@ -0,0 +1,25 @@
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export function normalizeThinkingConfig(value: Record<string, unknown>, discovered = false) {
const next = { ...value };
for (const [json, yaml] of [['zero_allowed', 'zero-allowed'], ['dynamic_allowed', 'dynamic-allowed']]) {
if (!(yaml in next) && json in next) next[yaml] = next[json];
delete next[json];
}
if (!discovered) return next;
return Object.fromEntries(Object.entries(next).filter(([key, item]) => {
if (key === 'min' || key === 'max') return Number.isSafeInteger(item) && Number(item) >= 0;
if (key === 'zero-allowed' || key === 'dynamic-allowed') return typeof item === 'boolean';
return key === 'levels' && Array.isArray(item) && item.every((level) => typeof level === 'string');
}));
}
export function normalizeProviderModels(record: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(record.models)) return { ...record };
return { ...record, models: record.models.map((model) => (
isObject(model) && isObject(model.thinking)
? { ...model, thinking: normalizeThinkingConfig(model.thinking) }
: model
)) };
}
+8 -1
View File
@@ -5324,7 +5324,8 @@ textarea:focus-visible {
background: var(--theme-fffdf8);
}
.provider-cloak-settings select {
.provider-cloak-settings select,
.provider-advanced-toggle > select {
width: 100%;
min-width: 0;
height: 40px;
@@ -5335,6 +5336,12 @@ textarea:focus-visible {
color: var(--theme-2d2a26);
}
.provider-advanced-toggle > select {
width: auto;
max-width: 55%;
flex: 0 1 auto;
}
.thinking-alias-page {
grid-template-rows: auto;
gap: 12px;
+19
View File
@@ -74,6 +74,8 @@ const path = require('node:path');
await form.locator('.provider-advanced-settings summary').click();
const headers = form.getByLabel(/Custom Headers/);
await headers.fill('X-Layout-Test: reachable');
await form.getByRole('combobox', { name: 'Disable Cooldown', exact: true }).selectOption('false');
await form.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
await assertDialogControls(form);
const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight }));
assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog');
@@ -97,13 +99,30 @@ const path = require('node:path');
const record = await page.evaluate(() => window.providerFixture.records[0]);
assert.equal(record.headers['X-Layout-Test'], 'reachable');
assert.equal(record.models.length, 30);
assert.equal(record['disable-cooling'], false);
assert.equal(record.cloak['cache-user-id'], false);
assert.equal(await page.evaluate(() => document.body.style.overflow), '');
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
await form.locator('.provider-advanced-settings summary').click();
for (const name of ['Disable Cooldown', 'Cache User ID']) {
const control = form.getByRole('combobox', { name, exact: true });
assert.equal(await control.inputValue(), 'false');
await control.selectOption('');
}
await form.getByRole('button', { name: 'Save', exact: true }).click();
await form.waitFor({ state: 'detached' });
const inherited = await page.evaluate(() => window.providerFixture.records[0]);
assert.equal(inherited['disable-cooling'], undefined);
assert.equal(inherited.cloak?.['cache-user-id'], undefined);
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
await page.getByRole('button', { name: 'Add', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add API Connection', exact: true });
await assertViewportOverlay('.config-dialog-backdrop');
await assertDialogControls(add);
await add.locator('.provider-advanced-settings summary').click();
assert.equal(await add.getByLabel('Test Model', { exact: true }).count(), 0);
await add.getByRole('button', { name: 'Cancel', exact: true }).click();
await add.waitFor({ state: 'detached' });
assert.deepEqual(errors, []);
+173
View File
@@ -0,0 +1,173 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import { managementApi, responseList } from '../src/services/managementApi';
import { loadAuthFileSettings, saveAuthFileSettings } from '../src/services/authFileSettings';
import { setOAuthCredentialFileDisabled } from '../src/services/authFiles';
import { loadOAuthModelSettings, saveOAuthModelSettings } from '../src/services/oauthModelSettings';
import { fetchModels } from '../src/services/modelService';
const executable = process.env.CPA_V8_TEST_CORE;
describe.skipIf(!executable)('real v8 management operations', () => {
let child: ChildProcess;
let work = '';
let origin = '';
let originalWindow: PropertyDescriptor | undefined;
const headers = { Authorization: 'Bearer isolated-test-secret', 'Content-Type': 'application/json' };
const name = 'contract-codex.json';
const call = async (method: string, path: string, query?: Record<string, string>, body?: unknown) => {
const url = new URL(origin + path);
Object.entries(query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value));
const response = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
const text = await response.text();
let result: any;
try { result = JSON.parse(text); } catch { result = text; }
if (!response.ok) throw new Error(`Management API error (${response.status}): ${result.error ?? text}${result.message ? `: ${result.message}` : ''}`);
return result;
};
beforeAll(async () => {
work = await mkdtemp(join(tmpdir(), 'easycpa-v8-operations-'));
const listener = createServer();
await new Promise<void>((done) => listener.listen(0, '127.0.0.1', done));
const port = (listener.address() as { port: number }).port;
await new Promise<void>((done) => listener.close(() => done()));
const config = join(work, 'config.yaml');
await writeFile(config, `config-version: 8
server: {host: 127.0.0.1, port: ${port}}
management: {secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}
access: {api-keys: [isolated-client-key]}
oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
`);
child = spawn(resolve(executable!), ['-config', config, '-local-model'], {
cwd: work, windowsHide: true, stdio: 'ignore',
});
let startupError: Error | undefined;
child.on('error', (error) => { startupError = error; });
origin = `http://127.0.0.1:${port}/v8/management`;
let ready = false;
for (let attempt = 0; attempt < 100; attempt++) {
if (startupError) throw startupError;
if (child.exitCode !== null) throw new Error(`Kernel exited: ${child.exitCode}`);
try { await call('GET', '/config'); ready = true; break; } catch {}
await Bun.sleep(100);
}
if (!ready) throw new Error('Kernel startup timed out');
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
mockIPC(async (command, args) => {
if (command === 'upload_auth_file') {
return call('POST', '/credentials', { name: args!.name as string }, JSON.parse(new TextDecoder().decode(new Uint8Array(args!.data as number[]))));
}
expect(command).toBe('management_request');
const request = args!.request as { method: string; path: string; query?: Record<string, string>; body?: unknown };
return call(request.method, request.path, request.query, request.body);
});
}, 15000);
afterAll(async () => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (child?.pid && child.exitCode === null) {
const exited = new Promise<void>((done) => child.once('exit', () => done()));
child.kill();
await exited;
}
if (work && resolve(work).startsWith(resolve(tmpdir()) + sep + 'easycpa-v8-operations-')) {
await rm(work, { recursive: true, force: true });
}
});
it('uploads, edits every credential setting, clears overrides, toggles and deletes', async () => {
await managementApi.uploadAuthFile(new File([JSON.stringify({
type: 'codex', access_token: 'isolated-access-token', expired: '2099-01-01T00:00:00Z',
email: 'test@example.invalid', custom_metadata: { preserve: true },
})], name, { type: 'application/json' }));
let file: Record<string, unknown> | undefined;
for (let attempt = 0; attempt < 50; attempt++) {
file = responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name);
if (file) break;
await Bun.sleep(100);
}
expect(file).toBeDefined();
const original = await loadAuthFileSettings(name);
const edited = {
...original, prefix: 'team', proxy_url: 'direct', priority: '-2', weight: '3',
disable_cooling: 'false' as const, websockets: 'false' as const,
excluded_models: 'old-*\nretired', headers: '{"X-Test":"one","X-Remove":"two"}', note: 'test note',
};
await saveAuthFileSettings(name, original, edited);
const reloaded = await loadAuthFileSettings(name);
expect(reloaded).toMatchObject({ ...edited, headers: reloaded.headers });
expect(JSON.parse(reloaded.headers)).toEqual(JSON.parse(edited.headers));
const models = await loadOAuthModelSettings({ scope: 'credential', provider: 'codex', label: 'test', name });
expect(models.catalogError).toBe('');
expect(models.excludedRules).toEqual(['old-*', 'retired']);
await saveOAuthModelSettings(models, ['next-*']);
const beforeClear = await loadAuthFileSettings(name);
await saveAuthFileSettings(name, beforeClear, {
...beforeClear, prefix: '', proxy_url: '', priority: '', weight: '',
disable_cooling: '', websockets: '', excluded_models: '', headers: '{"X-Test":"updated"}', note: '',
});
const cleared = await loadAuthFileSettings(name);
expect(cleared).toMatchObject({ prefix: '', proxy_url: '', priority: '0', weight: '',
disable_cooling: '', websockets: '', excluded_models: '', note: '' });
expect(JSON.parse(cleared.headers)).toEqual({ 'X-Test': 'updated' });
const metadata = await managementApi.get<Record<string, unknown>>('/auth-files/download', { name });
expect(metadata.custom_metadata).toEqual({ preserve: true });
expect(metadata.access_token).toBe('isolated-access-token');
await setOAuthCredentialFileDisabled(file!, true);
expect(responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name)?.disabled).toBe(true);
await setOAuthCredentialFileDisabled(file!, false);
expect(responseList(await managementApi.get('/auth-files'), 'files').find((item) => item.name === name)?.disabled).toBe(false);
await managementApi.delete('/auth-files', { query: { name } });
expect(responseList(await managementApi.get('/auth-files'), 'files').some((item) => item.name === name)).toBe(false);
});
it('edits provider model exclusions without changing siblings and clears the last rule', async () => {
await call('PUT', '/config/oauth/excluded-models', undefined, null);
const target = { scope: 'provider' as const, provider: 'codex', label: 'Codex' };
const original = await loadOAuthModelSettings(target);
expect(original.catalogError).toBe('');
expect(original.models.length).toBeGreaterThan(0);
expect(original.excludedRules).toEqual([]);
await managementApi.patch('/oauth-excluded-models', { provider: 'claude', models: ['keep-*'] });
await saveOAuthModelSettings(original, ['hide-*']);
const edited = await loadOAuthModelSettings(target);
expect(edited.excludedRules).toEqual(['hide-*']);
await saveOAuthModelSettings(edited, []);
expect((await loadOAuthModelSettings(target)).excludedRules).toEqual([]);
expect(await managementApi.get('/oauth-excluded-models')).toEqual({ 'oauth-excluded-models': { claude: ['keep-*'] } });
await managementApi.delete('/oauth-excluded-models', { query: { provider: 'claude' } });
});
it('discovers models and forwards API-call requests to an isolated local upstream', async () => {
const upstream = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
if (request.headers.get('Authorization') !== 'Bearer local-key') return new Response('wrong key', { status: 401 });
return Response.json({ data: [{ id: 'local-model' }] });
} });
try {
const models = await fetchModels('openai', `http://127.0.0.1:${upstream.port}/v1`, 'local-key');
expect(models.map((model) => model.name)).toEqual(['local-model']);
const response = await managementApi.post<Record<string, unknown>>('/api-call', {
method: 'GET', url: `http://127.0.0.1:${upstream.port}/usage`, header: { Authorization: 'Bearer local-key' },
});
expect(response.status_code).toBe(200);
expect(JSON.parse(response.body as string)).toEqual({ data: [{ id: 'local-model' }] });
} finally { await upstream.stop(true); }
});
it('uses the v8 usage queue and OAuth validation/session endpoints', async () => {
expect(await call('GET', '/observability/usage/queue', { count: '10' })).toEqual([]);
const status = await call('GET', '/oauth/status', { state: 'isolated-unknown-state' });
expect(typeof status.status).toBe('string');
await expect(call('GET', '/oauth/auth-url', { provider: 'unsupported-test-provider' })).rejects.toThrow('(404): provider_not_found');
await expect(call('POST', '/oauth/callback', undefined, { provider: 'codex', redirect_url: 'http://localhost/callback' })).rejects.toThrow('(400)');
await managementApi.delete('/oauth-session', { query: { state: 'isolated-unknown-state' } });
});
});
+69 -3
View File
@@ -7,8 +7,15 @@ import {
legacyManagementConfigView,
managementApi,
} from '../src/services/managementApi';
import {
apiAccessRemarkLocatorFromRecord,
buildProviderRecord,
createProviderDraft,
providerCategoryMatchesRecord,
providerRecordWithDisabledState,
} from '../src/pages/ApiAccessPage';
describe('optional v8 config reads', () => {
describe('v8 Management API requests', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
@@ -38,6 +45,48 @@ describe('optional v8 config reads', () => {
}
}
});
it('saves and reloads DeepSeek through the v8 Codex group without credential names', async () => {
for (const baseUrl of ['https://api.deepseek.com', 'https://gateway.example.test/v1']) {
let persisted: unknown = [];
mockIPC((command, args) => {
expect(command).toBe('management_request');
const request = args?.request as { method: string; path: string; body: unknown };
expect(request.path).toBe('/config/api-keys/codex');
if (request.method === 'GET') return persisted;
expect(request.method).toBe('PUT');
const groups = request.body as Record<string, unknown>[];
for (const group of groups) {
for (const key of group.keys as Record<string, unknown>[]) {
if ('name' in key) throw new Error('invalid_config: field name not found in type config.CodexKey');
}
}
persisted = request.body;
return { status: 'ok' };
});
const original = buildProviderRecord('codex-api-key', {
...createProviderDraft('deepseek'), apiKey: 'deepseek-test-key', baseUrl,
models: [{ name: 'deepseek-chat', alias: 'my-deepseek' }],
priority: '5', proxyUrl: 'direct', headersText: 'X-Team: test',
});
const regular = buildProviderRecord('codex-api-key', {
...createProviderDraft('codex-api-key'), apiKey: 'codex-test-key',
baseUrl: 'https://codex.example.test/v1', models: [{ name: 'gpt-test' }],
});
for (const record of [original, providerRecordWithDisabledState('codex-api-key', original, true)]) {
await managementApi.put('/codex-api-key', [regular, record]);
const loaded = await managementApi.get<{ 'codex-api-key': Record<string, unknown>[] }>('/codex-api-key');
expect(loaded['codex-api-key']).toEqual([regular, record]);
expect(providerCategoryMatchesRecord('deepseek', loaded['codex-api-key'][1])).toBe(true);
expect(apiAccessRemarkLocatorFromRecord('codex-api-key', loaded['codex-api-key'][1]))
.toEqual(apiAccessRemarkLocatorFromRecord('codex-api-key', original));
await managementApi.put('/codex-api-key', [record, regular]);
expect((await managementApi.get('/codex-api-key'))).toEqual({ 'codex-api-key': [record, regular] });
}
await managementApi.put('/codex-api-key', [regular]);
expect(await managementApi.get('/codex-api-key')).toEqual({ 'codex-api-key': [regular] });
}
});
});
describe('apiCallErrorMessage', () => {
@@ -72,10 +121,12 @@ describe('v8 Management API compatibility view', () => {
}];
expect(flattenV8ProviderGroups('codex', groups)).toEqual([
{
name: 'production',
'base-url': 'https://example.test/v1', priority: 10,
models: [{ name: 'model-a' }], 'api-key': 'first', 'proxy-url': 'direct',
},
{
name: 'production',
'base-url': 'https://example.test/v1', priority: 0,
models: [{ name: 'model-a' }], 'api-key': 'second',
},
@@ -90,13 +141,28 @@ describe('v8 Management API compatibility view', () => {
models: [{ name: 'claude-test' }],
cloak: { mode: 'always' },
}])).toEqual([{
name: 'claude-1',
name: 'delegated-key',
'base-url': 'https://claude.test',
models: [{ name: 'claude-test' }],
keys: [{ name: 'delegated-key', 'api-key': 'secret', cloak: { mode: 'always' } }],
keys: [{ 'api-key': 'secret', cloak: { mode: 'always' } }],
}]);
});
it('repairs legacy credential names while preserving policies and unnamed record identities', () => {
const records = flattenV8ProviderGroups('codex', [{
name: 'codex-1', 'base-url': 'https://gateway.example.test/v1', priority: 5,
keys: [
{ name: 'DeepSeek', 'api-key': 'first', weight: 2 },
{ 'api-key': 'second', priority: 0 },
],
}]);
const groups = groupLegacyProviderRecords('codex', records);
expect(groups[0]).toMatchObject({ name: 'DeepSeek', keys: [{ 'api-key': 'first', weight: 2 }] });
expect(groups[1]).toMatchObject({ name: 'codex-2', keys: [{ 'api-key': 'second' }] });
expect(flattenV8ProviderGroups('codex', groups)).toEqual(records);
expect(records[1]).not.toHaveProperty('name');
});
it('projects the v8 config tree into the legacy semantic view used by the UI', () => {
const view = legacyManagementConfigView({
access: { 'api-keys': ['client-key'] },
+170
View File
@@ -0,0 +1,170 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { spawn, type ChildProcess } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve, sep } from 'node:path';
import {
applyProviderPreset, buildProviderRecord, createProviderDraft,
type ProviderSection,
} from '../src/pages/ApiAccessPage';
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../src/services/managementApi';
const executable = process.env.CPA_V8_TEST_CORE;
describe.skipIf(!executable)('real v8 provider configuration', () => {
let child: ChildProcess;
let work = '';
let origin = '';
const headers = { Authorization: 'Bearer isolated-test-secret', 'Content-Type': 'application/json' };
beforeAll(async () => {
work = await mkdtemp(join(tmpdir(), 'easycpa-v8-fields-'));
const listener = createServer();
await new Promise<void>((done) => listener.listen(0, '127.0.0.1', done));
const port = (listener.address() as { port: number }).port;
await new Promise<void>((done) => listener.close(() => done()));
const config = join(work, 'config.yaml');
await writeFile(config, `config-version: 8
server: {host: 127.0.0.1, port: ${port}}
management: {secret-key: isolated-test-secret, disable-control-panel: true, disable-auto-update-panel: true}
access: {api-keys: [isolated-client-key]}
oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
`);
child = spawn(resolve(executable!), ['--config', config, '--local-model'], {
cwd: work, windowsHide: true, stdio: 'ignore',
});
let startupError: Error | undefined;
child.on('error', (error) => { startupError = error; });
origin = `http://127.0.0.1:${port}/v8/management`;
for (let attempt = 0; attempt < 100; attempt++) {
if (startupError) throw startupError;
if (child.exitCode !== null) throw new Error(`Kernel exited: ${child.exitCode}`);
try { if ((await fetch(`${origin}/config`, { headers })).ok) return; } catch {}
await Bun.sleep(100);
}
throw new Error('Kernel startup timed out');
}, 15000);
afterAll(async () => {
if (child?.pid && child.exitCode === null) {
const exited = new Promise<void>((done) => child.once('exit', () => done()));
child.kill();
await exited;
}
if (work && resolve(work).startsWith(resolve(tmpdir()) + sep + 'easycpa-v8-fields-')) {
await rm(work, { recursive: true, force: true });
}
});
const save = async (provider: string, records: Record<string, unknown>[]) => {
const url = `${origin}/config/api-keys/${provider}`;
const response = await fetch(url, {
method: 'PUT', headers, body: JSON.stringify(groupLegacyProviderRecords(provider, records)),
});
const result = await response.text();
expect({ status: response.status, result: response.ok ? 'ok' : result }).toEqual({ status: 200, result: 'ok' });
const loaded = await fetch(url, { headers });
expect(loaded.status).toBe(200);
return flattenV8ProviderGroups(provider, await loaded.json());
};
for (const category of ['codex-api-key', 'deepseek', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
const section: ProviderSection = category === 'deepseek' ? 'codex-api-key' : category;
const provider = section.replace('-api-key', '');
it(`${category}: accepts advanced fields and discovered reasoning metadata`, async () => {
const draft = {
...createProviderDraft(category), name: category === 'deepseek' ? 'DeepSeek' : 'integration',
apiKey: 'test-key', baseUrl: 'https://gateway.example.test/v1', priority: '-2',
proxyUrl: 'direct', prefix: 'team', headersText: 'X-Team: test', excludedModelsText: 'old-*',
disableCooling: true, websockets: true,
cloakMode: 'always', cloakStrictMode: true, cloakSensitiveWordsText: 'internal', cloakCacheUserId: true,
models: [{ name: 'upstream', alias: 'public', thinking: {
min: 0, max: 8192, zero_allowed: true, dynamic_allowed: true, levels: ['low', 'high'],
} }],
};
const [loaded] = await save(provider, [buildProviderRecord(section, draft)]);
expect(loaded).toMatchObject({ priority: -2, prefix: 'team', headers: { 'X-Team': 'test' }, 'disable-cooling': true });
expect(loaded.models).toMatchObject([{ name: 'upstream', alias: 'public', thinking: {
min: 0, max: 8192, 'zero-allowed': true, 'dynamic-allowed': true, levels: ['low', 'high'],
} }]);
if (section === 'openai-compatibility') expect(loaded['api-key-entries']).toMatchObject([{ 'proxy-url': 'direct' }]);
else expect(loaded).toMatchObject({ 'proxy-url': 'direct', 'excluded-models': ['old-*'] });
if (section === 'codex-api-key') expect(loaded.websockets).toBe(true);
if (section === 'claude-api-key') expect(loaded.cloak).toMatchObject({
mode: 'always', 'strict-mode': true, 'sensitive-words': ['internal'], 'cache-user-id': true,
});
const editedDraft = { ...draft, priority: '0', prefix: '', headersText: '', proxyUrl: '',
excludedModelsText: '', disableCooling: false, websockets: false,
cloakMode: 'never', cloakStrictMode: false, cloakSensitiveWordsText: '', cloakCacheUserId: false,
models: [{ name: 'upstream', alias: 'renamed' }],
};
const [edited] = await save(provider, [buildProviderRecord(section, editedDraft, loaded)]);
expect(edited.priority ?? 0).toBe(0);
for (const field of ['prefix', 'headers', 'proxy-url', 'excluded-models']) expect(edited).not.toHaveProperty(field);
expect(edited['disable-cooling']).toBe(false);
expect(edited.models).toMatchObject([{ name: 'upstream', alias: 'renamed' }]);
if (section === 'codex-api-key') expect(edited.websockets ?? false).toBe(false);
if (section === 'openai-compatibility') {
expect((edited['api-key-entries'] as Record<string, unknown>[])[0]).not.toHaveProperty('proxy-url');
}
if (section === 'claude-api-key') {
expect(edited.cloak).toMatchObject({ mode: 'never', 'cache-user-id': false });
expect((edited.cloak as Record<string, unknown>)['strict-mode'] ?? false).toBe(false);
expect(edited.cloak).not.toHaveProperty('sensitive-words');
}
const [inherited] = await save(provider, [buildProviderRecord(section, {
...editedDraft, disableCooling: null, cloakMode: '', cloakCacheUserId: null,
}, edited)]);
expect(inherited).not.toHaveProperty('disable-cooling');
if (section === 'claude-api-key') expect(inherited).not.toHaveProperty('cloak');
});
}
for (const provider of ['interactions', 'vertex', 'xai', 'meta']) {
it(`${provider}: round-trips the additional provider families supported by the adapter`, async () => {
const [loaded] = await save(provider, [{
'api-key': 'test-key', 'base-url': 'https://gateway.example.test/v1', weight: 2,
'disable-cooling': false, 'request-retry': 0,
models: [{ name: 'model', thinking: { zero_allowed: false, dynamic_allowed: true } }],
}]);
expect(loaded).toMatchObject({ weight: 2, 'disable-cooling': false, 'request-retry': 0 });
expect(loaded.models).toMatchObject([{ thinking: { 'zero-allowed': false, 'dynamic-allowed': true } }]);
});
}
it('removes obsolete OpenAI test-model metadata when saving old records', async () => {
const [loaded] = await save('openai-compatibility', [{
name: 'compat', 'base-url': 'https://gateway.example.test/v1',
'api-key-entries': [{ 'api-key': 'test-key' }], models: [{ name: 'model' }], 'test-model': 'model',
}]);
expect(loaded).not.toHaveProperty('test-model');
});
it('persists explicit false for cooldown and Claude user ID caching', async () => {
const draft = { ...createProviderDraft('claude-api-key'), apiKey: 'test-key',
baseUrl: 'https://gateway.example.test', disableCooling: false,
cloakMode: 'always', cloakCacheUserId: false,
};
const [loaded] = await save('claude', [buildProviderRecord('claude-api-key', draft)]);
expect(loaded['disable-cooling']).toBe(false);
expect(loaded.cloak).toMatchObject({ 'cache-user-id': false });
});
it('clears reasoning levels without restoring the previous values or dropping model options', async () => {
const current = { name: 'compat', 'base-url': 'https://gateway.example.test/v1',
'api-key-entries': [{ 'api-key': 'test-key' }], models: [
{ name: 'model', alias: 'first', 'max-context-length': 65536, thinking: { levels: ['low', 'high'] } },
{ name: 'model', alias: 'second', thinking: { min: 128, max: 8192, levels: ['high'] } },
],
};
const draft = applyProviderPreset('openai-compatibility', {
...createProviderDraft('openai-compatibility'), name: 'compat', apiKey: 'test-key',
baseUrl: current['base-url'], thinkingLevels: [], thinkingLevelsEdited: true,
models: [{ name: 'model', alias: 'first', thinking: { levels: ['low', 'high'] } }],
});
const [loaded] = await save('openai-compatibility', [buildProviderRecord('openai-compatibility', draft, current)]);
expect(loaded.models).toEqual([
{ name: 'model', alias: 'first', 'max-context-length': 65536 },
{ name: 'model', alias: 'second', thinking: { min: 128, max: 8192 } },
]);
});
});
+37 -2
View File
@@ -23,6 +23,7 @@ import {
stripResponseFields,
} from '../src/pages/ApiAccessPage';
import { modelsFromRecord } from '../src/services/modelService';
import { flattenV8ProviderGroups } from '../src/services/managementApi';
describe('shared-credential provider entries (#276)', () => {
const first = {
@@ -68,6 +69,39 @@ describe('shared-credential provider entries (#276)', () => {
expect(hasDuplicateProviderRecord('codex-api-key', [first], [second])).toBe(false);
});
it('rejects duplicate Codex and Claude credentials regardless of v8 group labels', () => {
for (const section of ['codex-api-key', 'claude-api-key'] as const) {
const records = flattenV8ProviderGroups(section.replace('-api-key', ''), [{
name: 'production', 'base-url': first['base-url'], priority: 10,
models: first.models, keys: [{ 'api-key': 'shared-key' }],
}]);
const candidate = buildProviderRecord(section, {
...createProviderDraft(section), apiKey: 'shared-key',
baseUrl: first['base-url'], priority: '10', models: first.models,
});
expect(hasDuplicateProviderRecord(section, records, [candidate])).toBe(true);
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, name: 'another-group' }])).toBe(true);
expect(hasDuplicateProviderRecord(section, records, [candidate], 0)).toBe(false);
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, priority: 1 }])).toBe(false);
expect(hasDuplicateProviderRecord(section, records, [{ ...candidate, models: second.models }])).toBe(false);
expect(records[0].name).toBe('production');
expect(candidate).not.toHaveProperty('name');
}
expect(hasDuplicateProviderRecord('openai-compatibility', [{ ...first, name: 'production' }], [
{ ...first, name: 'another-group' },
])).toBe(false);
});
it('keeps group labels in edit and remark identities when checking for duplicates', () => {
const production = { ...first, name: 'production' };
const staging = { ...first, name: 'staging' };
expect(hasDuplicateProviderRecord('claude-api-key', [production], [staging])).toBe(true);
expect(resolveProviderRecordIndex([staging, production], row(production, 0))).toBe(1);
expect(apiAccessRemarkLocatorFromRecord('claude-api-key', production).providerName).toBe('production');
expect(providerRemarkIdentity('claude-api-key', apiAccessRemarkLocatorFromRecord('claude-api-key', production)))
.not.toBe(providerRemarkIdentity('claude-api-key', apiAccessRemarkLocatorFromRecord('claude-api-key', staging)));
});
it('locates the second entry for edit/toggle/delete, even after an external reorder', () => {
const selected = row({ ...second, 'auth-index': 'runtime-second' }, 1);
expect(resolveProviderRecordIndex([first, second], selected)).toBe(1);
@@ -325,9 +359,10 @@ describe('API 接入配置合并', () => {
name: 'custom-openai',
baseUrl: 'https://api.example.com',
thinkingLevels: ['fast', 'ultra'],
thinkingLevelsEdited: true,
models: [
{ name: 'reasoning-a' },
{ name: 'reasoning-b', thinking: { effort: 'high' } },
{ name: 'reasoning-b', thinking: { min: 128, max: 8192 } },
],
});
const result = buildProviderRecord('openai-compatibility', draft);
@@ -336,7 +371,7 @@ describe('API 接入配置合并', () => {
{ name: 'reasoning-a', thinking: { levels: ['fast', 'ultra'] } },
{
name: 'reasoning-b',
thinking: { effort: 'high', levels: ['fast', 'ultra'] },
thinking: { min: 128, max: 8192, levels: ['fast', 'ultra'] },
},
]);
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'bun:test';
import { applyProviderPreset, buildProviderRecord, createProviderDraft, stripResponseFields } from '../src/pages/ApiAccessPage';
import { modelsFromDiscoveredPayload, modelsFromRecord } from '../src/services/modelService';
describe('provider field editing semantics', () => {
it('distinguishes explicit false, inherited defaults and untouched optional settings', () => {
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
const draft = { ...createProviderDraft(section), name: 'provider', apiKey: 'key', baseUrl: 'https://example.test/v1' };
const current = { 'disable-cooling': false, 'request-retry': 0, weight: 0 };
for (const value of [true, false, null, undefined]) {
const result = buildProviderRecord(section, { ...draft, disableCooling: value }, current);
if (value === null) expect(result).not.toHaveProperty('disable-cooling');
else expect(result['disable-cooling']).toBe(value ?? false);
expect(result['request-retry']).toBe(0);
expect(result.weight).toBe(0);
}
}
});
it('preserves other cloak fields during a partial update and can restore inherited caching', () => {
const draft = { name: '', remark: '', apiKey: 'key', baseUrl: '', priority: '', models: [] };
const current = { cloak: { mode: 'always', 'strict-mode': true, 'cache-user-id': true, 'sensitive-words': ['private'] } };
for (const value of [false, true, null]) {
const result = buildProviderRecord('claude-api-key', { ...draft, cloakCacheUserId: value }, current);
expect(result.cloak).toMatchObject({ mode: 'always', 'strict-mode': true, 'sensitive-words': ['private'] });
if (value === null) expect(result.cloak).not.toHaveProperty('cache-user-id');
else expect(result.cloak).toMatchObject({ 'cache-user-id': value });
}
expect(current.cloak['cache-user-id']).toBe(true);
});
it('leaves each model reasoning policy intact when only another field was edited', () => {
const current = { name: 'compat', models: [
{ name: 'first', thinking: { levels: ['low', 'high'] } },
{ name: 'second', thinking: { min: 128, max: 8192, levels: ['medium'] } },
] };
const draft = applyProviderPreset('openai-compatibility', {
...createProviderDraft('openai-compatibility'), name: 'compat', apiKey: 'key',
models: modelsFromRecord(current.models), thinkingLevels: ['low', 'high', 'medium'],
thinkingLevelsEdited: false, priority: '2',
});
expect(buildProviderRecord('openai-compatibility', draft, current).models).toEqual(current.models);
});
it('normalizes discovery JSON names and ignores non-config upstream reasoning metadata', () => {
const models = modelsFromDiscoveredPayload({ data: [{ id: 'model', thinking: {
min: 128, max: 8192, zero_allowed: true, 'zero-allowed': false, dynamic_allowed: true,
levels: ['low', 'high'], effort: 'high', vendor_metadata: { arbitrary: true },
} }, { id: 'bad-metadata', thinking: { min: 'oops', max: 1.5, levels: [1], zero_allowed: 'yes' } }] });
expect(models[0].thinking).toEqual({ min: 128, max: 8192, 'zero-allowed': false, 'dynamic-allowed': true, levels: ['low', 'high'] });
expect(models[1].thinking).toBeUndefined();
expect(stripResponseFields({ 'test-model': 'old', models: [{ name: 'model', thinking: { zero_allowed: true }, custom: true }] }))
.toEqual({ models: [{ name: 'model', thinking: { 'zero-allowed': true }, custom: true }] });
});
it('accepts negative priorities and rejects fractional, nonnumeric and unsafe values', () => {
const draft = { ...createProviderDraft('codex-api-key'), apiKey: 'key' };
expect(buildProviderRecord('codex-api-key', { ...draft, priority: '-2' }).priority).toBe(-2);
for (const priority of ['1.5', 'abc', 'Infinity', '9007199254740992']) {
expect(() => buildProviderRecord('codex-api-key', { ...draft, priority })).toThrow('Priority must be a safe integer');
}
});
});