Enhance support for the v8 kernel
This commit is contained in:
1 parent
9991aea818
commit
59b05e9281
25 files changed
+1463
-185
No files matched your search
+209
-26
@@ -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, ¤t_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, ¤t)
|
||||
.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, ¤t_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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
§ion_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
|
||||
|
||||
@@ -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 "] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, ¤t, &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 [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(¤t, &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(¤t, &["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
@@ -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 件)',
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user