feat(api-access): support native CPA v8 groups and fix alias source validation

This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 6dc8650e68
commit 84fe2be8ac
22 files changed
+1136 -95

No files matched your search

+2
View File
@@ -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::*;
+11 -2
View File
@@ -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);
+32 -9
View File
@@ -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());
}
}
+59
View File
@@ -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(&current, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
let updated = add_model_alias_to_yaml(&current, &sources[0], "gpt-test-fast", "", false).unwrap();
let error = commit_management_alias_config_changes(&gui, &current, &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(&current, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap();
let updated = add_model_alias_to_yaml(&current, &sources[0], "gpt-test-fast", "", false).unwrap();
put_management_alias_config_changes(&gui, &current, &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() {
+76
View File
@@ -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";
+125
View File
@@ -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>;
}
+29
View File
@@ -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': "設定",
+29
View File
@@ -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",
+29
View File
@@ -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
View File
@@ -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>
+11
View File
@@ -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> {
+81
View File
@@ -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;
}
+45
View File
@@ -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%; }
}
+11 -7
View File
@@ -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
View File
@@ -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}`);
+1
View File
@@ -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>
+33
View File
@@ -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>);
+26 -1
View File
@@ -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', '');
+3 -2
View File
@@ -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');
+102
View File
@@ -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; });
+99
View File
@@ -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)]);
});
});