diff --git a/src-tauri/src/core_config/aliases.rs b/src-tauri/src/core_config/aliases.rs index e93c6be..9d10f74 100644 --- a/src-tauri/src/core_config/aliases.rs +++ b/src-tauri/src/core_config/aliases.rs @@ -64,6 +64,79 @@ pub(crate) async fn put_management_config_yaml( read_management_value(response).await.map(|_| ()) } +pub(crate) async fn put_management_oauth_model_aliases( + config: &GuiConfigFile, + aliases: &serde_json::Value, +) -> Result<(), String> { + let client = management_http_client()?; + let response = client + .put(management_endpoint(config, "oauth-model-alias")?) + .header("Authorization", management_authorization(config)?) + .json(aliases) + .send() + .await + .map_err(|error| format!("保存 OAuth 模型别名失败: {error}"))?; + read_management_value(response).await.map(|_| ()) +} + +#[derive(Debug, PartialEq)] +pub(crate) struct ManagementAliasConfigChanges { + pub(crate) oauth_model_aliases: Option, + pub(crate) update_config_yaml: bool, +} + +pub(crate) fn management_alias_config_changes( + current: &str, + updated: &str, +) -> Result { + let split = |content: &str| { + let mut document = serde_norway::from_str::(content) + .map_err(|error| format!("解析内核 YAML 配置失败: {error}"))?; + let root = document + .as_mapping_mut() + .ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?; + let oauth_aliases = root + .remove(yaml_key("oauth-model-alias")) + .unwrap_or_else(|| serde_norway::Value::Mapping(serde_norway::Mapping::new())); + Ok::<_, String>((document, oauth_aliases)) + }; + let (current_without_oauth, current_oauth) = split(current)?; + let (updated_without_oauth, updated_oauth) = split(updated)?; + let oauth_model_aliases = if current_oauth == updated_oauth { + None + } else { + Some( + serde_json::to_value(updated_oauth) + .map_err(|error| format!("序列化 OAuth 模型别名失败: {error}"))?, + ) + }; + Ok(ManagementAliasConfigChanges { + oauth_model_aliases, + update_config_yaml: current_without_oauth != updated_without_oauth, + }) +} + +pub(crate) async fn put_management_alias_config_changes( + config: &GuiConfigFile, + current: &str, + updated: &str, +) -> Result<(), String> { + if updated == current { + return Ok(()); + } + let changes = management_alias_config_changes(current, updated)?; + if let Some(oauth_model_aliases) = changes.oauth_model_aliases.as_ref() { + // The dedicated endpoint refreshes CPA's OAuth model registry immediately. + // Writing the same section only through config.yaml updates the file and + // management snapshot, but can leave /v1/models and routing stale. + put_management_oauth_model_aliases(config, oauth_model_aliases).await?; + } + if changes.update_config_yaml { + put_management_config_yaml(config, updated).await?; + } + Ok(()) +} + pub(crate) async fn ensure_claude_desktop_model_aliases( config: &GuiConfigFile, mappings: &ClaudeDesktopModelMappings, @@ -84,10 +157,7 @@ pub(crate) async fn ensure_claude_desktop_model_aliases( )? } }; - if updated != content { - put_management_config_yaml(config, &updated).await?; - } - Ok(()) + put_management_alias_config_changes(config, &content, &updated).await } pub(crate) async fn remove_managed_claude_model_aliases( @@ -95,10 +165,7 @@ pub(crate) async fn remove_managed_claude_model_aliases( ) -> Result<(), String> { let content = fetch_management_config_yaml(config).await?; let updated = remove_managed_claude_model_aliases_in_yaml(&content)?; - if updated != content { - put_management_config_yaml(config, &updated).await?; - } - Ok(()) + put_management_alias_config_changes(config, &content, &updated).await } pub(crate) fn ensure_claude_desktop_model_aliases_in_yaml( diff --git a/src-tauri/src/tests/agent_configuration.rs b/src-tauri/src/tests/agent_configuration.rs index 2cae070..a5ebd70 100644 --- a/src-tauri/src/tests/agent_configuration.rs +++ b/src-tauri/src/tests/agent_configuration.rs @@ -905,6 +905,37 @@ fn claude_desktop_aliases_support_codex_oauth_models() { ); } +#[test] +fn claude_oauth_alias_changes_use_the_runtime_refresh_endpoint() { + let current = "port: 8317\nopenai-compatibility: []\n"; + let oauth_only = "port: 8317\nopenai-compatibility: []\noauth-model-alias:\n codex:\n - name: gpt-5.6-sol\n alias: claude-opus-5\n fork: true\n"; + let changes = management_alias_config_changes(current, oauth_only).unwrap(); + + assert!(!changes.update_config_yaml); + assert_eq!( + changes.oauth_model_aliases, + Some(serde_json::json!({ + "codex": [{ + "name": "gpt-5.6-sol", + "alias": "claude-opus-5", + "fork": true, + }] + })) + ); + + let mixed = oauth_only.replace( + "openai-compatibility: []", + "openai-compatibility:\n - name: CPA\n base-url: https://example.com/v1", + ); + let changes = management_alias_config_changes(current, &mixed).unwrap(); + assert!(changes.update_config_yaml); + assert!(changes.oauth_model_aliases.is_some()); + + let changes = management_alias_config_changes(oauth_only, current).unwrap(); + assert!(!changes.update_config_yaml); + assert_eq!(changes.oauth_model_aliases, Some(serde_json::json!({}))); +} + #[test] fn claude_managed_aliases_expand_flow_yaml_and_are_removed_selectively() { let compact = "# keep this comment\ncodex-api-key: [{api-key: test, models: [{name: gpt-5.6-sol, alias: ''}]}]\ncredential-concurrency: {max-limit: 1000000, cleanup-interval: 5s}\n";