diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index fd0a4d03f6..8d6c289db6 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -8,7 +8,6 @@ mod live; mod usage; use indexmap::IndexMap; -use regex::Regex; use serde::Deserialize; use serde_json::Value; @@ -927,6 +926,168 @@ base_url = "http://localhost:8080" ); }); } + + #[test] + fn extract_provider_base_url_prefers_active_codex_model_provider_section() { + let provider = Provider::with_id( + "codex".into(), + "Codex".into(), + json!({ + "config": r#"model_provider = "azure" +base_url = "https://top-level.example/v1" + +[model_providers.azure] +base_url = "https://azure.example/v1" + +[model_providers.openai] +base_url = "https://openai.example/v1" + +[mcp_servers.local] +base_url = "http://localhost:8080" +"# + }), + None, + ); + + let base_url = extract_provider_base_url(&provider, &AppType::Codex); + + assert_eq!(base_url.as_deref(), Some("https://azure.example/v1")); + } + + #[test] + fn extract_provider_base_url_falls_back_to_top_level_codex_base_url() { + let provider = Provider::with_id( + "codex".into(), + "Codex".into(), + json!({ + "config": r#"model = "gpt-5" +base_url = "https://top-level.example/v1" + +[mcp_servers.local] +base_url = "http://localhost:8080" +"# + }), + None, + ); + + let base_url = extract_provider_base_url(&provider, &AppType::Codex); + + assert_eq!(base_url.as_deref(), Some("https://top-level.example/v1")); + } +} + +pub(super) fn non_empty_trimmed(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(ToOwned::to_owned) +} + +pub(super) fn extract_codex_toml_base_url(config_toml: &str) -> Option { + let doc = config_toml.parse::().ok()?; + let model_provider = doc + .get("model_provider") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|v| !v.is_empty()); + + if let Some(provider_key) = model_provider { + if let Some(base_url) = non_empty_trimmed( + doc.get("model_providers") + .and_then(|v| v.get(provider_key)) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()), + ) { + return Some(base_url); + } + } + + non_empty_trimmed(doc.get("base_url").and_then(|v| v.as_str())) +} + +pub(super) fn extract_provider_api_key(provider: &Provider, app_type: &AppType) -> Option { + let settings = &provider.settings_config; + + let direct = match app_type { + AppType::Codex => non_empty_trimmed( + settings + .get("auth") + .and_then(|v| v.get("OPENAI_API_KEY")) + .and_then(|v| v.as_str()), + ), + _ => None, + }; + + direct + .or_else(|| { + non_empty_trimmed( + settings + .get("apiKey") + .or_else(|| settings.get("api_key")) + .and_then(|v| v.as_str()) + .or_else(|| { + settings + .get("options") + .and_then(|v| v.get("apiKey").or_else(|| v.get("api_key"))) + .and_then(|v| v.as_str()) + }), + ) + }) + .or_else(|| { + let env = settings.get("env")?; + non_empty_trimmed( + env.get("ANTHROPIC_AUTH_TOKEN") + .or_else(|| env.get("ANTHROPIC_API_KEY")) + .or_else(|| env.get("OPENAI_API_KEY")) + .or_else(|| env.get("OPENROUTER_API_KEY")) + .or_else(|| env.get("GOOGLE_API_KEY")) + .or_else(|| env.get("GEMINI_API_KEY")) + .or_else(|| env.get("CODEX_API_KEY")) + .and_then(|v| v.as_str()), + ) + }) +} + +pub(super) fn extract_provider_base_url(provider: &Provider, app_type: &AppType) -> Option { + let settings = &provider.settings_config; + + let direct = match app_type { + AppType::Codex => settings + .get("config") + .and_then(|v| v.as_str()) + .and_then(extract_codex_toml_base_url), + _ => None, + }; + + direct + .or_else(|| { + non_empty_trimmed( + settings + .get("baseUrl") + .or_else(|| settings.get("baseURL")) + .or_else(|| settings.get("base_url")) + .and_then(|v| v.as_str()) + .or_else(|| { + settings + .get("options") + .and_then(|v| { + v.get("baseURL") + .or_else(|| v.get("baseUrl")) + .or_else(|| v.get("base_url")) + }) + .and_then(|v| v.as_str()) + }), + ) + }) + .or_else(|| { + let env = settings.get("env")?; + non_empty_trimmed( + env.get("ANTHROPIC_BASE_URL") + .or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) + .or_else(|| env.get("OPENAI_BASE_URL")) + .and_then(|v| v.as_str()), + ) + }) } impl ProviderService { @@ -2096,110 +2257,45 @@ impl ProviderService { ) -> Result<(String, String), AppError> { match app_type { AppType::Claude => { - let env = provider - .settings_config - .get("env") - .and_then(|v| v.as_object()) - .ok_or_else(|| { - AppError::localized( - "provider.claude.env.missing", - "配置格式错误: 缺少 env", - "Invalid configuration: missing env section", - ) - })?; - - let api_key = env - .get("ANTHROPIC_AUTH_TOKEN") - .or_else(|| env.get("ANTHROPIC_API_KEY")) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.claude.api_key.missing", - "缺少 API Key", - "API key is missing", - ) - })? - .to_string(); - - let base_url = env - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.claude.base_url.missing", - "缺少 ANTHROPIC_BASE_URL 配置", - "Missing ANTHROPIC_BASE_URL configuration", - ) - })? - .to_string(); + let api_key = extract_provider_api_key(provider, app_type).ok_or_else(|| { + AppError::localized( + "provider.claude.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })?; + + let base_url = extract_provider_base_url(provider, app_type).ok_or_else(|| { + AppError::localized( + "provider.claude.base_url.missing", + "缺少 ANTHROPIC_BASE_URL 配置", + "Missing ANTHROPIC_BASE_URL configuration", + ) + })?; Ok((api_key, base_url)) } AppType::Codex => { - let auth = provider - .settings_config - .get("auth") - .and_then(|v| v.as_object()) - .ok_or_else(|| { - AppError::localized( - "provider.codex.auth.missing", - "配置格式错误: 缺少 auth", - "Invalid configuration: missing auth section", - ) - })?; - - let api_key = auth - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.codex.api_key.missing", - "缺少 API Key", - "API key is missing", - ) - })? - .to_string(); - - let config_toml = provider - .settings_config - .get("config") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let base_url = if config_toml.contains("base_url") { - let re = Regex::new(r#"base_url\s*=\s*["']([^"']+)["']"#).map_err(|e| { - AppError::localized( - "provider.regex_init_failed", - format!("正则初始化失败: {e}"), - format!("Failed to initialize regex: {e}"), - ) - })?; - re.captures(config_toml) - .and_then(|caps| caps.get(1)) - .map(|m| m.as_str().to_string()) - .ok_or_else(|| { - AppError::localized( - "provider.codex.base_url.invalid", - "config.toml 中 base_url 格式错误", - "base_url in config.toml has invalid format", - ) - })? - } else { - return Err(AppError::localized( + let api_key = extract_provider_api_key(provider, app_type).ok_or_else(|| { + AppError::localized( + "provider.codex.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })?; + + let base_url = extract_provider_base_url(provider, app_type).ok_or_else(|| { + AppError::localized( "provider.codex.base_url.missing", "config.toml 中缺少 base_url 配置", "base_url is missing from config.toml", - )); - }; + ) + })?; Ok((api_key, base_url)) } AppType::Gemini => { - use crate::gemini_config::json_to_env; - - let env_map = json_to_env(&provider.settings_config)?; - - let api_key = env_map.get("GEMINI_API_KEY").cloned().ok_or_else(|| { + let api_key = extract_provider_api_key(provider, app_type).ok_or_else(|| { AppError::localized( "gemini.missing_api_key", "缺少 GEMINI_API_KEY", @@ -2207,68 +2303,34 @@ impl ProviderService { ) })?; - let base_url = env_map - .get("GOOGLE_GEMINI_BASE_URL") - .cloned() + let base_url = extract_provider_base_url(provider, app_type) .unwrap_or_else(|| "https://generativelanguage.googleapis.com".to_string()); Ok((api_key, base_url)) } AppType::OpenCode => { - // OpenCode uses options.apiKey and options.baseURL - let options = provider - .settings_config - .get("options") - .and_then(|v| v.as_object()) - .ok_or_else(|| { - AppError::localized( - "provider.opencode.options.missing", - "配置格式错误: 缺少 options", - "Invalid configuration: missing options section", - ) - })?; - - let api_key = options - .get("apiKey") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.opencode.api_key.missing", - "缺少 API Key", - "API key is missing", - ) - })? - .to_string(); - - let base_url = options - .get("baseURL") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let api_key = extract_provider_api_key(provider, app_type).ok_or_else(|| { + AppError::localized( + "provider.opencode.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })?; + + let base_url = extract_provider_base_url(provider, app_type).unwrap_or_default(); Ok((api_key, base_url)) } AppType::OpenClaw => { - // OpenClaw uses apiKey and baseUrl directly on the object - let api_key = provider - .settings_config - .get("apiKey") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AppError::localized( - "provider.openclaw.api_key.missing", - "缺少 API Key", - "API key is missing", - ) - })? - .to_string(); - - let base_url = provider - .settings_config - .get("baseUrl") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); + let api_key = extract_provider_api_key(provider, app_type).ok_or_else(|| { + AppError::localized( + "provider.openclaw.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + })?; + + let base_url = extract_provider_base_url(provider, app_type).unwrap_or_default(); Ok((api_key, base_url)) } diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 5d567dc7f3..c0122b7715 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -5,6 +5,9 @@ use crate::app_config::AppType; use crate::error::AppError; use crate::provider::{UsageData, UsageResult, UsageScript}; +use crate::services::provider::{ + extract_provider_api_key, extract_provider_base_url, non_empty_trimmed, +}; use crate::settings; use crate::store::AppState; use crate::usage_script; @@ -81,32 +84,21 @@ pub(crate) async fn execute_and_format_usage_result( } } -/// Extract API key from provider configuration -fn extract_api_key_from_provider(provider: &crate::provider::Provider) -> Option { - if let Some(env) = provider.settings_config.get("env") { - // Try multiple possible API key fields - env.get("ANTHROPIC_AUTH_TOKEN") - .or_else(|| env.get("ANTHROPIC_API_KEY")) - .or_else(|| env.get("OPENROUTER_API_KEY")) - .or_else(|| env.get("GOOGLE_API_KEY")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } else { - None - } -} +fn resolve_usage_credentials( + provider: &crate::provider::Provider, + app_type: &AppType, + api_key_override: Option<&str>, + base_url_override: Option<&str>, +) -> (String, String) { + let api_key = non_empty_trimmed(api_key_override) + .or_else(|| extract_provider_api_key(provider, app_type)) + .unwrap_or_default(); -/// Extract base URL from provider configuration -fn extract_base_url_from_provider(provider: &crate::provider::Provider) -> Option { - if let Some(env) = provider.settings_config.get("env") { - // Try multiple possible base URL fields - env.get("ANTHROPIC_BASE_URL") - .or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) - .and_then(|v| v.as_str()) - .map(|s| s.trim_end_matches('/').to_string()) - } else { - None - } + let base_url = non_empty_trimmed(base_url_override) + .or_else(|| extract_provider_base_url(provider, app_type)) + .unwrap_or_default(); + + (api_key, base_url) } /// Query provider usage (using saved script configuration) @@ -144,20 +136,12 @@ pub async fn query_usage( )); } - // Get credentials: prioritize UsageScript values, fallback to provider config - let api_key = usage_script - .api_key - .clone() - .filter(|k| !k.is_empty()) - .or_else(|| extract_api_key_from_provider(provider)) - .unwrap_or_default(); - - let base_url = usage_script - .base_url - .clone() - .filter(|u| !u.is_empty()) - .or_else(|| extract_base_url_from_provider(provider)) - .unwrap_or_default(); + let (api_key, base_url) = resolve_usage_credentials( + provider, + &app_type, + usage_script.api_key.as_deref(), + usage_script.base_url.as_deref(), + ); ( usage_script.code.clone(), @@ -185,9 +169,9 @@ pub async fn query_usage( /// Test usage script (using temporary script content, not saved) #[allow(clippy::too_many_arguments)] pub async fn test_usage_script( - _state: &AppState, - _app_type: AppType, - _provider_id: &str, + state: &AppState, + app_type: AppType, + provider_id: &str, script_code: &str, timeout: u64, api_key: Option<&str>, @@ -196,11 +180,23 @@ pub async fn test_usage_script( user_id: Option<&str>, template_type: Option<&str>, ) -> Result { - // Use provided credential parameters directly for testing + let provider = state + .db + .get_provider_by_id(provider_id, app_type.as_str())? + .ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + let (resolved_api_key, resolved_base_url) = + resolve_usage_credentials(&provider, &app_type, api_key, base_url); + execute_and_format_usage_result( script_code, - api_key.unwrap_or(""), - base_url.unwrap_or(""), + &resolved_api_key, + &resolved_base_url, timeout, access_token, user_id, @@ -226,3 +222,158 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::provider::Provider; + use serde_json::json; + + fn provider_with_settings(settings_config: serde_json::Value) -> Provider { + Provider { + id: "provider-1".to_string(), + name: "provider".to_string(), + settings_config, + website_url: None, + category: None, + created_at: None, + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } + } + + #[test] + fn resolve_usage_credentials_falls_back_when_overrides_are_blank() { + let provider = provider_with_settings(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-provider", + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/" + } + })); + + let (api_key, base_url) = + resolve_usage_credentials(&provider, &AppType::Claude, Some(""), Some(" ")); + + assert_eq!(api_key, "sk-provider"); + assert_eq!(base_url, "https://api.deepseek.com/anthropic/"); + } + + #[test] + fn resolve_usage_credentials_reads_top_level_and_options_values() { + let provider = provider_with_settings(json!({ + "options": { + "apiKey": "sk-options", + "baseURL": "https://api.deepseek.com/v1/" + } + })); + + let (api_key, base_url) = + resolve_usage_credentials(&provider, &AppType::OpenCode, None, None); + + assert_eq!(api_key, "sk-options"); + assert_eq!(base_url, "https://api.deepseek.com/v1/"); + } + + #[test] + fn resolve_usage_credentials_prefers_non_empty_overrides() { + let provider = provider_with_settings(json!({ + "apiKey": "sk-provider", + "baseUrl": "https://provider.example.com/", + "env": { + "ANTHROPIC_AUTH_TOKEN": "sk-env", + "ANTHROPIC_BASE_URL": "https://env.example.com/" + } + })); + + let (api_key, base_url) = resolve_usage_credentials( + &provider, + &AppType::OpenClaw, + Some("sk-override"), + Some("https://override.example.com/"), + ); + + assert_eq!(api_key, "sk-override"); + assert_eq!(base_url, "https://override.example.com/"); + } + + #[test] + fn resolve_usage_credentials_ignores_blank_provider_fields_and_uses_env() { + let provider = provider_with_settings(json!({ + "apiKey": " ", + "api_key": "", + "base_url": "", + "options": { + "api_key": "", + "base_url": " " + }, + "env": { + "OPENAI_API_KEY": "sk-env", + "OPENAI_BASE_URL": "https://env.example.com/v1/" + } + })); + + let (api_key, base_url) = resolve_usage_credentials(&provider, &AppType::Codex, None, None); + + assert_eq!(api_key, "sk-env"); + assert_eq!(base_url, "https://env.example.com/v1/"); + } + + #[test] + fn resolve_usage_credentials_reads_codex_auth_and_config_toml() { + let provider = provider_with_settings(json!({ + "auth": { + "OPENAI_API_KEY": "sk-codex" + }, + "config": "model = \"gpt-5\"\nbase_url = \"https://api.openai.com/v1\"\n" + })); + + let (api_key, base_url) = resolve_usage_credentials(&provider, &AppType::Codex, None, None); + + assert_eq!(api_key, "sk-codex"); + assert_eq!(base_url, "https://api.openai.com/v1"); + } + + #[test] + fn resolve_usage_credentials_prefers_active_codex_model_provider_base_url() { + let provider = provider_with_settings(json!({ + "auth": { + "OPENAI_API_KEY": "sk-codex" + }, + "config": r#"model_provider = "azure" +base_url = "https://top-level.example/v1" + +[model_providers.azure] +base_url = "https://azure.example/v1" + +[model_providers.openai] +base_url = "https://openai.example/v1" + +[mcp_servers.local] +base_url = "http://localhost:8080" +"# + })); + + let (api_key, base_url) = resolve_usage_credentials(&provider, &AppType::Codex, None, None); + + assert_eq!(api_key, "sk-codex"); + assert_eq!(base_url, "https://azure.example/v1"); + } + + #[test] + fn resolve_usage_credentials_supports_legacy_alias_fields() { + let provider = provider_with_settings(json!({ + "api_key": "sk-legacy", + "base_url": "https://legacy.example.com/v1", + })); + + let (api_key, base_url) = + resolve_usage_credentials(&provider, &AppType::OpenClaw, None, None); + + assert_eq!(api_key, "sk-legacy"); + assert_eq!(base_url, "https://legacy.example.com/v1"); + } +}