From 9ea303b22451ac63cfbe29d846bc53c651b579e4 Mon Sep 17 00:00:00 2001 From: pa001024 Date: Wed, 10 Jun 2026 22:57:27 +0800 Subject: [PATCH] fix: usage script provider credential resolution (#1479) The JS-script usage path resolved {{apiKey}}/{{baseUrl}} with env-only field guessing, so apps that store credentials elsewhere (Codex: auth.OPENAI_API_KEY + config.toml base_url) always got empty values and custom-template queries failed despite a fully configured provider. - query_usage / test_usage_script now delegate to Provider::resolve_usage_credentials, the same per-app resolver used by the native balance/coding-plan path and mirrored by the frontend getProviderCredentials; explicit non-empty script values still win - test_usage_script loads the provider and applies the same fallback, so testing matches what a saved script does - the custom-template variable preview shows the effective values (script overrides first, then provider config) instead of always showing provider credentials - extract_codex_base_url documents and test-locks the frontend-mirror invariant: non-active [model_providers.*] sections are never read Reworked from the original patch to reuse the existing resolver instead of duplicating per-app extraction. Co-authored-by: Jason --- src-tauri/src/codex_config.rs | 49 ++++++- src-tauri/src/provider.rs | 10 +- src-tauri/src/services/provider/usage.rs | 167 +++++++++++++++++------ src/components/UsageScriptModal.tsx | 23 ++-- 4 files changed, 189 insertions(+), 60 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index ac9b43bf1..965d5848a 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -208,7 +208,10 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) -> /// Extract the upstream base URL from a Codex `config.toml` string. /// /// Prefers the active `[model_providers.].base_url`, falling -/// back to a top-level `base_url` when no model provider is selected. +/// back to a top-level `base_url`. Deliberately never reads a non-active +/// `[model_providers.*]` section — the frontend `extractCodexBaseUrl` +/// (`getRecoverableBaseUrlAssignments`) excludes those too, and a leftover +/// section unrelated to the active provider must not leak into `{{baseUrl}}`. pub fn extract_codex_base_url(config_text: &str) -> Option { let doc = config_text.parse::().ok()?; @@ -1254,6 +1257,50 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn extract_base_url_prefers_active_provider_section() { + let input = r#"model_provider = "azure" + +[model_providers.azure] +base_url = "https://azure.example.com/v1" + +[model_providers.other] +base_url = "https://other.example.com/v1" +"#; + + assert_eq!( + extract_codex_base_url(input).as_deref(), + Some("https://azure.example.com/v1") + ); + } + + #[test] + fn extract_base_url_falls_back_to_top_level_only() { + let top_level = r#"base_url = "https://top-level.example.com/v1""#; + assert_eq!( + extract_codex_base_url(top_level).as_deref(), + Some("https://top-level.example.com/v1") + ); + } + + // Mirrors the frontend extractCodexBaseUrl: a non-active provider section + // is never a credential source, whether the active provider points + // elsewhere (e.g. the built-in "openai") or none is selected at all. + #[test] + fn extract_base_url_ignores_non_active_provider_sections() { + let mismatched = r#"model_provider = "openai" + +[model_providers.custom] +base_url = "https://leftover.example.com/v1" +"#; + assert_eq!(extract_codex_base_url(mismatched), None); + + let no_active = r#"[model_providers.any] +base_url = "https://single.example.com/v1" +"#; + assert_eq!(extract_codex_base_url(no_active), None); + } + #[test] fn prepare_provider_live_config_rejects_key_without_config() { let err = prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), "") diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index c4fdc1458..160ae8be9 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -108,17 +108,13 @@ impl Provider { .unwrap_or(false) } - /// Resolve `(base_url, api_key)` for native usage queries (balance / - /// coding-plan) from the stored provider config. + /// Resolve `(base_url, api_key)` for usage queries (native balance / + /// coding-plan and the JS-script `{{apiKey}}`/`{{baseUrl}}` fallback) + /// from the stored provider config. /// /// Each app persists credentials in a different shape, so callers must pass /// the owning app type. This mirrors the frontend `getProviderCredentials` /// in `UsageScriptModal.tsx`. - /// - /// TODO: the env-only helpers in `services/provider/usage.rs` - /// (`extract_api_key_from_provider` / `extract_base_url_from_provider`) - /// duplicate this per-app logic on the JS-script path and could delegate - /// here in a follow-up to remove the remaining copy. pub fn resolve_usage_credentials( &self, app_type: &crate::app_config::AppType, diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 5d567dc7f..7388249b8 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -81,32 +81,33 @@ 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 - } -} +/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty +/// script values win, otherwise fall back to the provider's stored config via +/// `Provider::resolve_usage_credentials` — the same per-app resolver the +/// native balance/coding-plan path and the frontend `getProviderCredentials` +/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them. +fn resolve_script_credentials( + app_type: &AppType, + provider: &crate::provider::Provider, + api_key: Option<&str>, + base_url: Option<&str>, +) -> (String, String) { + let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type); -/// 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 api_key = api_key + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or(provider_api_key); + + let base_url = base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + // Trim like the provider path so `{{baseUrl}}/path` never doubles the slash. + .map(|value| value.trim_end_matches('/').to_owned()) + .unwrap_or(provider_base_url); + + (api_key, base_url) } /// Query provider usage (using saved script configuration) @@ -145,19 +146,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_script_credentials( + &app_type, + provider, + usage_script.api_key.as_deref(), + usage_script.base_url.as_deref(), + ); ( usage_script.code.clone(), @@ -185,9 +179,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 +190,23 @@ pub async fn test_usage_script( user_id: Option<&str>, template_type: Option<&str>, ) -> Result { - // Use provided credential parameters directly for testing + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers.get(provider_id).ok_or_else(|| { + AppError::localized( + "provider.not_found", + format!("供应商不存在: {provider_id}"), + format!("Provider not found: {provider_id}"), + ) + })?; + + // Resolve like the real query so testing matches what a saved script does: + // explicit values win, empty ones fall back to the provider config. + let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url); + execute_and_format_usage_result( script_code, - api_key.unwrap_or(""), - base_url.unwrap_or(""), + &api_key, + &base_url, timeout, access_token, user_id, @@ -226,3 +232,76 @@ pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError Ok(()) } + +#[cfg(test)] +mod tests { + use super::resolve_script_credentials; + use crate::app_config::AppType; + use crate::provider::Provider; + use serde_json::json; + + fn provider_with_settings(settings_config: serde_json::Value) -> Provider { + Provider::with_id( + "provider-1".to_string(), + "Provider".to_string(), + settings_config, + None, + ) + } + + #[test] + fn script_values_override_provider_credentials() { + let provider = provider_with_settings(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "provider-key", + "ANTHROPIC_BASE_URL": "https://provider.example.com/" + } + })); + + let (api_key, base_url) = resolve_script_credentials( + &AppType::Claude, + &provider, + Some(" script-key "), + Some(" https://script.example.com/ "), + ); + assert_eq!(api_key, "script-key"); + assert_eq!(base_url, "https://script.example.com"); + } + + #[test] + fn empty_script_values_fall_back_to_provider_credentials() { + let provider = provider_with_settings(json!({ + "env": { + "ANTHROPIC_AUTH_TOKEN": "provider-key", + "ANTHROPIC_BASE_URL": "https://provider.example.com/" + } + })); + + let (api_key, base_url) = + resolve_script_credentials(&AppType::Claude, &provider, Some(""), None); + assert_eq!(api_key, "provider-key"); + assert_eq!(base_url, "https://provider.example.com"); + } + + #[test] + fn codex_fallback_reads_auth_and_config_toml() { + let provider = provider_with_settings(json!({ + "auth": { + "OPENAI_API_KEY": "openai-key" + }, + "config": r#"model_provider = "azure" + +[model_providers.azure] +base_url = "https://azure.example.com/v1/" + +[model_providers.other] +base_url = "https://other.example.com/v1" +"# + })); + + let (api_key, base_url) = + resolve_script_credentials(&AppType::Codex, &provider, None, None); + assert_eq!(api_key, "openai-key"); + assert_eq!(base_url, "https://azure.example.com/v1"); + } +} diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c5be05590..e05e1c733 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -331,6 +331,14 @@ const UsageScriptModal: React.FC = ({ const [testing, setTesting] = useState(false); + // {{apiKey}}/{{baseUrl}} 实际注入值,镜像后端 resolve_script_credentials 的 + // 优先级:脚本配置中的显式非空值优先(旧配置可能携带),否则回退供应商凭据。 + const effectiveScriptCredentials = { + apiKey: script.apiKey?.trim() || providerCredentials.apiKey, + baseUrl: + script.baseUrl?.trim().replace(/\/+$/, "") || providerCredentials.baseUrl, + }; + // 🔧 失焦时的验证(严格)- 仅确保有效整数 const validateTimeout = (value: string): number => { const num = Number(value); @@ -678,13 +686,12 @@ const UsageScriptModal: React.FC = ({ const preset = PRESET_TEMPLATES[presetName]; if (preset !== undefined) { if (presetName === TEMPLATE_TYPES.CUSTOM) { - // 🔧 自定义模式:用户应该在脚本中直接写完整 URL 和凭证,而不是依赖变量替换 - // 这样可以避免同源检查导致的问题 - // 如果用户想使用变量,需要手动在配置中设置 baseUrl/apiKey + // 自定义模板没有凭证输入框:清空显式覆盖值后,测试与真实查询 + // 都会在后端回退到供应商配置(Provider::resolve_usage_credentials), + // 与下方“支持的变量”区域展示的 {{apiKey}}/{{baseUrl}} 取值一致。 setScript({ ...script, code: preset, - // 清除凭证,用户可选择手动输入或保持空 apiKey: undefined, baseUrl: undefined, accessToken: undefined, @@ -886,9 +893,9 @@ const UsageScriptModal: React.FC = ({ {"{{baseUrl}}"} = - {providerCredentials.baseUrl ? ( + {effectiveScriptCredentials.baseUrl ? ( - {providerCredentials.baseUrl} + {effectiveScriptCredentials.baseUrl} ) : ( @@ -903,11 +910,11 @@ const UsageScriptModal: React.FC = ({ {"{{apiKey}}"} = - {providerCredentials.apiKey ? ( + {effectiveScriptCredentials.apiKey ? ( <> {showApiKey ? ( - {providerCredentials.apiKey} + {effectiveScriptCredentials.apiKey} ) : (