diff --git a/src-tauri/src/core_config.rs b/src-tauri/src/core_config.rs index e8ca330..a5342fe 100644 --- a/src-tauri/src/core_config.rs +++ b/src-tauri/src/core_config.rs @@ -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::*; diff --git a/src-tauri/src/core_config/alias_save.rs b/src-tauri/src/core_config/alias_save.rs index 1799b80..e573685 100644 --- a/src-tauri/src/core_config/alias_save.rs +++ b/src-tauri/src/core_config/alias_save.rs @@ -102,7 +102,8 @@ pub(crate) async fn commit_management_alias_config_changes( 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( .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); diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index 82b6bb8..d3286e8 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -226,6 +226,10 @@ pub(crate) fn validate_thinking_alias_effort(value: &str) -> Result Result { + 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 { 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 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::, _>>()?; + 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 diff --git a/src-tauri/src/core_config/provider_groups.rs b/src-tauri/src/core_config/provider_groups.rs new file mode 100644 index 0000000..43b7697 --- /dev/null +++ b/src-tauri/src/core_config/provider_groups.rs @@ -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 { + 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| { + 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 = 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()); + } +} diff --git a/src-tauri/src/tests/core_v8_contract.rs b/src-tauri/src/tests/core_v8_contract.rs index f3737e9..a083161 100644 --- a/src-tauri/src/tests/core_v8_contract.rs +++ b/src-tauri/src/tests/core_v8_contract.rs @@ -128,6 +128,65 @@ impl TestCore { } } +#[tokio::test] +#[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] +async fn v8_alias_save_preserves_multi_key_groups_and_restores_inheritance_after_failure() { + let executable = fs::canonicalize(std::env::var_os("CPA_V8_TEST_CORE").expect("set CPA_V8_TEST_CORE")).unwrap(); + let directory = agent_test_home("v8-contract-groups"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let mut core = TestCore { + child: None, config: directory.join("config.yaml"), directory, executable, + origin: format!("http://127.0.0.1:{port}"), + client: reqwest::Client::builder().no_proxy().timeout(Duration::from_secs(3)).build().unwrap(), + }; + let gui = GuiConfigFile { + host: "127.0.0.1".into(), port, auth_dir: path_to_string(&core.directory.join("auth")), + management_secret_key: "isolated-test-secret".into(), + api_keys: vec![GuiApiKeyEntry { key: "isolated-client-key".into(), remark: String::new() }], + ..GuiConfigFile::default() + }; + let initial = r#" +config-version: 8 +management: {disable-control-panel: true, disable-auto-update-panel: true} +api-keys: + codex: + - name: shared-upstream + base-url: http://127.0.0.1:1/v1 + models: [{name: gpt-test}] + keys: + - {api-key: test-first, weight: 2, priority: null} + - {api-key: test-second, weight: 5, models: null, headers: {}, excluded-models: [], disable-cooling: false} + - api-key: test-third + models: [{name: private-model}] +"#; + fs::write(&core.config, apply_gui_managed_settings(initial, &gui).unwrap()).unwrap(); + core.start().await; + core.validate_save(&fs::read_to_string(&core.config).unwrap()).await; + let original = core.config_view().await; + let current = fetch_management_config_yaml(&gui).await.unwrap(); + let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap(); + let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap(); + let error = commit_management_alias_config_changes(&gui, ¤t, &updated, || Err::<(), _>("local save failed".into())).await.unwrap_err(); + assert!(error.contains("Original configuration was restored"), "{error}"); + assert_eq!(core.config_view().await["api-keys"], original["api-keys"]); + + let current = fetch_management_config_yaml(&gui).await.unwrap(); + let sources = resolved_thinking_alias_sources(¤t, &[], &super::support::test_agent_models(&["gpt-test"])).unwrap(); + let updated = add_model_alias_to_yaml(¤t, &sources[0], "gpt-test-fast", "", false).unwrap(); + put_management_alias_config_changes(&gui, ¤t, &updated).await.unwrap(); + let saved = core.config_view().await; + let group = &saved["api-keys"]["codex"][0]; + assert_eq!(saved["api-keys"]["codex"].as_array().unwrap().len(), 1); + assert_eq!(group["name"], "shared-upstream"); + assert_eq!(group["keys"].as_array().unwrap().len(), 3); + assert_eq!(group["models"], original["api-keys"]["codex"][0]["models"]); + assert_eq!(group["keys"][0]["models"][1]["alias"], "gpt-test-fast"); + assert_eq!(group["keys"][1], original["api-keys"]["codex"][0]["keys"][1]); + assert_eq!(group["keys"][2], original["api-keys"]["codex"][0]["keys"][2]); +} + #[tokio::test] #[ignore = "requires CPA_V8_TEST_CORE pointing to a v8 executable"] async fn v8_accepts_gui_settings_and_reloads_client_keys() { diff --git a/src-tauri/src/tests/model_aliases.rs b/src-tauri/src/tests/model_aliases.rs index 7e64dd2..dfa4177 100644 --- a/src-tauri/src/tests/model_aliases.rs +++ b/src-tauri/src/tests/model_aliases.rs @@ -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"; diff --git a/src/components/ProviderGroupKeysEditor.tsx b/src/components/ProviderGroupKeysEditor.tsx new file mode 100644 index 0000000..9c66d31 --- /dev/null +++ b/src/components/ProviderGroupKeysEditor.tsx @@ -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; + section: string; + disabled: boolean; + onChange: (keys: ProviderKeyDraft[]) => void; +}; + +export function ProviderGroupKeysEditor({ keys, shared, section, disabled, onChange }: Props) { + const { t } = useI18n(); + const [visible, setVisible] = useState>({}); + 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
+
{t('apiAccess.groups.keys')}

{t('apiAccess.groups.keysHint')}

+ +
+ {!keys.length ?

{t('apiAccess.groups.emptyKeys')}

: null} + {keys.map((draft, index) => { + const key = draft.value; + const overrideCount = providerKeyOverrides(key).length; + const effective = effectiveProviderKey(shared, key); + return
+ {t('apiAccess.groups.keyNumber', { number: index + 1 })} +
+ + + +
+
+ {t('apiAccess.groups.keySettings')} · {overrideCount ? t('apiAccess.groups.overrides', { count: overrideCount }) : t('apiAccess.groups.inherits')} + +

{t('apiAccess.groups.overrideHint')}

+ {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
+ + {overridden ? kind === 'lines' ?