feat(api-access): support native CPA v8 groups and fix alias source validation
This commit is contained in:
1 parent
6dc8650e68
commit
84fe2be8ac
22 files changed
+1136
-95
No files matched your search
@@ -5,11 +5,13 @@ mod alias_edit;
|
||||
mod alias_save;
|
||||
mod aliases;
|
||||
mod commands;
|
||||
mod provider_groups;
|
||||
mod settings;
|
||||
mod yaml;
|
||||
pub(crate) use alias_edit::*;
|
||||
pub(crate) use alias_save::*;
|
||||
pub(crate) use aliases::*;
|
||||
pub(crate) use commands::*;
|
||||
pub(crate) use provider_groups::*;
|
||||
pub(crate) use settings::*;
|
||||
pub(crate) use yaml::*;
|
||||
@@ -102,7 +102,8 @@ pub(crate) async fn commit_management_alias_config_changes<T>(
|
||||
let _guard = SAVE_LOCK.lock().await;
|
||||
validate_alias_api_access_preserved(current, updated)?;
|
||||
let changes = management_alias_config_changes(current, updated)?;
|
||||
let latest = fetch_management_config_yaml(config).await?;
|
||||
let native_before = fetch_management_raw_config_yaml(config).await?;
|
||||
let latest = management_v8_yaml_to_legacy_view(&native_before)?;
|
||||
if !alias_config_is_unchanged(current, &latest)? {
|
||||
return Err("Configuration changed. Close the editor, refresh, and try again".to_string());
|
||||
}
|
||||
@@ -118,7 +119,7 @@ pub(crate) async fn commit_management_alias_config_changes<T>(
|
||||
.await;
|
||||
match result {
|
||||
Ok(value) => Ok(value),
|
||||
Err(error) => Err(match restore_management_alias_config(config, current, updated).await {
|
||||
Err(error) => Err(match restore_management_alias_config(config, current, updated, &native_before).await {
|
||||
Ok(()) => format!("Save failed. Original configuration was restored; you can retry: {error}"),
|
||||
Err(restore_error) => format!("Save failed: {error}; automatic restoration failed, and the configuration may be partially written. Close the editor, refresh, and inspect it: {restore_error}"),
|
||||
}),
|
||||
@@ -129,6 +130,7 @@ async fn restore_management_alias_config(
|
||||
config: &GuiConfigFile,
|
||||
current: &str,
|
||||
updated: &str,
|
||||
native_before: &str,
|
||||
) -> Result<(), String> {
|
||||
let latest = fetch_management_config_yaml(config).await?;
|
||||
validate_alias_transaction_state(current, updated, &latest)?;
|
||||
@@ -139,6 +141,13 @@ async fn restore_management_alias_config(
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
// Semantic rollback can leave an explicit per-key model list where the key
|
||||
// originally inherited its group. Restore the original native layout too.
|
||||
if errors.is_empty() {
|
||||
if let Err(error) = restore_native_provider_groups(config, native_before).await {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if let Some(aliases) = management_alias_config_changes(&latest, current)?.oauth_model_aliases {
|
||||
if let Err(error) = put_management_oauth_model_aliases(config, &aliases).await {
|
||||
errors.push(error);
|
||||
|
||||
@@ -226,6 +226,10 @@ pub(crate) fn validate_thinking_alias_effort(value: &str) -> Result<String, Stri
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Result<String, String> {
|
||||
management_v8_yaml_to_legacy_view(&fetch_management_raw_config_yaml(config).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_management_raw_config_yaml(config: &GuiConfigFile) -> Result<String, String> {
|
||||
let client = management_http_client()?;
|
||||
let response = client
|
||||
.get(management_endpoint(config, "config.yaml")?)
|
||||
@@ -237,8 +241,7 @@ pub(crate) async fn fetch_management_config_yaml(config: &GuiConfigFile) -> Resu
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format_management_request_error("Failed to read kernel YAML configuration", &error))?;
|
||||
let content = read_management_text(response).await?;
|
||||
management_v8_yaml_to_legacy_view(&content)
|
||||
read_management_text(response).await
|
||||
}
|
||||
|
||||
pub(crate) async fn put_management_config_yaml(
|
||||
@@ -528,7 +531,7 @@ pub(crate) fn management_v8_yaml_to_legacy_view(content: &str) -> Result<String,
|
||||
.map_err(|error| format!("Failed to serialize kernel YAML configuration: {error}"))
|
||||
}
|
||||
|
||||
async fn put_management_config_value(
|
||||
pub(crate) async fn put_management_config_value(
|
||||
config: &GuiConfigFile,
|
||||
path: &str,
|
||||
value: &serde_norway::Value,
|
||||
@@ -579,6 +582,7 @@ async fn put_management_legacy_alias_view_changes(
|
||||
};
|
||||
let mut current = parse(current)?;
|
||||
let mut updated = parse(updated)?;
|
||||
let v8 = current.get(yaml_key("config-version")).and_then(serde_norway::Value::as_i64) == Some(8);
|
||||
|
||||
// OAuth aliases are written by put_management_oauth_model_aliases so their
|
||||
// transaction and rollback remain independent from other alias routes.
|
||||
@@ -591,7 +595,7 @@ async fn put_management_legacy_alias_view_changes(
|
||||
let before = current.remove(yaml_key(legacy));
|
||||
let after = updated.remove(yaml_key(legacy));
|
||||
if before != after {
|
||||
provider_changes.push((provider, after));
|
||||
provider_changes.push((provider, before, after));
|
||||
}
|
||||
}
|
||||
if current != updated {
|
||||
@@ -601,6 +605,22 @@ async fn put_management_legacy_alias_view_changes(
|
||||
);
|
||||
}
|
||||
|
||||
let native = if v8 && !provider_changes.is_empty() {
|
||||
Some(parse(&fetch_management_raw_config_yaml(config).await?)?)
|
||||
} else { None };
|
||||
// Resolve every group change before writing payload rules or provider data.
|
||||
let provider_changes = provider_changes.into_iter().map(|(provider, before, after)| {
|
||||
let groups = match after {
|
||||
Some(after) => Some(if let Some(native) = &native {
|
||||
let groups = nested_yaml_value(native, &["api-keys", provider])
|
||||
.cloned().unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new()));
|
||||
update_v8_provider_group_models(provider, &groups, &before.unwrap_or_else(|| serde_norway::Value::Sequence(Vec::new())), &after)?
|
||||
} else { group_legacy_provider_records(provider, &after)? }),
|
||||
None => None,
|
||||
};
|
||||
Ok::<_, String>((provider, groups))
|
||||
}).collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if current_payload != updated_payload {
|
||||
if let Some(value) = updated_payload.as_ref() {
|
||||
put_management_config_value(config, "requests/payload", value).await?;
|
||||
@@ -608,10 +628,9 @@ async fn put_management_legacy_alias_view_changes(
|
||||
delete_management_config_value(config, "requests/payload").await?;
|
||||
}
|
||||
}
|
||||
for (provider, records) in provider_changes {
|
||||
if let Some(records) = records.as_ref() {
|
||||
let groups = group_legacy_provider_records(provider, records)?;
|
||||
put_management_config_value(config, &format!("api-keys/{provider}"), &groups).await?;
|
||||
for (provider, groups) in provider_changes {
|
||||
if let Some(groups) = groups.as_ref() {
|
||||
put_management_config_value(config, &format!("api-keys/{provider}"), groups).await?;
|
||||
} else {
|
||||
delete_management_config_value(config, &format!("api-keys/{provider}")).await?;
|
||||
}
|
||||
@@ -1734,7 +1753,11 @@ pub(crate) fn collect_config_thinking_alias_sources(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !thinking_alias_model_is_available(available_models, &client_model) {
|
||||
// A matching ID may be served by another provider. Check this
|
||||
// source's exclusions too, including '*' used to disable API keys.
|
||||
if !configured_provider_model_is_enabled(provider, &client_model)
|
||||
|| !thinking_alias_model_is_available(available_models, &client_model)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if client_model != upstream_model
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) async fn restore_native_provider_groups(config: &GuiConfigFile, original: &str) -> Result<(), String> {
|
||||
let original: serde_norway::Value = serde_norway::from_str(original).map_err(|error| error.to_string())?;
|
||||
if original.get("config-version").and_then(serde_norway::Value::as_i64) != Some(8) { return Ok(()); }
|
||||
let current: serde_norway::Value = serde_norway::from_str(&fetch_management_raw_config_yaml(config).await?)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let empty = serde_norway::Value::Sequence(Vec::new());
|
||||
let mut changes = Vec::new();
|
||||
for (_, provider) in V8_PROVIDER_FAMILIES {
|
||||
let before = original.get("api-keys").and_then(|value| value.get(provider)).unwrap_or(&empty);
|
||||
let after = current.get("api-keys").and_then(|value| value.get(provider)).unwrap_or(&empty);
|
||||
if before == after { continue; }
|
||||
if flatten_v8_provider_groups(provider, before)? != flatten_v8_provider_groups(provider, after)? {
|
||||
return Err("Provider configuration changed during restoration".into());
|
||||
}
|
||||
changes.push((provider, before));
|
||||
}
|
||||
for (provider, groups) in changes {
|
||||
put_management_config_value(config, &format!("api-keys/{provider}"), groups).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Alias editors use a per-key view. Apply only their model changes back to the
|
||||
// original v8 groups, preserving key overrides, names, and shared configuration.
|
||||
pub(crate) fn update_v8_provider_group_models(
|
||||
provider: &str,
|
||||
groups: &serde_norway::Value,
|
||||
before: &serde_norway::Value,
|
||||
after: &serde_norway::Value,
|
||||
) -> Result<serde_norway::Value, String> {
|
||||
if flatten_v8_provider_groups(provider, groups)? != *before {
|
||||
return Err("Provider groups changed. Refresh and try again".into());
|
||||
}
|
||||
let before = before.as_sequence().ok_or("Provider records must be an array")?;
|
||||
let after = after.as_sequence().ok_or("Provider records must be an array")?;
|
||||
if before.len() != after.len() {
|
||||
return Err("An alias update cannot add or remove provider keys".into());
|
||||
}
|
||||
for (before, after) in before.iter().zip(after) {
|
||||
let mut before = before.as_mapping().cloned().ok_or("Invalid provider record")?;
|
||||
let mut after = after.as_mapping().cloned().ok_or("Invalid provider record")?;
|
||||
before.remove(yaml_key("models"));
|
||||
after.remove(yaml_key("models"));
|
||||
if before != after {
|
||||
return Err("An alias update cannot change provider connection settings".into());
|
||||
}
|
||||
}
|
||||
let models = |record: &serde_norway::Value| record.get("models").cloned();
|
||||
let assign = |mapping: &mut serde_norway::Mapping, value: Option<serde_norway::Value>| {
|
||||
if let Some(value) = value { mapping.insert(yaml_key("models"), value); }
|
||||
else { mapping.remove(yaml_key("models")); }
|
||||
};
|
||||
let mut updated = groups.clone();
|
||||
let mut offset = 0;
|
||||
for group in updated.as_sequence_mut().ok_or("Provider groups must be an array")? {
|
||||
let group = group.as_mapping_mut().ok_or("Invalid provider group")?;
|
||||
if provider == "openai-compatibility" {
|
||||
if models(&before[offset]) != models(&after[offset]) {
|
||||
assign(group, models(&after[offset]));
|
||||
}
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
let keys = group.get(yaml_key("keys")).and_then(serde_norway::Value::as_sequence)
|
||||
.ok_or("Provider keys must be an array")?;
|
||||
let inherited: Vec<usize> = keys.iter().enumerate().filter_map(|(index, key)| {
|
||||
key.get("models").is_none_or(serde_norway::Value::is_null).then_some(index)
|
||||
}).collect();
|
||||
let shared_update = inherited.first().copied().filter(|first| {
|
||||
models(&before[offset + first]) != models(&after[offset + first])
|
||||
&& inherited.iter().all(|index| models(&after[offset + index]) == models(&after[offset + first]))
|
||||
});
|
||||
let count = keys.len();
|
||||
if let Some(first) = shared_update {
|
||||
assign(group, models(&after[offset + first]));
|
||||
}
|
||||
let keys = group.get_mut(yaml_key("keys")).and_then(serde_norway::Value::as_sequence_mut)
|
||||
.ok_or("Provider keys must be an array")?;
|
||||
for (index, key) in keys.iter_mut().enumerate() {
|
||||
if models(&before[offset + index]) == models(&after[offset + index])
|
||||
|| (shared_update.is_some() && inherited.contains(&index)) { continue; }
|
||||
assign(key.as_mapping_mut().ok_or("Invalid provider key")?, models(&after[offset + index]));
|
||||
}
|
||||
offset += count;
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(value: &str) -> serde_norway::Value { serde_norway::from_str(value).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn alias_updates_preserve_groups_and_per_key_inheritance() {
|
||||
let groups = parse(r#"
|
||||
- name: gateway
|
||||
base-url: https://example.test/v1
|
||||
models: [{name: original}]
|
||||
headers: {X-Group: shared}
|
||||
keys:
|
||||
- {api-key: first, weight: 2, priority: null}
|
||||
- {api-key: second, weight: 5, models: null, headers: {}, excluded-models: [], disable-cooling: false}
|
||||
- api-key: third
|
||||
models: [{name: private}]
|
||||
"#);
|
||||
let before = flatten_v8_provider_groups("codex", &groups).unwrap();
|
||||
let mut after = before.clone();
|
||||
after[0]["models"] = parse("[{name: original}, {name: original, alias: fast}]");
|
||||
let updated = update_v8_provider_group_models("codex", &groups, &before, &after).unwrap();
|
||||
assert_eq!(updated.as_sequence().unwrap().len(), 1);
|
||||
assert_eq!(updated[0]["models"], groups[0]["models"]);
|
||||
assert_eq!(updated[0]["keys"][0]["models"], after[0]["models"]);
|
||||
assert_eq!(updated[0]["keys"][0]["priority"], groups[0]["keys"][0]["priority"]);
|
||||
assert_eq!(updated[0]["keys"][1], groups[0]["keys"][1]);
|
||||
assert_eq!(updated[0]["keys"][2], groups[0]["keys"][2]);
|
||||
assert_eq!(flatten_v8_provider_groups("codex", &updated).unwrap(), after);
|
||||
|
||||
after[1]["models"] = after[0]["models"].clone();
|
||||
let updated = update_v8_provider_group_models("codex", &groups, &before, &after).unwrap();
|
||||
assert_eq!(updated[0]["models"], after[0]["models"]);
|
||||
assert_eq!(updated[0]["keys"], groups[0]["keys"]);
|
||||
assert_eq!(flatten_v8_provider_groups("codex", &updated).unwrap(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_updates_leave_empty_and_compatible_groups_intact() {
|
||||
for provider in ["codex", "openai-compatibility"] {
|
||||
let groups = parse("- name: empty\n keys: []\n- name: active\n models: [{name: model}]\n keys: [{api-key: key, proxy-url: direct}]\n");
|
||||
let before = flatten_v8_provider_groups(provider, &groups).unwrap();
|
||||
let mut after = before.clone();
|
||||
let index = after.as_sequence().unwrap().len() - 1;
|
||||
after[index]["models"] = parse("[{name: model, alias: alias}]");
|
||||
let updated = update_v8_provider_group_models(provider, &groups, &before, &after).unwrap();
|
||||
assert_eq!(updated[0], groups[0]);
|
||||
assert_eq!(updated[1]["keys"], groups[1]["keys"]);
|
||||
assert_eq!(updated[1]["models"], after[index]["models"]);
|
||||
after[index]["base-url"] = yaml_key("https://changed.test");
|
||||
assert!(update_v8_provider_group_models(provider, &groups, &before, &after).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_updates_reject_changed_groups_before_writing() {
|
||||
let groups = parse("[{name: source, models: [{name: model}], keys: [{api-key: key}]}]");
|
||||
let before = flatten_v8_provider_groups("codex", &groups).unwrap();
|
||||
let mut changed = groups.clone();
|
||||
changed[0]["keys"][0]["priority"] = serde_norway::Value::Number(7.into());
|
||||
assert!(update_v8_provider_group_models("codex", &changed, &before, &before).is_err());
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,65 @@ impl TestCore {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v8_alias_save_preserves_multi_key_groups_and_restores_inheritance_after_failure() {
|
||||
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-groups");
|
||||
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(3)).build().unwrap(),
|
||||
};
|
||||
let 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: "isolated-client-key".into(), remark: String::new() }],
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let initial = r#"
|
||||
config-version: 8
|
||||
management: {disable-control-panel: true, disable-auto-update-panel: true}
|
||||
api-keys:
|
||||
codex:
|
||||
- name: shared-upstream
|
||||
base-url: http://127.0.0.1:1/v1
|
||||
models: [{name: gpt-test}]
|
||||
keys:
|
||||
- {api-key: test-first, weight: 2, priority: null}
|
||||
- {api-key: test-second, weight: 5, models: null, headers: {}, excluded-models: [], disable-cooling: false}
|
||||
- api-key: test-third
|
||||
models: [{name: private-model}]
|
||||
"#;
|
||||
fs::write(&core.config, apply_gui_managed_settings(initial, &gui).unwrap()).unwrap();
|
||||
core.start().await;
|
||||
core.validate_save(&fs::read_to_string(&core.config).unwrap()).await;
|
||||
let original = core.config_view().await;
|
||||
let current = fetch_management_config_yaml(&gui).await.unwrap();
|
||||
let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
|
||||
let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap();
|
||||
let error = commit_management_alias_config_changes(&gui, ¤t, &updated, || Err::<(), _>("local save failed".into())).await.unwrap_err();
|
||||
assert!(error.contains("Original configuration was restored"), "{error}");
|
||||
assert_eq!(core.config_view().await["api-keys"], original["api-keys"]);
|
||||
|
||||
let current = fetch_management_config_yaml(&gui).await.unwrap();
|
||||
let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
|
||||
let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap();
|
||||
put_management_alias_config_changes(&gui, ¤t, &updated).await.unwrap();
|
||||
let saved = core.config_view().await;
|
||||
let group = &saved["api-keys"]["codex"][0];
|
||||
assert_eq!(saved["api-keys"]["codex"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(group["name"], "shared-upstream");
|
||||
assert_eq!(group["keys"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(group["models"], original["api-keys"]["codex"][0]["models"]);
|
||||
assert_eq!(group["keys"][0]["models"][1]["alias"], "gpt-test-fast");
|
||||
assert_eq!(group["keys"][1], original["api-keys"]["codex"][0]["keys"][1]);
|
||||
assert_eq!(group["keys"][2], original["api-keys"]["codex"][0]["keys"][2]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"]
|
||||
async fn v8_accepts_gui_settings_and_reloads_client_keys() {
|
||||
|
||||
@@ -171,6 +171,82 @@ fn thinking_alias_uses_source_native_override_parameters() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_sources_exclude_blocked_providers_even_when_another_provider_serves_the_model() {
|
||||
for section in ["codex-api-key", "openai-compatibility", "claude-api-key", "gemini-api-key"] {
|
||||
let input = format!(r#"
|
||||
{section}:
|
||||
- name: disabled
|
||||
disabled: true
|
||||
models: [{{name: upstream, alias: gpt-test, thinking: {{levels: [low, high]}}}}]
|
||||
- name: disabled-with-wildcard
|
||||
excluded-models: ['*']
|
||||
models: [{{name: upstream, alias: gpt-test, thinking: {{levels: [low, high]}}}}]
|
||||
- name: excluded-exactly
|
||||
excluded-models: [' GPT-TEST ']
|
||||
models: [{{name: upstream, alias: gpt-test, thinking: {{levels: [low, high]}}}}]
|
||||
- name: excluded-by-pattern
|
||||
excluded-models: ['gpt-*']
|
||||
models: [{{name: upstream, alias: gpt-test, thinking: {{levels: [low, high]}}}}]
|
||||
- name: active
|
||||
excluded-models: [unrelated]
|
||||
models: [{{name: upstream, alias: gpt-test, thinking: {{levels: [low, high]}}}}]
|
||||
"#);
|
||||
for capability in [AliasSourceCapability::Base, AliasSourceCapability::Reasoning, AliasSourceCapability::Fast] {
|
||||
if capability == AliasSourceCapability::Fast
|
||||
&& !matches!(section, "codex-api-key" | "openai-compatibility")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let sources = resolved_oauth_alias_sources(
|
||||
&input, &[], &test_agent_models(&["gpt-test"]), capability,
|
||||
).unwrap();
|
||||
assert_eq!(sources.len(), 1, "{section}: blocked providers must not borrow availability");
|
||||
assert_eq!(sources[0].source.provider, "active");
|
||||
assert!(matches!(sources[0].location,
|
||||
ThinkingAliasSourceLocation::ConfigModel { provider_index: 4, model_index: 0, .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v8_alias_creation_uses_the_enabled_source_and_keeps_disabled_aliases_editable() {
|
||||
let input = r#"
|
||||
config-version: 8
|
||||
api-keys:
|
||||
codex:
|
||||
- name: disabled-luna
|
||||
excluded-models: ['*']
|
||||
models:
|
||||
- name: gpt-5.6-luna
|
||||
- name: gpt-5.6-luna
|
||||
alias: gpt-5.6-luna-max-fast-fp
|
||||
keys: [{api-key: disabled-test-key}]
|
||||
- name: active-luna
|
||||
models: [{name: gpt-5.6-luna}]
|
||||
keys: [{api-key: active-test-key}]
|
||||
"#;
|
||||
let content = management_v8_yaml_to_legacy_view(input).unwrap();
|
||||
let sources = resolved_oauth_alias_sources(
|
||||
&content, &[], &test_agent_models(&["gpt-5.6-luna"]), AliasSourceCapability::Base,
|
||||
).unwrap();
|
||||
assert_eq!(sources.len(), 1);
|
||||
assert_eq!(sources[0].source.provider, "active-luna");
|
||||
let updated = add_model_alias_to_yaml(
|
||||
&content, &sources[0], "luna-active-max-fast", "max", true,
|
||||
).unwrap();
|
||||
let before: serde_norway::Value = serde_norway::from_str(&content).unwrap();
|
||||
let after: serde_norway::Value = serde_norway::from_str(&updated).unwrap();
|
||||
assert_eq!(before["codex-api-key"][0], after["codex-api-key"][0]);
|
||||
assert_eq!(after["codex-api-key"][1]["models"][1]["alias"].as_str(), Some("luna-active-max-fast"));
|
||||
let context = model_alias_edit_context(&updated, "luna-active-max-fast", &[]).unwrap();
|
||||
assert_eq!(context.effort.as_deref(), Some("max"));
|
||||
assert!(context.fast);
|
||||
let disabled = model_alias_edit_context(&updated, "gpt-5.6-luna-max-fast-fp", &[]).unwrap();
|
||||
assert_eq!(disabled.source.provider, "disabled-luna");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_claude_and_gemini_models_use_native_overrides() {
|
||||
let input = "claude-api-key:\n - api-key: claude-key\n models:\n - name: claude-opus-4-6\n thinking:\n levels: [low, high]\ngemini-api-key:\n - api-key: gemini-key\n models:\n - name: gemini-3.1-pro\n thinking:\n levels: [low, high]\n";
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { isRecord, maskSecret, readString } from '../services/managementApi';
|
||||
import { effectiveProviderKey, providerKeyDraft, providerKeyOverrides, type ProviderKeyDraft } from '../services/providerGroups';
|
||||
import '../styles/provider-groups.css';
|
||||
|
||||
type Props = {
|
||||
keys: ProviderKeyDraft[];
|
||||
shared: Record<string, unknown>;
|
||||
section: string;
|
||||
disabled: boolean;
|
||||
onChange: (keys: ProviderKeyDraft[]) => void;
|
||||
};
|
||||
|
||||
export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onChange }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [visible, setVisible] = useState<Record<string, boolean>>({});
|
||||
const update = (id: string, patch: (draft: ProviderKeyDraft) => ProviderKeyDraft) =>
|
||||
onChange(keys.map((draft) => draft.id === id ? patch(draft) : draft));
|
||||
const set = (id: string, field: string, value: unknown) => update(id, (draft) => {
|
||||
const next = { ...draft.value };
|
||||
if (value === undefined) delete next[field];
|
||||
else next[field] = value;
|
||||
const text = { ...draft.text };
|
||||
delete text[field];
|
||||
return { ...draft, value: next, text };
|
||||
});
|
||||
const text = (id: string, field: string, value: string) => update(id, (draft) => ({ ...draft, text: { ...draft.text, [field]: value } }));
|
||||
const fields: { field: string; label: MessageKey; kind: 'number' | 'text' | 'boolean' | 'lines'; initial: unknown }[] = [
|
||||
{ field: 'priority', label: 'apiAccess.field.priority', kind: 'number', initial: 0 },
|
||||
{ field: 'proxy-url', label: 'apiAccess.field.proxyUrl', kind: 'text', initial: '' },
|
||||
{ field: 'prefix', label: 'apiAccess.field.prefix', kind: 'text', initial: '' },
|
||||
{ field: 'request-retry', label: 'apiAccess.groups.retry', kind: 'number', initial: 0 },
|
||||
{ field: 'disable-cooling', label: 'apiAccess.cooling.title', kind: 'boolean', initial: false },
|
||||
{ field: 'headers', label: 'apiAccess.field.headers', kind: 'lines', initial: {} },
|
||||
{ field: 'excluded-models', label: 'apiAccess.field.excludedModels', kind: 'lines', initial: [] },
|
||||
];
|
||||
return <section className="provider-group-keys" aria-label={t('apiAccess.groups.keys')}>
|
||||
<div className="provider-group-heading"><div><strong>{t('apiAccess.groups.keys')}</strong><p>{t('apiAccess.groups.keysHint')}</p></div>
|
||||
<button type="button" className="secondary-button compact-button" disabled={disabled}
|
||||
onClick={() => onChange([...keys, providerKeyDraft({ 'api-key': '' })])}><Plus size={14} />{t('apiAccess.groups.addKey')}</button>
|
||||
</div>
|
||||
{!keys.length ? <p className="provider-group-hint">{t('apiAccess.groups.emptyKeys')}</p> : null}
|
||||
{keys.map((draft, index) => {
|
||||
const key = draft.value;
|
||||
const overrideCount = providerKeyOverrides(key).length;
|
||||
const effective = effectiveProviderKey(shared, key);
|
||||
return <fieldset className="provider-group-key" key={draft.id} disabled={disabled}>
|
||||
<legend>{t('apiAccess.groups.keyNumber', { number: index + 1 })}</legend>
|
||||
<div className="provider-group-key-main">
|
||||
<label><span>{t('apiAccess.field.key')}</span><input type={visible[draft.id] ? 'text' : 'password'} autoComplete="off" spellCheck={false}
|
||||
aria-label={t('apiAccess.groups.keyNumber', { number: index + 1 })} value={readString(key, 'api-key')}
|
||||
onChange={(event) => set(draft.id, 'api-key', event.currentTarget.value)} placeholder="sk-..." /></label>
|
||||
<button type="button" className="secondary-button compact-button" aria-pressed={Boolean(visible[draft.id])}
|
||||
onClick={() => setVisible((state) => ({ ...state, [draft.id]: !state[draft.id] }))}>{t('apiAccess.groups.showKey')}</button>
|
||||
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.groups.removeKey', { number: index + 1 })}
|
||||
onClick={() => onChange(keys.filter((item) => item.id !== draft.id))}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
<details className="provider-key-settings">
|
||||
<summary>{t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')}</summary>
|
||||
<label><span>{t('apiAccess.groups.weight')}</span><input type="number" min="0" max="1000000" step="1" value={key.weight == null ? '' : String(key.weight)}
|
||||
placeholder="1" onChange={(event) => set(draft.id, 'weight', event.currentTarget.value === '' ? undefined : Number(event.currentTarget.value))} /></label>
|
||||
<p className="provider-group-hint">{t('apiAccess.groups.overrideHint')}</p>
|
||||
{fields.map(({ field, label, kind, initial }) => {
|
||||
const overridden = key[field] != null;
|
||||
const content = draft.text?.[field] ?? (field === 'headers' && isRecord(key[field])
|
||||
? Object.entries(key[field]).map(([name, value]) => `${name}: ${value}`).join('\n')
|
||||
: Array.isArray(key[field]) ? key[field].join('\n') : String(key[field] ?? ''));
|
||||
return <div className="provider-key-override" key={field}>
|
||||
<label className="provider-key-override-toggle"><input type="checkbox" checked={overridden}
|
||||
onChange={(event) => set(draft.id, field, event.currentTarget.checked ? structuredClone(effective[field] ?? initial) : undefined)} />
|
||||
<span>{t('apiAccess.groups.overrideField', { field: t(label) })}</span></label>
|
||||
{overridden ? kind === 'lines' ? <textarea aria-label={t(label)} rows={3} value={content} onChange={(event) => text(draft.id, field, event.currentTarget.value)} />
|
||||
: kind === 'boolean' ? <select aria-label={t(label)} value={String(key[field])} onChange={(event) => set(draft.id, field, event.currentTarget.value === 'true')}>
|
||||
<option value="false">{t('apiAccess.cooling.enable')}</option><option value="true">{t('apiAccess.cooling.disable')}</option></select>
|
||||
: <input aria-label={t(label)} type={kind} step={kind === 'number' ? '1' : undefined} value={String(key[field] ?? '')}
|
||||
onChange={(event) => set(draft.id, field, kind === 'number' ? Number(event.currentTarget.value) : event.currentTarget.value)} /> : null}
|
||||
</div>;
|
||||
})}
|
||||
<div className="provider-key-override">
|
||||
<label className="provider-key-override-toggle"><input type="checkbox" checked={key.models != null}
|
||||
onChange={(event) => set(draft.id, 'models', event.currentTarget.checked ? structuredClone(effective.models ?? []) : undefined)} />
|
||||
<span>{t('apiAccess.groups.overrideModels')}</span></label>
|
||||
{Array.isArray(key.models) ? <div className="model-config-list">
|
||||
{key.models.map((raw, modelIndex) => {
|
||||
const model = isRecord(raw) ? raw : { name: String(raw) };
|
||||
const change = (patch: Record<string, string>) => set(draft.id, 'models', (key.models as unknown[]).map((item, i) => i === modelIndex ? { ...model, ...patch } : item));
|
||||
return <div className="model-config-entry" key={modelIndex}>
|
||||
<input aria-label={t('apiAccess.models.namePlaceholder')} value={readString(model, 'name')} onChange={(event) => change({ name: event.currentTarget.value })} />
|
||||
<input aria-label={t('apiAccess.models.aliasPlaceholder')} value={readString(model, 'alias')} onChange={(event) => change({ alias: event.currentTarget.value })} />
|
||||
<button type="button" className="icon-button quiet danger" aria-label={t('apiAccess.models.remove')} onClick={() => set(draft.id, 'models', (key.models as unknown[]).filter((_, i) => i !== modelIndex))}><Trash2 size={14} /></button>
|
||||
</div>;
|
||||
})}
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => set(draft.id, 'models', [...key.models as unknown[], { name: '', alias: '' }])}><Plus size={14} />{t('apiAccess.models.add')}</button>
|
||||
</div> : null}
|
||||
</div>
|
||||
{section === 'codex-api-key' ? <label><span>WebSocket</span><select value={key.websockets == null ? '' : String(key.websockets)} onChange={(event) => set(draft.id, 'websockets', event.currentTarget.value === '' ? undefined : event.currentTarget.value === 'true')}>
|
||||
<option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option>
|
||||
</select></label> : null}
|
||||
{section === 'claude-api-key' ? <div className="provider-cloak-settings">
|
||||
<label><span>{t('apiAccess.cloak.mode')}</span><select value={isRecord(key.cloak) ? readString(key.cloak, 'mode') : ''} onChange={(event) => {
|
||||
const cloak = isRecord(key.cloak) ? { ...key.cloak } : {};
|
||||
if (event.currentTarget.value) cloak.mode = event.currentTarget.value; else delete cloak.mode;
|
||||
set(draft.id, 'cloak', Object.keys(cloak).length ? cloak : undefined);
|
||||
}}><option value="">{t('apiAccess.cloak.default')}</option><option value="auto">{t('apiAccess.cloak.auto')}</option><option value="always">{t('apiAccess.cloak.always')}</option><option value="never">{t('apiAccess.cloak.never')}</option></select></label>
|
||||
<label className="multiline-field"><span>{t('apiAccess.cloak.words')}</span>
|
||||
<textarea rows={3} value={draft.text?.['cloak-words'] ?? (isRecord(key.cloak) && Array.isArray(key.cloak['sensitive-words']) ? key.cloak['sensitive-words'].join('\n') : '')}
|
||||
onChange={(event) => text(draft.id, 'cloak-words', event.currentTarget.value)} /></label>
|
||||
{(['strict-mode', 'cache-user-id'] as const).map((field) => <label key={field}>
|
||||
<span>{t(field === 'strict-mode' ? 'apiAccess.cloak.strict' : 'apiAccess.cloak.cacheUser')}</span>
|
||||
<select value={isRecord(key.cloak) && key.cloak[field] != null ? String(key.cloak[field]) : ''} onChange={(event) => {
|
||||
const cloak = isRecord(key.cloak) ? { ...key.cloak } : {};
|
||||
if (event.currentTarget.value === '') delete cloak[field]; else cloak[field] = event.currentTarget.value === 'true';
|
||||
set(draft.id, 'cloak', Object.keys(cloak).length ? cloak : undefined);
|
||||
}}><option value="">{t('apiAccess.option.inherit')}</option><option value="true">{t('common.enabled')}</option><option value="false">{t('common.disabled')}</option></select>
|
||||
</label>)}
|
||||
</div> : null}
|
||||
</details>
|
||||
<span className="provider-group-key-state">{maskSecret(readString(key, 'api-key'))}</span>
|
||||
</fieldset>;
|
||||
})}
|
||||
</section>;
|
||||
}
|
||||
@@ -2,6 +2,35 @@ import type { MessageKey } from './locales/zh-CN';
|
||||
import { easyModeJa } from './easyMode';
|
||||
|
||||
export const jaOverrides = {
|
||||
"apiAccess.groups.summary": "上流グループ {count} 件",
|
||||
"apiAccess.groups.add": "グループを追加",
|
||||
"apiAccess.groups.edit": "グループを編集",
|
||||
"apiAccess.groups.name": "グループ名",
|
||||
"apiAccess.groups.namePlaceholder": "例:メイン、バックアップ",
|
||||
"apiAccess.groups.nameRequired": "制御文字を含まない1〜80文字のグループ名を入力してください。",
|
||||
"apiAccess.groups.description": "グループ内のキーは同じ上流URL、モデル、接続設定を共有します。",
|
||||
"apiAccess.groups.keys": "グループのキー",
|
||||
"apiAccess.groups.keysHint": "各キーは共有設定を継承するか、個別に上書きできます。",
|
||||
"apiAccess.groups.addKey": "キーを追加",
|
||||
"apiAccess.groups.emptyKeys": "このグループにキーはありません。",
|
||||
"apiAccess.groups.keyNumber": "キー {number}",
|
||||
"apiAccess.groups.removeKey": "キー {number} を削除",
|
||||
"apiAccess.groups.showKey": "キーを表示",
|
||||
"apiAccess.groups.keySettings": "キー設定",
|
||||
"apiAccess.groups.overrides": "個別設定 {count} 件",
|
||||
"apiAccess.groups.inherits": "共有設定を継承",
|
||||
"apiAccess.groups.overrideHint": "チェックで個別設定、解除で継承に戻ります。リストとヘッダーは全体を置換し、空欄は消去します。",
|
||||
"apiAccess.groups.overrideField": "個別設定:{field}",
|
||||
"apiAccess.groups.overrideModels": "個別設定:モデルとエイリアス",
|
||||
"apiAccess.groups.weight": "ラウンドロビン重み(既定1)",
|
||||
"apiAccess.groups.retry": "再試行回数",
|
||||
"apiAccess.groups.invalidKey": "キー設定が無効です:{field}。キーと整数の範囲を確認してください。",
|
||||
"apiAccess.groups.discoveryKey": "モデル取得に使用するキー",
|
||||
"apiAccess.groups.sharedHint": "以下のモデルと詳細設定は共有されます。キーの個別設定が優先されます。",
|
||||
"apiAccess.groups.sharedModels": "共有モデル",
|
||||
"apiAccess.groups.sharedToggleHint": "共有モデルの有効状態を切り替えます。キー固有の除外設定が優先されます。",
|
||||
"apiAccess.groups.partial": "一部のキーが無効",
|
||||
"apiAccess.groups.healthKey": "検査するキー",
|
||||
...easyModeJa,
|
||||
'authFiles.settings.noModels': '該当するモデルはありません。上の欄で除外ルールを入力できます。',
|
||||
'authFiles.settings.button': "設定",
|
||||
|
||||
@@ -2,6 +2,35 @@ import type { MessageKey } from './zh-CN';
|
||||
import { easyModeEn } from '../easyMode';
|
||||
|
||||
export const en: Record<MessageKey, string> = {
|
||||
"apiAccess.groups.summary": "{count} upstream groups",
|
||||
"apiAccess.groups.add": "Add Group",
|
||||
"apiAccess.groups.edit": "Edit Group",
|
||||
"apiAccess.groups.name": "Group Name",
|
||||
"apiAccess.groups.namePlaceholder": "For example: Primary gateway",
|
||||
"apiAccess.groups.nameRequired": "Enter a group name of 1–80 characters without control characters.",
|
||||
"apiAccess.groups.description": "One group connects to one upstream endpoint. Its keys share models and connection settings.",
|
||||
"apiAccess.groups.keys": "Group Keys",
|
||||
"apiAccess.groups.keysHint": "Each key can inherit shared settings or override them individually.",
|
||||
"apiAccess.groups.addKey": "Add Key",
|
||||
"apiAccess.groups.emptyKeys": "This group has no keys.",
|
||||
"apiAccess.groups.keyNumber": "Key {number}",
|
||||
"apiAccess.groups.removeKey": "Remove Key {number}",
|
||||
"apiAccess.groups.showKey": "Show Key",
|
||||
"apiAccess.groups.keySettings": "Key Settings",
|
||||
"apiAccess.groups.overrides": "{count} individual settings",
|
||||
"apiAccess.groups.inherits": "Inherits shared settings",
|
||||
"apiAccess.groups.overrideHint": "Check a setting to override it; uncheck to inherit. Lists and headers replace the shared value entirely; an empty override clears it.",
|
||||
"apiAccess.groups.overrideField": "Override: {field}",
|
||||
"apiAccess.groups.overrideModels": "Override: Models and Aliases",
|
||||
"apiAccess.groups.weight": "Round-robin Weight (default 1)",
|
||||
"apiAccess.groups.retry": "Retry Count",
|
||||
"apiAccess.groups.invalidKey": "Invalid key setting: {field}. Enter a key and check integer limits.",
|
||||
"apiAccess.groups.discoveryKey": "Key for Model Discovery",
|
||||
"apiAccess.groups.sharedHint": "Models and advanced settings below are shared. Individual key overrides take precedence.",
|
||||
"apiAccess.groups.sharedModels": "Shared Models",
|
||||
"apiAccess.groups.sharedToggleHint": "Controls shared model availability. Individual key exclusion overrides still take precedence.",
|
||||
"apiAccess.groups.partial": "Some keys disabled",
|
||||
"apiAccess.groups.healthKey": "Key to Check",
|
||||
...easyModeEn,
|
||||
'authFiles.settings.noModels': 'No matching models. Enter exclusion rules above.',
|
||||
'authFiles.settings.button': "Settings",
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { easyModeZhCN } from '../easyMode';
|
||||
|
||||
export const zhCN = {
|
||||
"apiAccess.groups.summary": "{count} 个上游分组",
|
||||
"apiAccess.groups.add": "新增分组",
|
||||
"apiAccess.groups.edit": "编辑分组",
|
||||
"apiAccess.groups.name": "分组名称",
|
||||
"apiAccess.groups.namePlaceholder": "例如:主站、备用站",
|
||||
"apiAccess.groups.nameRequired": "请填写 1–80 个字符的分组名称,不含控制字符。",
|
||||
"apiAccess.groups.description": "一个分组连接一个上游地址,组内密钥共享模型和连接设置。",
|
||||
"apiAccess.groups.keys": "组内密钥",
|
||||
"apiAccess.groups.keysHint": "每个密钥可继承共享设置,也可单独覆盖。",
|
||||
"apiAccess.groups.addKey": "添加密钥",
|
||||
"apiAccess.groups.emptyKeys": "此分组没有密钥。",
|
||||
"apiAccess.groups.keyNumber": "密钥 {number}",
|
||||
"apiAccess.groups.removeKey": "移除密钥 {number}",
|
||||
"apiAccess.groups.showKey": "显示密钥",
|
||||
"apiAccess.groups.keySettings": "密钥设置",
|
||||
"apiAccess.groups.overrides": "{count} 项独立设置",
|
||||
"apiAccess.groups.inherits": "继承共享设置",
|
||||
"apiAccess.groups.overrideHint": "勾选后单独设置;取消勾选恢复继承。列表和请求头会整体替换共享值,留空表示清空。",
|
||||
"apiAccess.groups.overrideField": "单独设置:{field}",
|
||||
"apiAccess.groups.overrideModels": "单独设置:模型与别名",
|
||||
"apiAccess.groups.weight": "轮询权重(默认 1)",
|
||||
"apiAccess.groups.retry": "重试次数",
|
||||
"apiAccess.groups.invalidKey": "密钥配置无效:{field}。请填写密钥,并检查整数范围。",
|
||||
"apiAccess.groups.discoveryKey": "用于获取模型的密钥",
|
||||
"apiAccess.groups.sharedHint": "以下模型和高级设置由组内密钥共享;密钥已有的独立设置优先。",
|
||||
"apiAccess.groups.sharedModels": "共享模型",
|
||||
"apiAccess.groups.sharedToggleHint": "控制组内共享模型的启用状态。密钥的独立模型排除设置仍然优先。",
|
||||
"apiAccess.groups.partial": "部分密钥已停用",
|
||||
"apiAccess.groups.healthKey": "检测密钥",
|
||||
...easyModeZhCN,
|
||||
'authFiles.settings.noModels': '没有匹配的候选模型,可在上方手动填写排除规则。',
|
||||
'authFiles.settings.button': "设置",
|
||||
|
||||
+175
-70
@@ -53,7 +53,6 @@ import {
|
||||
readBoolean,
|
||||
readNumber,
|
||||
readString,
|
||||
responseList,
|
||||
} from '../services/managementApi';
|
||||
import {
|
||||
DEEPSEEK_BASE_URL,
|
||||
@@ -77,6 +76,12 @@ import {
|
||||
} from '../services/providerHealthCheck';
|
||||
import { modelMatchesRule } from '../services/oauthModels';
|
||||
import { normalizeProviderProxyUrl } from '../services/providerProxy';
|
||||
import { ProviderGroupKeysEditor } from '../components/ProviderGroupKeysEditor';
|
||||
import {
|
||||
cleanProviderGroup, effectiveProviderKey, providerGroupIdentity, providerGroupKeys, providerGroupStatus,
|
||||
providerKeyDraft, serializeProviderKey, validateProviderGroupKeys, type ProviderKeyDraft,
|
||||
} from '../services/providerGroups';
|
||||
import { providerGroupsApi } from '../services/managementApi';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
@@ -207,6 +212,7 @@ const requestErrorMessage = (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
export type ProviderDraft = {
|
||||
groupKeys?: ProviderKeyDraft[];
|
||||
name: string;
|
||||
apiKey: string;
|
||||
remark: string;
|
||||
@@ -303,7 +309,8 @@ const rowFromRecord = (
|
||||
record: Record<string, unknown>,
|
||||
index: number,
|
||||
): ProviderRow => {
|
||||
const entries = definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
|
||||
const grouped = Array.isArray(record.keys);
|
||||
const entries = grouped ? providerGroupKeys(record) : definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
|
||||
? record['api-key-entries'].filter(isRecord)
|
||||
: [];
|
||||
const entry = entries[0] ?? null;
|
||||
@@ -318,14 +325,15 @@ const rowFromRecord = (
|
||||
section,
|
||||
index,
|
||||
record,
|
||||
name: definitionFor(section).openAi
|
||||
name: grouped || definitionFor(section).openAi
|
||||
? readString(record, 'name') || translate(getCurrentLocale(), 'apiAccess.compatibleName', { number: index + 1 })
|
||||
: translate(getCurrentLocale(), definitionFor(section).labelKey),
|
||||
apiKey: entry ? readString(entry, 'api-key', 'apiKey') : singleApiKey,
|
||||
apiKeys: entry ? apiKeys : singleApiKey ? [singleApiKey] : [],
|
||||
baseUrl: readString(record, 'base-url', 'baseUrl'),
|
||||
models: modelsFromRecord(record.models),
|
||||
disabled: definitionFor(section).openAi
|
||||
models: grouped && Array.isArray(record.models)
|
||||
? record.models.flatMap((model) => modelsFromRecord([model])) : modelsFromRecord(record.models),
|
||||
disabled: grouped ? providerGroupStatus(record) === 'disabled' : definitionFor(section).openAi
|
||||
? readBoolean(record, 'disabled')
|
||||
: excludedModels.some((model) => model.trim() === '*'),
|
||||
priority: readNumber(record, 'priority'),
|
||||
@@ -384,6 +392,7 @@ const providerHeadersFromRecord = (record: Record<string, unknown>) =>
|
||||
: {};
|
||||
|
||||
export const stripResponseFields = (record: Record<string, unknown>) => {
|
||||
if (Array.isArray(record.keys)) return cleanProviderGroup(record);
|
||||
const next = normalizeProviderModels(record);
|
||||
delete next['test-model'];
|
||||
delete next.testModel;
|
||||
@@ -418,6 +427,7 @@ const normalizeProviderIdentity = (value: unknown): unknown => {
|
||||
};
|
||||
|
||||
const providerConfigIdentity = (record: Record<string, unknown>) => {
|
||||
if (Array.isArray(record.keys)) return providerGroupIdentity(record);
|
||||
const config = stripResponseFields(record);
|
||||
if (config.priority === 0) delete config.priority;
|
||||
if (config.websockets === false) delete config.websockets;
|
||||
@@ -427,7 +437,7 @@ const providerConfigIdentity = (record: Record<string, unknown>) => {
|
||||
|
||||
const providerRemarkConfigIdentity = (record: Record<string, unknown>) => {
|
||||
const config = stripResponseFields(record);
|
||||
for (const key of ['name', 'api-key', 'apiKey', 'api-key-entries', 'base-url', 'baseUrl', 'disabled']) {
|
||||
for (const key of ['name', 'api-key', 'apiKey', 'api-key-entries', 'keys', 'base-url', 'baseUrl', 'disabled']) {
|
||||
delete config[key];
|
||||
}
|
||||
if (Array.isArray(config['excluded-models'])) {
|
||||
@@ -442,6 +452,7 @@ export const hasDuplicateProviderRecord = (
|
||||
candidates: Record<string, unknown>[],
|
||||
targetIndex = -1,
|
||||
) => records.some((record, index) => index !== targetIndex && candidates.some((candidate) => {
|
||||
if (Array.isArray(candidate.keys)) return readString(record, 'name') === readString(candidate, 'name');
|
||||
if (definitionFor(section).openAi) return readString(record, 'name') === readString(candidate, 'name');
|
||||
if (section === 'gemini-api-key') {
|
||||
return readString(record, 'api-key', 'apiKey') === readString(candidate, 'api-key', 'apiKey')
|
||||
@@ -614,6 +625,7 @@ export const providerProxyDraftFromRecord = (
|
||||
section: ProviderSection,
|
||||
record: Record<string, unknown>,
|
||||
): Pick<ProviderDraft, 'proxyUrl' | 'proxyUrlEdited' | 'proxyUrlMixed'> => {
|
||||
if (Array.isArray(record.keys)) return { proxyUrl: readString(record, 'proxy-url'), proxyUrlEdited: false, proxyUrlMixed: false };
|
||||
const entries = definitionFor(section).openAi && Array.isArray(record['api-key-entries'])
|
||||
? record['api-key-entries'].filter(isRecord)
|
||||
: [];
|
||||
@@ -632,9 +644,10 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
|
||||
const definition = definitionFor(row.section);
|
||||
const isDeepSeek = row.section === 'codex-api-key' && isDeepSeekRecord(row.record);
|
||||
return {
|
||||
name: isDeepSeek ? 'DeepSeek' : row.name,
|
||||
...(Array.isArray(row.record.keys) ? { groupKeys: providerGroupKeys(row.record).map(providerKeyDraft) } : {}),
|
||||
name: Array.isArray(row.record.keys) ? row.name : isDeepSeek ? 'DeepSeek' : row.name,
|
||||
apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey,
|
||||
remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''),
|
||||
remark: row.remark || (!Array.isArray(row.record.keys) && definition.openAi && !isDeepSeek ? row.name : ''),
|
||||
baseUrl: row.baseUrl,
|
||||
...providerProxyDraftFromRecord(row.section, row.record),
|
||||
priority: row.priority === null ? '' : String(row.priority),
|
||||
@@ -668,6 +681,9 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => {
|
||||
};
|
||||
};
|
||||
|
||||
export const providerDraftFromRecord = (section: ProviderSection, record: Record<string, unknown>) =>
|
||||
draftFromRow(rowFromRecord(section, record, 0));
|
||||
|
||||
const emptyProviderDraft = (): ProviderDraft => ({
|
||||
name: '',
|
||||
apiKey: '',
|
||||
@@ -703,7 +719,7 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
|
||||
export const applyProviderRemarkIdentity = (
|
||||
category: ProviderCategory,
|
||||
draft: ProviderDraft,
|
||||
): ProviderDraft => category === 'deepseek'
|
||||
): ProviderDraft => draft.groupKeys ? draft : category === 'deepseek'
|
||||
? { ...draft, name: draft.name.trim() || 'DeepSeek' }
|
||||
: definitionFor(category).openAi
|
||||
? { ...draft, name: draft.remark.trim() }
|
||||
@@ -894,10 +910,66 @@ export const buildProviderRecord = (
|
||||
return applyAdvancedFields(next, section, draft);
|
||||
};
|
||||
|
||||
export const buildProviderGroupRecord = (
|
||||
section: ProviderSection, draft: ProviderDraft, current?: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const sharedDraft = { ...draft, apiKey: '', proxyUrlEdited: true,
|
||||
websockets: undefined, cloakMode: undefined, cloakStrictMode: undefined,
|
||||
cloakSensitiveWordsText: undefined, cloakCacheUserId: undefined,
|
||||
disabled: Array.isArray(current?.['excluded-models']) && current['excluded-models'].includes('*'),
|
||||
};
|
||||
const next = buildProviderRecord(section, sharedDraft, current);
|
||||
// Native groups expose every mapping, including multiple aliases of the same
|
||||
// upstream ID. Match each row independently so edits/removals are explicit.
|
||||
const existing = Array.isArray(current?.models) ? current.models : [];
|
||||
const used = new Set<number>();
|
||||
next.models = draft.models.filter((model) => model.name.trim()).flatMap((model) => {
|
||||
let index = existing.findIndex((item, index) => !used.has(index) && isRecord(item)
|
||||
&& readString(item, 'name') === model.name && readString(item, 'alias') === (model.alias ?? ''));
|
||||
if (index < 0) index = existing.findIndex((item, index) => !used.has(index) && isRecord(item) && readString(item, 'name') === model.name);
|
||||
if (index >= 0) used.add(index);
|
||||
return mergeModelRecords(index >= 0 ? [existing[index]] : [], [model]);
|
||||
});
|
||||
if (section === 'openai-compatibility' && draft.thinkingLevelsEdited) {
|
||||
for (const model of next.models as Record<string, unknown>[]) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
applyAdvancedFields(next, section, sharedDraft);
|
||||
delete next['api-key'];
|
||||
delete next['api-key-entries'];
|
||||
next.name = draft.name.trim();
|
||||
if (draft.proxyUrl?.trim()) next['proxy-url'] = draft.proxyUrl.trim();
|
||||
else delete next['proxy-url'];
|
||||
next.keys = (draft.groupKeys ?? []).map(serializeProviderKey);
|
||||
// Preserve absent/null/empty fields when the corresponding shared setting was
|
||||
// not edited. In v8 these values can have different inheritance semantics.
|
||||
if (current) {
|
||||
const baseline = draftFromRow(rowFromRecord(section, current, 0));
|
||||
const fields: [keyof ProviderDraft, string][] = [
|
||||
['baseUrl', 'base-url'], ['proxyUrl', 'proxy-url'], ['priority', 'priority'],
|
||||
['prefix', 'prefix'], ['headersText', 'headers'], ['excludedModelsText', 'excluded-models'],
|
||||
['disableCooling', 'disable-cooling'], ['models', 'models'],
|
||||
];
|
||||
for (const [field, configField] of fields) {
|
||||
if (field === 'models' && draft.thinkingLevelsEdited) continue;
|
||||
if (field === 'excludedModelsText' && draft.modelSelectionCatalog) continue;
|
||||
if (JSON.stringify(draft[field]) !== JSON.stringify(baseline[field])) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(current, configField)) next[configField] = structuredClone(current[configField]);
|
||||
else delete next[configField];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const providerIdentityMatches = (
|
||||
row: ProviderRecordIdentity,
|
||||
record: Record<string, unknown>,
|
||||
) => {
|
||||
if (Array.isArray(record.keys)) return readString(record, 'name') === row.name;
|
||||
if (definitionFor(row.section).openAi) {
|
||||
return readString(record, 'name') === row.name;
|
||||
}
|
||||
@@ -915,7 +987,7 @@ export type ProviderRecordIdentity = Pick<
|
||||
const providerPrimaryIdentityMatches = (
|
||||
row: ProviderRecordIdentity,
|
||||
record: Record<string, unknown>,
|
||||
) => definitionFor(row.section).openAi
|
||||
) => Array.isArray(record.keys) || definitionFor(row.section).openAi
|
||||
? readString(record, 'name') === row.name
|
||||
: readString(record, 'api-key', 'apiKey') === row.apiKey;
|
||||
|
||||
@@ -1038,10 +1110,7 @@ export function ApiAccessPage() {
|
||||
const responses = await Promise.allSettled(
|
||||
providerLoadDefinitions.map(async (definition) => ({
|
||||
section: definition.section,
|
||||
records: responseList(
|
||||
await managementApi.get(`/${definition.section}`),
|
||||
definition.responseKey,
|
||||
),
|
||||
records: await providerGroupsApi.get(definition.section),
|
||||
})),
|
||||
);
|
||||
const failures: string[] = [];
|
||||
@@ -1104,9 +1173,6 @@ export function ApiAccessPage() {
|
||||
.map((record, index) => rowFromRecord(activeSection, record, index))
|
||||
.map((row) => ({
|
||||
...row,
|
||||
name: activeCategory === 'deepseek'
|
||||
? t('apiAccess.provider.deepseek')
|
||||
: row.name,
|
||||
remark: apiAccessRemarks[
|
||||
providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row))
|
||||
] ?? '',
|
||||
@@ -1115,7 +1181,7 @@ export function ApiAccessPage() {
|
||||
.filter((row) => {
|
||||
const query = filter.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return [row.remark || row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
|
||||
return [row.remark, row.name, ...row.apiKeys, row.baseUrl, row.models.map(modelSearchText).join(' ')]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
@@ -1127,7 +1193,7 @@ export function ApiAccessPage() {
|
||||
feedback.clearNotice();
|
||||
setError('');
|
||||
setEditingRow(null);
|
||||
setDialogDraft(createProviderDraft(activeCategory));
|
||||
setDialogDraft({ ...createProviderDraft(activeCategory), groupKeys: [providerKeyDraft({ 'api-key': '' })] });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -1154,11 +1220,14 @@ export function ApiAccessPage() {
|
||||
models: preparedDraft.models.filter((model) => model.name.trim()),
|
||||
};
|
||||
const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key';
|
||||
const remarkRequired = definition.openAi && activeCategory !== 'deepseek';
|
||||
const parsedApiKeys = parseProviderApiKeys(preparedDraft.apiKey);
|
||||
const parsedApiKeys = (preparedDraft.groupKeys ?? []).map(({ value }) => readString(value, 'api-key').trim());
|
||||
if (!preparedDraft.name.trim() || preparedDraft.name.trim().length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.name)) {
|
||||
return { saved: false, target: 'form', error: t('apiAccess.groups.nameRequired') };
|
||||
}
|
||||
const invalidKey = validateProviderGroupKeys(preparedDraft.groupKeys ?? []);
|
||||
if (invalidKey) return { saved: false, target: 'form', error: t('apiAccess.groups.invalidKey', { field: invalidKey }) };
|
||||
if (
|
||||
parsedApiKeys.length === 0
|
||||
|| (remarkRequired && !preparedDraft.remark.trim())
|
||||
(parsedApiKeys.length === 0 && !editingRow && !definition.openAi)
|
||||
|| (baseUrlRequired && !preparedDraft.baseUrl.trim())
|
||||
) {
|
||||
return {
|
||||
@@ -1208,7 +1277,7 @@ export function ApiAccessPage() {
|
||||
if (
|
||||
definition.openAi
|
||||
&& activeCategory !== 'deepseek'
|
||||
&& draftToSave.models.length === 0
|
||||
&& !editingRow && draftToSave.models.length === 0
|
||||
) {
|
||||
let fetchedModels: ModelOption[];
|
||||
try {
|
||||
@@ -1216,8 +1285,10 @@ export function ApiAccessPage() {
|
||||
'openai',
|
||||
baseUrl,
|
||||
parsedApiKeys[0],
|
||||
editingRow?.authIndex,
|
||||
providerHeaders,
|
||||
undefined,
|
||||
preparedDraft.groupKeys?.[0]
|
||||
? providerHeadersFromRecord(effectiveProviderKey({ headers: providerHeaders }, serializeProviderKey(preparedDraft.groupKeys[0])))
|
||||
: providerHeaders,
|
||||
);
|
||||
if (fetchedModels.length === 0) {
|
||||
throw new Error(t('apiAccess.error.noModels'));
|
||||
@@ -1234,8 +1305,7 @@ export function ApiAccessPage() {
|
||||
models: fetchedModels,
|
||||
});
|
||||
}
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const current = sectionRecordsFromConfig(latestConfig, activeSection);
|
||||
const current = await providerGroupsApi.get(activeSection);
|
||||
let nextList: Record<string, unknown>[];
|
||||
let targetIndex = -1;
|
||||
let currentRecord: Record<string, unknown> | undefined;
|
||||
@@ -1248,13 +1318,7 @@ export function ApiAccessPage() {
|
||||
currentRecord = current[targetIndex];
|
||||
}
|
||||
|
||||
const recordsToSave = definition.openAi
|
||||
? [buildProviderRecord(activeSection, draftToSave, currentRecord)]
|
||||
: parsedApiKeys.map((apiKey) => buildProviderRecord(
|
||||
activeSection,
|
||||
{ ...draftToSave, apiKey },
|
||||
currentRecord,
|
||||
));
|
||||
const recordsToSave = [buildProviderGroupRecord(activeSection, draftToSave, currentRecord)];
|
||||
if (hasDuplicateProviderRecord(activeSection, current, recordsToSave, targetIndex)) {
|
||||
throw new Error(t('apiAccess.error.duplicate'));
|
||||
}
|
||||
@@ -1266,7 +1330,7 @@ export function ApiAccessPage() {
|
||||
]
|
||||
: [...current, ...recordsToSave];
|
||||
|
||||
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
|
||||
await providerGroupsApi.put(activeSection, nextList.map(stripResponseFields));
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: activeSection,
|
||||
@@ -1296,12 +1360,11 @@ export function ApiAccessPage() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const current = sectionRecordsFromConfig(latestConfig, row.section);
|
||||
const current = await providerGroupsApi.get(row.section);
|
||||
const targetIndex = resolveProviderRecordIndex(current, row);
|
||||
if (targetIndex < 0) throw new Error(t('apiAccess.error.stale'));
|
||||
const remainingRecords = current.filter((_, index) => index !== targetIndex).map(stripResponseFields);
|
||||
await managementApi.put(`/${row.section}`, remainingRecords);
|
||||
await providerGroupsApi.put(row.section, remainingRecords);
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: row.section,
|
||||
@@ -1327,8 +1390,7 @@ export function ApiAccessPage() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const latestRows = sectionRecordsFromConfig(latestConfig, row.section);
|
||||
const latestRows = await providerGroupsApi.get(row.section);
|
||||
const targetIndex = resolveProviderRecordIndex(latestRows, row);
|
||||
if (targetIndex < 0) {
|
||||
throw new Error(t('apiAccess.error.stale'));
|
||||
@@ -1340,10 +1402,8 @@ export function ApiAccessPage() {
|
||||
: Array.isArray(latestRecord['excluded-models'])
|
||||
&& latestRecord['excluded-models'].some((model) => String(model).trim() === '*');
|
||||
if (definition.openAi) {
|
||||
await managementApi.patch('/openai-compatibility', {
|
||||
index: targetIndex,
|
||||
value: { disabled: !currentlyDisabled },
|
||||
});
|
||||
await providerGroupsApi.put(row.section, latestRows.map((record, index) =>
|
||||
stripResponseFields(index === targetIndex ? { ...record, disabled: !currentlyDisabled } : record)));
|
||||
} else {
|
||||
const nextRecord = providerRecordWithDisabledState(
|
||||
row.section,
|
||||
@@ -1353,7 +1413,7 @@ export function ApiAccessPage() {
|
||||
const nextRows = latestRows.map((record, index) =>
|
||||
index === targetIndex ? nextRecord : stripResponseFields(record),
|
||||
);
|
||||
await managementApi.put(`/${row.section}`, nextRows);
|
||||
await providerGroupsApi.put(row.section, nextRows);
|
||||
}
|
||||
await loadProviders(false);
|
||||
} catch (requestError) {
|
||||
@@ -1369,11 +1429,10 @@ export function ApiAccessPage() {
|
||||
setError('');
|
||||
setNotice('');
|
||||
try {
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const latestRows = sectionRecordsFromConfig(latestConfig, source.section);
|
||||
const latestRows = await providerGroupsApi.get(source.section);
|
||||
const nextRows = reorderProviderRecords(latestRows, rows, source, target);
|
||||
if (!nextRows) throw new Error(t('apiAccess.error.stale'));
|
||||
await managementApi.put(`/${source.section}`, nextRows);
|
||||
await providerGroupsApi.put(source.section, nextRows);
|
||||
await loadProviders(false);
|
||||
} catch (requestError) {
|
||||
await loadProviders(false);
|
||||
@@ -1435,7 +1494,7 @@ export function ApiAccessPage() {
|
||||
<div className="management-panel-heading">
|
||||
<div>
|
||||
<h2 title={t(activeDefinition.labelKey)}>{t(activeDefinition.labelKey)}</h2>
|
||||
<span>{t('apiAccess.matches', { count: rows.length })}</span>
|
||||
<span>{t('apiAccess.groups.summary', { count: rows.length })}</span>
|
||||
</div>
|
||||
<div className="api-access-heading-tools">
|
||||
<button type="button" className="secondary-button compact-button" onClick={() => void loadProviders()} disabled={loading || busy}>
|
||||
@@ -1444,7 +1503,7 @@ export function ApiAccessPage() {
|
||||
</button>
|
||||
<button type="button" className="primary-button compact-button" onClick={openCreate} disabled={loading || busy}>
|
||||
<Plus size={16} aria-hidden="true" />
|
||||
{t('apiAccess.add')}
|
||||
{t('apiAccess.groups.add')}
|
||||
</button>
|
||||
<div className="management-toolbar compact-toolbar">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
@@ -1485,10 +1544,11 @@ export function ApiAccessPage() {
|
||||
>
|
||||
<div className="provider-row-main">
|
||||
<div className="provider-row-title">
|
||||
<strong title={row.remark || row.name}>{row.remark || row.name}</strong>
|
||||
<strong title={row.name}>{row.name}</strong>
|
||||
{row.remark ? <span>{row.remark}</span> : null}
|
||||
</div>
|
||||
<code title={definitionFor(row.section).openAi ? t('apiAccess.keys.count', { count: row.apiKeys.length }) : undefined}>
|
||||
{definitionFor(row.section).openAi && row.apiKeys.length > 1
|
||||
<code title={t('apiAccess.keys.count', { count: row.apiKeys.length })}>
|
||||
{row.apiKeys.length > 1
|
||||
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
|
||||
: maskSecret(row.apiKey)}
|
||||
</code>
|
||||
@@ -1509,12 +1569,12 @@ export function ApiAccessPage() {
|
||||
>
|
||||
{t('apiAccess.health.action')}
|
||||
</button>
|
||||
<label className="provider-enabled-control" title={row.disabled ? t('apiAccess.enable') : t('apiAccess.disable')}>
|
||||
<span>{row.disabled ? t('apiAccess.status.disabled') : t('apiAccess.status.enabled')}</span>
|
||||
<label className="provider-enabled-control" title={t('apiAccess.groups.sharedToggleHint')}>
|
||||
<span>{t(definitionFor(row.section).openAi ? row.record.disabled ? 'apiAccess.status.disabled' : 'apiAccess.status.enabled' : 'apiAccess.groups.sharedModels')}</span>
|
||||
<span className="switch-control">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!row.disabled}
|
||||
checked={definitionFor(row.section).openAi ? !row.record.disabled : !(Array.isArray(row.record['excluded-models']) && row.record['excluded-models'].includes('*'))}
|
||||
onChange={() => void toggleProvider(row)}
|
||||
disabled={busy}
|
||||
aria-label={t('apiAccess.toggleAria', { remark: row.remark || row.name, action: row.disabled ? t('common.enable') : t('common.disable') })}
|
||||
@@ -1522,6 +1582,7 @@ export function ApiAccessPage() {
|
||||
<span className="switch-track" />
|
||||
</span>
|
||||
</label>
|
||||
{providerGroupStatus(row.record) === 'partial' ? <span className="state-pill">{t('apiAccess.groups.partial')}</span> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={() => openEdit(row)} disabled={busy} title={t('common.edit')} aria-label={t('common.edit')}>
|
||||
<Edit3 size={16} aria-hidden="true" />
|
||||
</button>
|
||||
@@ -1549,7 +1610,7 @@ export function ApiAccessPage() {
|
||||
/>
|
||||
) : null}
|
||||
{healthDialogRow ? (
|
||||
<ProviderHealthDialog
|
||||
<ProviderGroupHealthDialog
|
||||
key={providerHealthIdentity(healthDialogRow)}
|
||||
row={healthDialogRow}
|
||||
onClose={() => setHealthDialogRow(null)}
|
||||
@@ -1563,11 +1624,30 @@ export function ApiAccessPage() {
|
||||
type ProviderHealthDialogProps = {
|
||||
row: ProviderRow;
|
||||
onClose: () => void;
|
||||
keySelector?: ReactNode;
|
||||
};
|
||||
|
||||
function ProviderGroupHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const keys = providerGroupKeys(row.record);
|
||||
const [selected, setSelected] = useState(0);
|
||||
const key = keys[selected];
|
||||
const effectiveRow = useMemo(() => {
|
||||
if (!key) return row;
|
||||
const record = effectiveProviderKey(row.record, key);
|
||||
return { ...row, record, apiKey: readString(key, 'api-key'), apiKeys: [readString(key, 'api-key')],
|
||||
authIndex: readString(key, 'auth-index', 'authIndex'), models: modelsFromRecord(record.models) };
|
||||
}, [row, key]);
|
||||
return <ProviderHealthDialog key={selected} row={effectiveRow} onClose={onClose} keySelector={keys.length ?
|
||||
<label className="provider-health-key-selector"><span>{t('apiAccess.groups.healthKey')}</span>
|
||||
<select value={selected} onChange={(event) => setSelected(Number(event.currentTarget.value))}>
|
||||
{keys.map((entry, index) => <option key={index} value={index}>{t('apiAccess.groups.keyNumber', { number: index + 1 })} · {maskSecret(readString(entry, 'api-key'))}</option>)}
|
||||
</select></label> : undefined} />;
|
||||
}
|
||||
|
||||
type ProviderModelHealthState = { status: 'checking' } | ProviderModelHealthResult;
|
||||
|
||||
function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
|
||||
function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const configuredModels = useMemo(
|
||||
() => mergeProviderHealthModels([], row.models),
|
||||
@@ -1684,6 +1764,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{keySelector}
|
||||
<div className="model-discovery-search">
|
||||
<Search size={16} aria-hidden="true" />
|
||||
<input value={search} onChange={(event) => setSearch(event.currentTarget.value)} placeholder={t('apiAccess.health.search')} />
|
||||
@@ -1798,6 +1879,8 @@ export function ApiProviderDialog({
|
||||
const definition = definitionFor(activeCategory);
|
||||
const activeSection = definition.section;
|
||||
const [draft, setDraft] = useState<ProviderDraft>(initialDraft);
|
||||
const grouped = Boolean(draft.groupKeys);
|
||||
const [discoveryKeyId, setDiscoveryKeyId] = useState(initialDraft.groupKeys?.[0]?.id ?? '');
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [modelError, setModelError] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
@@ -1849,7 +1932,7 @@ export function ApiProviderDialog({
|
||||
), [modelOptions, selectedModelNames]);
|
||||
|
||||
const updateTextField = (
|
||||
field: 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText',
|
||||
field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'proxyUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'cloakMode' | 'cloakSensitiveWordsText',
|
||||
value: string,
|
||||
) => {
|
||||
setFormError('');
|
||||
@@ -1950,13 +2033,15 @@ export function ApiProviderDialog({
|
||||
: definition.section === 'codex-api-key'
|
||||
? 'codex'
|
||||
: 'openai';
|
||||
const modelApiKey = draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
|
||||
const selectedKey = draft.groupKeys?.find((key) => key.id === discoveryKeyId) ?? draft.groupKeys?.[0];
|
||||
const connection = selectedKey ? effectiveProviderKey({ headers: parseProviderHeaders(draft.headersText ?? '') }, serializeProviderKey(selectedKey)) : null;
|
||||
const modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
|
||||
const fetchedModels = await fetchModels(
|
||||
provider,
|
||||
draft.baseUrl,
|
||||
modelApiKey,
|
||||
editingRow?.authIndex,
|
||||
parseProviderHeaders(draft.headersText ?? ''),
|
||||
connection ? readString(selectedKey!.value, 'auth-index', 'authIndex') || undefined : editingRow?.authIndex,
|
||||
connection ? providerHeadersFromRecord(connection) : parseProviderHeaders(draft.headersText ?? ''),
|
||||
);
|
||||
if (requestId !== discoveryRequestRef.current) return;
|
||||
const models = applyProviderPreset(
|
||||
@@ -2009,7 +2094,10 @@ export function ApiProviderDialog({
|
||||
if (modelLoading || selectedModels.length === 0) return;
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
models: selectedModels,
|
||||
models: grouped ? selectedModels.flatMap((selected) => {
|
||||
const existing = current.models.filter((model) => model.name.toLowerCase() === selected.name.toLowerCase());
|
||||
return existing.length ? existing : [selected];
|
||||
}) : selectedModels,
|
||||
modelSelectionCatalog: activeSection === 'openai-compatibility' ? undefined : discoveredModels,
|
||||
excludedModelsText: activeSection === 'openai-compatibility'
|
||||
? current.excludedModelsText
|
||||
@@ -2071,14 +2159,16 @@ export function ApiProviderDialog({
|
||||
<div className="config-dialog-heading">
|
||||
<div>
|
||||
<Plus size={19} aria-hidden="true" />
|
||||
<h2 id="api-provider-dialog-title">{editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
|
||||
<h2 id="api-provider-dialog-title">{grouped ? t(editingRow ? 'apiAccess.groups.edit' : 'apiAccess.groups.add') : editingRow ? t('apiAccess.dialog.edit') : t('apiAccess.dialog.add')}</h2>
|
||||
</div>
|
||||
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')} aria-label={t('common.close')}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
|
||||
<label className="multiline-field">
|
||||
{grouped ? <><p className="provider-group-hint">{t('apiAccess.groups.description')}</p>
|
||||
<label><span>{t('apiAccess.groups.name')}</span><input autoFocus value={draft.name} maxLength={80} onChange={(event) => updateTextField('name', event.currentTarget.value)} placeholder={t('apiAccess.groups.namePlaceholder')} /></label></> : null}
|
||||
<label><span>{t('apiAccess.field.remark')}</span><input autoFocus={!grouped && definition.openAi} value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
|
||||
{!grouped ? <label className="multiline-field">
|
||||
<span>{t('apiAccess.field.keysMany')}</span>
|
||||
<textarea
|
||||
autoFocus={!definition.openAi}
|
||||
@@ -2087,10 +2177,25 @@ export function ApiProviderDialog({
|
||||
placeholder={'sk-...\nsk-...'}
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
</label> : null}
|
||||
<label><span>{t('apiAccess.field.baseUrl')}</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
|
||||
<label><span>{t('apiAccess.field.proxyUrl')}</span><input value={draft.proxyUrl ?? ''} onChange={(event) => updateTextField('proxyUrl', event.currentTarget.value)} placeholder={draft.proxyUrlMixed ? t('apiAccess.proxyMixed') : 'socks5://127.0.0.1:1080'} /></label>
|
||||
{draft.proxyUrlMixed ? <button type="button" className="secondary-button compact-button api-provider-proxy-clear" onClick={() => updateTextField('proxyUrl', '')}>{t('apiAccess.proxyClear')}</button> : null}
|
||||
{draft.groupKeys ? <ProviderGroupKeysEditor keys={draft.groupKeys} section={activeSection} disabled={busy}
|
||||
shared={{ models: draft.models, priority: draft.priority ? Number(draft.priority) : undefined, prefix: draft.prefix,
|
||||
'proxy-url': draft.proxyUrl, 'disable-cooling': draft.disableCooling,
|
||||
'excluded-models': draft.excludedModelsText?.split(/[\n,]/).map((item) => item.trim()).filter(Boolean),
|
||||
headers: (() => { try { return parseProviderHeaders(draft.headersText ?? ''); } catch { return {}; } })(),
|
||||
}} onChange={(groupKeys) => {
|
||||
discoveryRequestRef.current += 1; setModelLoading(false); setModelError(''); setFormError('');
|
||||
if (activeCategory === 'deepseek') setModelDiscoveryReady(false);
|
||||
setDraft((current) => ({ ...current, groupKeys }));
|
||||
}} /> : null}
|
||||
{draft.groupKeys?.length ? <label><span>{t('apiAccess.groups.discoveryKey')}</span><select value={draft.groupKeys.some((key) => key.id === discoveryKeyId) ? discoveryKeyId : draft.groupKeys[0].id}
|
||||
onChange={(event) => { discoveryRequestRef.current += 1; setModelLoading(false); setDiscoveryKeyId(event.currentTarget.value); }}>
|
||||
{draft.groupKeys.map((key, index) => <option value={key.id} key={key.id}>{t('apiAccess.groups.keyNumber', { number: index + 1 })} · {maskSecret(readString(key.value, 'api-key'))}</option>)}
|
||||
</select></label> : null}
|
||||
{grouped ? <p className="provider-group-hint">{t('apiAccess.groups.sharedHint')}</p> : null}
|
||||
{activeCategory === 'openai-compatibility' ? (
|
||||
<div className="thinking-level-config">
|
||||
<div className="thinking-level-heading">
|
||||
@@ -2188,7 +2293,7 @@ export function ApiProviderDialog({
|
||||
<textarea value={draft.excludedModelsText ?? ''} onChange={(event) => updateTextField('excludedModelsText', event.currentTarget.value)} rows={3} placeholder={'model-old-*\nmodel-preview'} />
|
||||
</label>
|
||||
) : null}
|
||||
{activeSection === 'claude-api-key' ? (
|
||||
{!grouped && activeSection === 'claude-api-key' ? (
|
||||
<div className="provider-cloak-settings">
|
||||
<label>
|
||||
<span>{t('apiAccess.cloak.mode')}</span>
|
||||
@@ -2217,7 +2322,7 @@ export function ApiProviderDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{activeSection === 'codex-api-key' ? (
|
||||
{!grouped && activeSection === 'codex-api-key' ? (
|
||||
<div className="provider-advanced-toggle">
|
||||
<div><strong>WebSocket</strong><span>{t('apiAccess.websocket.description')}</span></div>
|
||||
<label className="switch-control" title={t('apiAccess.websocket.enable')}><input type="checkbox" checked={Boolean(draft.websockets)} onChange={(event) => updateBooleanField('websockets', event.currentTarget.checked)} /><span className="switch-track" /></label>
|
||||
|
||||
@@ -37,6 +37,17 @@ const SHARED_PROVIDER_FIELDS = new Set([
|
||||
|
||||
const providerDefinition = (path: string) => PROVIDER_PATHS[path as ProviderPath];
|
||||
|
||||
// Native v8 groups: callers keep keys and overrides intact through every edit.
|
||||
export const providerGroupsApi = {
|
||||
get: async (section: string): Promise<Record<string, unknown>[]> => {
|
||||
const value = await optionalConfigValue(`/config/api-keys/${section.replace(/-api-key$/, '')}`, [], {});
|
||||
if (!Array.isArray(value) || !value.every(isRecord)) throw new Error('Invalid provider group response');
|
||||
return value;
|
||||
},
|
||||
put: (section: string, groups: Record<string, unknown>[]) =>
|
||||
request('PUT', `/config/api-keys/${section.replace(/-api-key$/, '')}`, { body: groups }),
|
||||
};
|
||||
|
||||
async function optionalConfigValue(
|
||||
path: string, fallback: ManagementJson, options: ManagementRequestOptions,
|
||||
): Promise<unknown> {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isRecord, readString } from './managementApi';
|
||||
import { normalizeProviderModels } from './providerModels';
|
||||
|
||||
export type ProviderKeyDraft = { id: string; value: Record<string, unknown>; text?: Record<string, string> };
|
||||
export const providerGroupKeys = (group: Record<string, unknown>): Record<string, unknown>[] =>
|
||||
Array.isArray(group.keys) ? group.keys.filter(isRecord) : [];
|
||||
|
||||
export function providerKeyDraft(value: Record<string, unknown> = {}): ProviderKeyDraft {
|
||||
return { id: crypto.randomUUID(), value: structuredClone(value) };
|
||||
}
|
||||
|
||||
export function cleanProviderGroup(group: Record<string, unknown>): Record<string, unknown> {
|
||||
const clean = normalizeProviderModels(group);
|
||||
for (const field of ['auth-index', 'authIndex', 'auth_index', 'test-model', 'testModel']) delete clean[field];
|
||||
if (Array.isArray(clean.keys)) clean.keys = clean.keys.filter(isRecord).map(cleanProviderGroup);
|
||||
return clean;
|
||||
}
|
||||
|
||||
export function providerGroupIdentity(group: Record<string, unknown>): string {
|
||||
const sort = (value: unknown): unknown => Array.isArray(value) ? value.map(sort)
|
||||
: isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, sort(value[key])])) : value;
|
||||
return JSON.stringify(sort(cleanProviderGroup(group)));
|
||||
}
|
||||
|
||||
// Lists and maps replace the group setting; null and absent fields inherit it.
|
||||
export function effectiveProviderKey(group: Record<string, unknown>, key: Record<string, unknown>) {
|
||||
const { keys: _keys, ...shared } = group;
|
||||
return { ...shared, ...Object.fromEntries(Object.entries(key).filter(([, value]) => value !== null)) };
|
||||
}
|
||||
|
||||
export const providerKeyIsDisabled = (group: Record<string, unknown>, key: Record<string, unknown>) => {
|
||||
const effective = effectiveProviderKey(group, key);
|
||||
return group.disabled === true || effective.disabled === true
|
||||
|| (Array.isArray(effective['excluded-models']) && effective['excluded-models'].some((item) => String(item).trim() === '*'));
|
||||
};
|
||||
|
||||
export function providerGroupStatus(group: Record<string, unknown>): 'enabled' | 'partial' | 'disabled' {
|
||||
const keys = providerGroupKeys(group);
|
||||
if (!keys.length) return group.disabled === true ? 'disabled' : 'enabled';
|
||||
const active = keys.filter((key) => !providerKeyIsDisabled(group, key)).length;
|
||||
return active === 0 ? 'disabled' : active === keys.length ? 'enabled' : 'partial';
|
||||
}
|
||||
|
||||
export function providerKeyOverrides(key: Record<string, unknown>): string[] {
|
||||
return Object.keys(key).filter((field) => !['api-key', 'auth-index', 'authIndex', 'auth_index', 'weight'].includes(field)
|
||||
&& key[field] != null);
|
||||
}
|
||||
|
||||
export function validateProviderGroupKeys(keys: ProviderKeyDraft[]): string | null {
|
||||
for (const { value } of keys) {
|
||||
if (!readString(value, 'api-key').trim()) return 'key';
|
||||
if (Array.isArray(value.models) && value.models.some((model) => !isRecord(model) || !readString(model, 'name').trim())) return 'models';
|
||||
for (const field of ['priority', 'weight', 'request-retry']) {
|
||||
const valueField = value[field];
|
||||
if (valueField == null) continue;
|
||||
if (typeof valueField !== 'number' || !Number.isSafeInteger(valueField)) return field;
|
||||
if (field === 'weight' && (valueField < 0 || valueField > 1_000_000)) return field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function serializeProviderKey(draft: ProviderKeyDraft): Record<string, unknown> {
|
||||
const value = cleanProviderGroup(draft.value);
|
||||
for (const [field, text] of Object.entries(draft.text ?? {})) {
|
||||
if (field === 'cloak-words') value.cloak = { ...(isRecord(value.cloak) ? value.cloak : {}),
|
||||
'sensitive-words': text.split(/[\n,]/).map((line) => line.trim()).filter(Boolean) };
|
||||
if (field === 'excluded-models') value[field] = text.split(/[\n,]/).map((line) => line.trim()).filter(Boolean);
|
||||
if (field === 'headers') {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const line of text.split('\n').filter((line) => line.trim())) {
|
||||
const colon = line.indexOf(':');
|
||||
if (colon <= 0 || !/^[!#$%&'*+.^_`|~\w-]+$/.test(line.slice(0, colon).trim())) throw new Error('Invalid key header');
|
||||
headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
|
||||
}
|
||||
value[field] = headers;
|
||||
}
|
||||
}
|
||||
value['api-key'] = readString(value, 'api-key').trim();
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
.provider-group-hint,
|
||||
.provider-group-heading p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.provider-group-keys { display: grid; gap: 12px; min-width: 0; }
|
||||
.provider-group-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.provider-group-heading strong { font-size: 13px; }
|
||||
.api-provider-dialog .provider-group-heading button { flex-shrink: 0; width: auto; }
|
||||
.provider-group-heading > div { min-width: 0; }
|
||||
.provider-group-key {
|
||||
min-width: 0; margin: 0; padding: 12px;
|
||||
border: 1px solid var(--theme-e8e4dc); border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.provider-group-key legend { padding: 0 6px; font-size: 12px; font-weight: 600; }
|
||||
.provider-group-key-main { display: flex; align-items: end; gap: 6px; }
|
||||
.provider-group-key-main > label { flex: 1; min-width: 0; }
|
||||
.provider-group-key-main input { width: 100%; min-width: 0; }
|
||||
.api-provider-dialog .provider-group-key-main button { flex-shrink: 0; margin-bottom: 2px; width: auto; }
|
||||
.provider-key-settings { margin-top: 12px; }
|
||||
.provider-key-settings summary { font-size: 12px; color: var(--text-secondary); cursor: pointer; padding: 4px 0; }
|
||||
.provider-key-settings[open] { display: grid; gap: 12px; }
|
||||
.provider-key-override { display: grid; min-width: 0; gap: 6px; }
|
||||
.api-provider-dialog .provider-key-override-toggle { display: flex; align-items: center; gap: 8px; grid-template-rows: none; }
|
||||
.api-provider-dialog .provider-key-override-toggle input { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.provider-key-override > textarea { width: 100%; box-sizing: border-box; }
|
||||
.provider-group-key-state { display: none; }
|
||||
.provider-health-key-selector { display: flex; align-items: center; gap: 10px; padding: 0 20px 12px; }
|
||||
.provider-health-key-selector select { min-width: 0; flex: 1; }
|
||||
.provider-group-key .model-config-entry { min-width: 0; }
|
||||
.provider-group-key select, .provider-health-key-selector select {
|
||||
min-height: 36px; border: 1px solid var(--theme-e8e4dc); border-radius: 7px;
|
||||
background: var(--bg-primary); color: var(--text-primary); padding: 5px 8px; font: inherit;
|
||||
}
|
||||
.api-provider-dialog .provider-group-key-main .icon-button { width: 32px; min-width: 32px; height: 32px; }
|
||||
.provider-row-title > span { color: var(--text-secondary); font-size: 12px; overflow-wrap: anywhere; }
|
||||
@media (max-width: 480px) {
|
||||
.provider-group-heading { align-items: flex-start; }
|
||||
.provider-group-key { padding: 8px; }
|
||||
.provider-group-key-main { flex-wrap: wrap; }
|
||||
.provider-group-key-main > label { flex-basis: 100%; }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const path = require('node:path');
|
||||
});
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const form = page.getByRole('dialog', { name: 'Edit API Connection', exact: true });
|
||||
const form = page.getByRole('dialog', { name: 'Edit Group', exact: true });
|
||||
assert.equal(await form.locator('.model-config-entry').count(), 30);
|
||||
|
||||
// Older WebKit makes an inline-size query container a fixed-position containing block.
|
||||
@@ -72,10 +72,13 @@ const path = require('node:path');
|
||||
await assertDialogControls(form);
|
||||
await form.getByLabel('Remark', { exact: true }).fill('Small-window edit');
|
||||
await form.locator('.provider-advanced-settings summary').click();
|
||||
const headers = form.getByLabel(/Custom Headers/);
|
||||
const headers = form.getByRole('textbox', { name: /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');
|
||||
const keySettings = form.locator('.provider-group-key').first();
|
||||
await keySettings.locator('summary').click();
|
||||
await keySettings.getByRole('combobox', { name: 'Cache User ID', exact: true }).selectOption('false');
|
||||
await keySettings.locator('summary').click();
|
||||
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');
|
||||
@@ -100,11 +103,12 @@ const path = require('node:path');
|
||||
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(record.keys[0].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();
|
||||
await form.locator('.provider-group-key').first().locator('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');
|
||||
@@ -114,11 +118,11 @@ const path = require('node:path');
|
||||
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);
|
||||
assert.equal(inherited.keys[0].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 page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
|
||||
await assertViewportOverlay('.config-dialog-backdrop');
|
||||
await assertDialogControls(add);
|
||||
await add.locator('.provider-advanced-settings summary').click();
|
||||
|
||||
+3
-4
@@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../../src/services/managementApi';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
|
||||
@@ -12,7 +11,7 @@ const fixture = window as typeof window & {
|
||||
};
|
||||
fixture.providerFixture = {
|
||||
records: [{
|
||||
'api-key': 'shared-test-key', 'base-url': 'https://claude.example.test', priority: 10,
|
||||
name: 'First route', keys: [{ 'api-key': 'shared-test-key' }], 'base-url': 'https://claude.example.test', priority: 10,
|
||||
models: [{ name: 'upstream-a', alias: 'claude-a' }],
|
||||
}],
|
||||
writes: [],
|
||||
@@ -40,13 +39,13 @@ mockIPC((cmd, rawArgs) => {
|
||||
return { status_code: 200, body: { data: [{ id: 'discovered-model' }] } };
|
||||
}
|
||||
if (request.method === 'GET') {
|
||||
const groups = groupLegacyProviderRecords('claude', fixture.providerFixture.records);
|
||||
const groups = structuredClone(fixture.providerFixture.records);
|
||||
if (section === 'config') return { 'api-keys': { claude: groups } };
|
||||
return section === 'config/api-keys/claude' ? groups : [];
|
||||
}
|
||||
fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) });
|
||||
if (request.method !== 'PUT' || section !== 'config/api-keys/claude') throw new Error('Unexpected provider mutation');
|
||||
fixture.providerFixture.records = flattenV8ProviderGroups('claude', structuredClone(request.body));
|
||||
fixture.providerFixture.records = structuredClone(request.body);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<html><head><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/provider-groups.tsx"></script></body></html>
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
import { ApiAccessPage } from '../../src/pages/ApiAccessPage';
|
||||
import '../../src/styles/index.css';
|
||||
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', new URLSearchParams(location.search).get('locale') ?? 'en');
|
||||
const fixture = window as typeof window & { groupFixture: { groups: Record<string, unknown>[], writes: unknown[], probes: unknown[] } };
|
||||
fixture.groupFixture = {
|
||||
groups: [{ name: 'Primary gateway', 'base-url': 'https://gateway.example.test/v1', priority: 2,
|
||||
headers: { 'X-Group': 'shared' }, models: [{ name: 'gpt-original' }, { name: 'gpt-original', alias: 'gpt-fast' }],
|
||||
keys: [{ 'api-key': 'key-inherited', weight: 2, priority: null },
|
||||
{ 'api-key': 'key-overridden', weight: 5, priority: 0, 'proxy-url': 'direct', headers: { 'X-Key': 'second' }, models: [{ name: 'private-model' }], 'excluded-models': [], 'disable-cooling': false }],
|
||||
}, { name: 'Backup gateway', 'base-url': 'https://backup.example.test/v1', models: [{ name: 'backup-model' }], keys: [{ 'api-key': 'backup-key' }] }],
|
||||
writes: [], probes: [],
|
||||
};
|
||||
mockIPC((cmd, args: any) => {
|
||||
if (cmd === 'set_app_locale' || cmd === 'save_api_access_remark') return null;
|
||||
if (cmd === 'resolve_api_access_remarks') return args.queries.map(() => '');
|
||||
if (cmd !== 'management_request') throw new Error(`Unexpected ${cmd}`);
|
||||
const request = args.request;
|
||||
if (request.path === '/requests/api-call') {
|
||||
fixture.groupFixture.probes.push(request.body);
|
||||
return { status_code: 200, body: { data: [{ id: 'gpt-original' }, { id: 'new-discovered' }] } };
|
||||
}
|
||||
if (request.method === 'GET') return request.path === '/config/api-keys/codex' ? structuredClone(fixture.groupFixture.groups) : [];
|
||||
if (request.path !== '/config/api-keys/codex' || request.method !== 'PUT') throw new Error('Unexpected mutation');
|
||||
fixture.groupFixture.writes.push(structuredClone(request.body));
|
||||
fixture.groupFixture.groups = structuredClone(request.body);
|
||||
return { status: 'ok' };
|
||||
});
|
||||
createRoot(document.getElementById('root')!).render(<I18nProvider><div className="app-shell"><aside className="sidebar" /><div className="workspace"><main className="content"><ApiAccessPage /></main></div></div></I18nProvider>);
|
||||
@@ -5,7 +5,7 @@ import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import {
|
||||
applyProviderPreset, buildProviderRecord, createProviderDraft,
|
||||
applyProviderPreset, buildProviderRecord, buildProviderGroupRecord, createProviderDraft, providerDraftFromRecord,
|
||||
type ProviderSection,
|
||||
} from '../src/pages/ApiAccessPage';
|
||||
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../src/services/managementApi';
|
||||
@@ -66,6 +66,31 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
return flattenV8ProviderGroups(provider, await loaded.json());
|
||||
};
|
||||
|
||||
it('native group edits preserve multiple keys, shared models and explicit overrides in v8', async () => {
|
||||
const url = `${origin}/config/api-keys/codex`;
|
||||
const groups = [{ name: 'native-multi-key', 'base-url': 'https://gateway.example.test/v1',
|
||||
models: [{ name: 'gpt-test' }, { name: 'gpt-test', alias: 'gpt-test-fast' }],
|
||||
headers: { 'X-Group': 'shared' }, 'excluded-models': ['old-*'],
|
||||
keys: [{ 'api-key': 'native-first', weight: 2 }, { 'api-key': 'native-second', priority: 0,
|
||||
weight: 5, models: null, headers: {}, 'excluded-models': [], 'disable-cooling': false, 'request-retry': 0 }],
|
||||
}];
|
||||
const write = async (value: unknown) => {
|
||||
const response = await fetch(url, { method: 'PUT', headers, body: JSON.stringify(value) });
|
||||
const body = await response.text();
|
||||
expect({ status: response.status, result: response.ok ? 'ok' : body }).toEqual({ status: 200, result: 'ok' });
|
||||
return (await (await fetch(url, { headers })).json()) as Record<string, unknown>[];
|
||||
};
|
||||
const loaded = await write(groups);
|
||||
const draft = providerDraftFromRecord('codex-api-key', loaded[0]);
|
||||
draft.name = 'renamed-native'; draft.priority = '6';
|
||||
const saved = await write([buildProviderGroupRecord('codex-api-key', draft, loaded[0])]);
|
||||
expect(saved.length).toBe(1);
|
||||
expect(saved[0].name).toBe('renamed-native');
|
||||
expect(saved[0].priority).toBe(6);
|
||||
expect(saved[0].keys).toEqual(loaded[0].keys);
|
||||
expect(saved[0].models).toEqual(loaded[0].models);
|
||||
});
|
||||
|
||||
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', '');
|
||||
|
||||
@@ -18,8 +18,9 @@ const base = 'http://127.0.0.1:1421';
|
||||
const form = page.locator('.api-provider-dialog');
|
||||
const waitForSave = () => form.waitFor({ state: 'detached' });
|
||||
|
||||
await page.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await form.getByLabel('API Keys (one per line)', { exact: true }).fill('shared-test-key');
|
||||
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
await form.getByLabel('Group Name', { exact: true }).fill('Second route');
|
||||
await form.getByLabel('Key 1', { exact: true }).fill('shared-test-key');
|
||||
await form.getByLabel('Base URL', { exact: true }).fill('https://claude.example.test');
|
||||
await form.getByLabel('Priority', { exact: true }).fill('1');
|
||||
await form.getByLabel('Remark', { exact: true }).fill('Second route');
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
|
||||
(async () => {
|
||||
const { createServer } = await import('vite');
|
||||
const react = (await import('@vitejs/plugin-react')).default;
|
||||
const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()],
|
||||
optimizeDeps: { entries: ['tests/fixtures/provider-groups.html'] }, logLevel: 'error',
|
||||
server: { host: '127.0.0.1', port: 0, watch: null } });
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/provider-groups.html`);
|
||||
const rows = page.locator('.real-provider-row');
|
||||
const primary = () => rows.filter({ hasText: 'Primary gateway' });
|
||||
const groups = () => page.evaluate(() => window.groupFixture.groups);
|
||||
const dialog = page.getByRole('dialog', { name: 'Edit Group', exact: true });
|
||||
await primary().waitFor();
|
||||
assert.equal(await rows.count(), 2, 'One row per group, not per key');
|
||||
const initial = await groups();
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
assert.equal(await dialog.locator('.provider-group-key').count(), 2);
|
||||
await dialog.getByLabel('Priority', { exact: true }).last().fill('7');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
let saved = await groups();
|
||||
assert.deepEqual(saved, [{ ...initial[0], priority: 7 }, initial[1]]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
const second = dialog.locator('.provider-group-key').nth(1);
|
||||
await second.locator('summary').click();
|
||||
await second.getByLabel('Override: Priority', { exact: true }).uncheck();
|
||||
await second.getByLabel('Key 2', { exact: true }).fill('rotated-second-key');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
saved = await groups();
|
||||
assert.equal(saved[0].keys[1]['api-key'], 'rotated-second-key');
|
||||
assert.ok(!('priority' in saved[0].keys[1]), 'Unchecking restores inheritance');
|
||||
assert.deepEqual(saved[0].keys[1].models, initial[0].keys[1].models);
|
||||
assert.deepEqual(saved[0].keys[0], initial[0].keys[0]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await dialog.getByLabel('Key for Model Discovery').selectOption({ index: 1 });
|
||||
await dialog.getByRole('button', { name: /Fetch Models|Get Models|Select Models/ }).click();
|
||||
await page.waitForFunction(() => window.groupFixture.probes.length > 0);
|
||||
const probe = await page.evaluate(() => window.groupFixture.probes.at(-1));
|
||||
assert.equal(probe.header.Authorization, 'Bearer rotated-second-key');
|
||||
assert.equal(probe.header['X-Key'], 'second');
|
||||
assert.ok(!('X-Group' in probe.header));
|
||||
await page.locator('.model-discovery-dialog').getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Add Group', exact: true }).click();
|
||||
const add = page.getByRole('dialog', { name: 'Add Group', exact: true });
|
||||
await add.getByLabel('Group Name', { exact: true }).fill('New multi-key group');
|
||||
await add.getByLabel('Base URL', { exact: true }).fill('https://new.example.test/v1');
|
||||
await add.getByLabel('Key 1', { exact: true }).fill('new-key-a');
|
||||
await add.getByRole('button', { name: 'Add Key', exact: true }).click();
|
||||
await add.getByLabel('Key 2', { exact: true }).fill('new-key-b');
|
||||
await add.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await add.waitFor({ state: 'detached' });
|
||||
saved = await groups();
|
||||
assert.equal(saved.length, 3);
|
||||
assert.deepEqual(saved[2].keys, [{ 'api-key': 'new-key-a' }, { 'api-key': 'new-key-b' }]);
|
||||
|
||||
await primary().getByRole('button', { name: 'Edit', exact: true }).click();
|
||||
await page.evaluate(() => { window.groupFixture.groups[0].keys[1].weight = 9; });
|
||||
const writes = await page.evaluate(() => window.groupFixture.writes.length);
|
||||
await dialog.getByLabel('Priority', { exact: true }).last().fill('9');
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await dialog.locator('.action-feedback-message').waitFor();
|
||||
assert.equal(await page.evaluate(() => window.groupFixture.writes.length), writes, 'Stale save must not write');
|
||||
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
await fs.mkdir(path.resolve('bin-work/provider-groups-qa'), { recursive: true });
|
||||
for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto(`${base}/tests/fixtures/provider-groups.html?locale=zh-CN`);
|
||||
await rows.first().getByRole('button', { name: '编辑', exact: true }).click();
|
||||
const form = page.locator('.api-provider-dialog');
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/edit-${viewport.width}.png`) });
|
||||
const dimensions = await form.evaluate(el => ({width: el.clientWidth, scroll: el.scrollWidth, offenders: [...el.querySelectorAll('*')].filter(child=>child.getBoundingClientRect().right > el.getBoundingClientRect().right).map(child=>({tag:child.tagName,cls:child.className,width:child.getBoundingClientRect().width})).slice(0,12)}));
|
||||
assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify({viewport,dimensions}));
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/edit-${viewport.width}.png`) });
|
||||
await form.locator('.provider-group-key').nth(1).locator('summary').click();
|
||||
await form.locator('.provider-group-key').nth(1).scrollIntoViewIfNeeded();
|
||||
assert.ok(await form.evaluate(el => el.scrollWidth <= el.clientWidth + 1), 'Expanded overrides fit');
|
||||
await page.screenshot({ path: path.resolve(`bin-work/provider-groups-qa/overrides-${viewport.width}.png`) });
|
||||
await form.getByRole('button', { name: '取消', exact: true }).click();
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: native group editing, key rotation and inheritance, discovery credentials, multi-key creation, stale-save protection and responsive layout.');
|
||||
} finally { await browser?.close(); await server.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildProviderGroupRecord, providerDraftFromRecord, reorderProviderRecords, resolveProviderRecordIndex } from '../src/pages/ApiAccessPage';
|
||||
import { cleanProviderGroup, effectiveProviderKey, providerGroupStatus, providerKeyDraft, serializeProviderKey } from '../src/services/providerGroups';
|
||||
|
||||
const group = () => ({
|
||||
name: 'shared-gateway', 'base-url': 'https://gateway.test/v1',
|
||||
priority: 3, 'proxy-url': 'http://proxy.test:80', 'excluded-models': ['old-*'],
|
||||
headers: { 'X-Group': 'shared' }, 'request-retry': 0,
|
||||
models: [{ name: 'upstream' }, { name: 'upstream', alias: 'upstream-fast', thinking: { levels: ['high'] } }],
|
||||
keys: [
|
||||
{ 'api-key': 'key-a', weight: 2, 'auth-index': 'runtime-a' },
|
||||
{ 'api-key': 'key-b', weight: 5, priority: null, headers: {}, models: null,
|
||||
'excluded-models': [], 'disable-cooling': false, websockets: false, 'request-scoped-errors': [{ status: 429, action: 'continue' }] },
|
||||
{ 'api-key': 'key-c', priority: 0, models: [{ name: 'private', alias: 'private-alias' }], 'proxy-url': 'direct' },
|
||||
],
|
||||
});
|
||||
|
||||
describe('native v8 provider groups', () => {
|
||||
for (const section of ['codex-api-key', 'claude-api-key', 'gemini-api-key', 'openai-compatibility'] as const) {
|
||||
test(`${section} keeps one group, key order and overrides on a shared edit`, () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord(section, original);
|
||||
draft.name = 'renamed'; draft.priority = '4';
|
||||
const saved = buildProviderGroupRecord(section, draft, original);
|
||||
expect(saved).toEqual({ ...cleanProviderGroup(original), name: 'renamed', priority: 4 });
|
||||
expect(original.keys[0]['auth-index']).toBe('runtime-a');
|
||||
expect(saved).not.toHaveProperty('api-key');
|
||||
expect(saved).not.toHaveProperty('api-key-entries');
|
||||
});
|
||||
}
|
||||
test('preserves absent, null and empty shared fields on an unrelated edit', () => {
|
||||
for (const fields of [{}, { models: null, headers: null, 'proxy-url': null }, { models: [], headers: {}, 'proxy-url': '' }]) {
|
||||
const original = { name: 'empty', keys: [], ...fields };
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
expect(buildProviderGroupRecord('codex-api-key', draft, original)).toEqual(original);
|
||||
}
|
||||
});
|
||||
test('shared model edits keep existing aliases and independent key models', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
draft.models = [...draft.models, { name: 'new-model' }];
|
||||
const saved = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(saved.models).toEqual(expect.arrayContaining([...original.models, { name: 'new-model' }]));
|
||||
expect((saved.models as unknown[]).length).toBe(3);
|
||||
expect(saved.keys).toEqual(cleanProviderGroup(original).keys);
|
||||
});
|
||||
test('key rotation and removal do not transfer settings to another key', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
const second = draft.groupKeys![1];
|
||||
second.value['api-key'] = 'rotated';
|
||||
draft.groupKeys = [second, providerKeyDraft({ 'api-key': 'new' })];
|
||||
const saved = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(saved.keys).toEqual([{ ...original.keys[1], 'api-key': 'rotated' }, { 'api-key': 'new' }]);
|
||||
});
|
||||
test('all aliases of one upstream can be edited and removed individually', () => {
|
||||
const original = group();
|
||||
const draft = providerDraftFromRecord('codex-api-key', original);
|
||||
expect(draft.models.map((model) => model.alias ?? '')).toEqual(['', 'upstream-fast']);
|
||||
draft.models[1].alias = 'upstream-renamed';
|
||||
const renamed = buildProviderGroupRecord('codex-api-key', draft, original);
|
||||
expect(renamed.models).toEqual([original.models[0], { ...original.models[1], alias: 'upstream-renamed' }]);
|
||||
draft.models = [draft.models[1]];
|
||||
expect(buildProviderGroupRecord('codex-api-key', draft, original).models)
|
||||
.toEqual([{ ...original.models[1], alias: 'upstream-renamed' }]);
|
||||
});
|
||||
test('stale checks distinguish inherited null from explicit empty overrides', () => {
|
||||
const original = group();
|
||||
const changed = structuredClone(original);
|
||||
changed.keys[1].models = [] as never;
|
||||
expect(resolveProviderRecordIndex([changed], { section: 'codex-api-key', name: original.name,
|
||||
apiKey: 'key-a', baseUrl: original['base-url'], index: 0, record: original })).toBe(-1);
|
||||
});
|
||||
test('inheritance respects null, explicit false, zero and entire map/list overrides', () => {
|
||||
const original = group();
|
||||
const effective = effectiveProviderKey(original, original.keys[1]);
|
||||
expect(effective).toMatchObject({ priority: 3, models: original.models, headers: {}, 'excluded-models': [], 'disable-cooling': false });
|
||||
expect(effectiveProviderKey(original, original.keys[2]).priority).toBe(0);
|
||||
expect(providerGroupStatus({ ...original, 'excluded-models': ['*'] })).toBe('partial');
|
||||
expect(providerGroupStatus({ ...original, disabled: true })).toBe('disabled');
|
||||
});
|
||||
test('serializes explicit blank overrides and rejects malformed headers', () => {
|
||||
const draft = providerKeyDraft({ 'api-key': 'key', headers: null, 'excluded-models': ['old'] });
|
||||
draft.text = { headers: '', 'excluded-models': '' };
|
||||
expect(serializeProviderKey(draft)).toEqual({ 'api-key': 'key', headers: {}, 'excluded-models': [] });
|
||||
draft.text.headers = 'bad header';
|
||||
expect(() => serializeProviderKey(draft)).toThrow();
|
||||
});
|
||||
test('external changes to a sibling key reject stale group saves', () => {
|
||||
const original = group();
|
||||
const identity = { section: 'codex-api-key' as const, name: original.name, apiKey: 'key-a', baseUrl: original['base-url'], index: 0, record: original };
|
||||
const changed = group(); changed.keys[1].weight = 10;
|
||||
expect(resolveProviderRecordIndex([changed], identity)).toBe(-1);
|
||||
const other = { ...group(), name: 'other' };
|
||||
const second = { ...identity, index: 1, name: 'other', record: other };
|
||||
expect(reorderProviderRecords([original, other], [identity, second], identity, second))
|
||||
.toEqual([cleanProviderGroup(other), cleanProviderGroup(original)]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user