From afa09e127e3fc655b3a93bfe2b2af2c146ab4745 Mon Sep 17 00:00:00 2001 From: Siskon Date: Mon, 1 Jun 2026 08:43:40 +0800 Subject: [PATCH] fix(usage): resolve per-app credentials for native balance/coding-plan queries (#3355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(usage): resolve per-app credentials for native balance/coding-plan queries The native usage-query paths (balance + coding_plan) in `query_provider_usage_inner` read credentials only from `env.ANTHROPIC_*`. That matches Claude providers, but Codex stores its key in `auth.OPENAI_API_KEY` with the base URL inside a TOML `config` string, Hermes/OpenClaw flatten them at the top level, and OpenCode nests them under `options`. So the card "refresh usage" / auto-query returned empty credentials and failed ("查询失败") for those apps, even though the config-page "Test" button worked (the frontend extracts per-app correctly). Introduce a single per-app resolver `Provider::resolve_usage_credentials` that mirrors the frontend `getProviderCredentials`, and route both native branches through it. Add `extract_codex_base_url` to `codex_config` as the canonical Codex TOML base-URL parser and make the proxy adapter delegate to it (removing a duplicate copy). Align the frontend `getProviderCredentials` to cover OpenCode and Claude Desktop and to use the same OpenRouter/Google key fallbacks as the backend. Fixes #3158 Fixes #3100 Fixes #2625 * refactor(usage): explicit AppType arms + frontend trailing-slash trim Address two review nits on the per-app credential resolver: - provider.rs: replace the catch-all `_` arm in resolve_usage_credentials with an explicit `AppType::Claude | AppType::ClaudeDesktop` arm, so a new AppType variant fails to compile here instead of silently defaulting to the Anthropic env shape. - UsageScriptModal.tsx: normalize getProviderCredentials baseUrl through a single trailing-slash trim so the frontend 'Test' path matches the backend resolver (trim_end_matches), keeping front/back truly in lockstep. No behavior change for well-formed configs; tests/typecheck/fmt/clippy clean. * fix(usage): skip empty primary credential fields in fallback chain `obj.get(key)` returns `Some` for a present-but-empty field, so the `.or_else()` fallback chains for the Claude/ClaudeDesktop and Gemini api-key lookups only skipped *absent* keys, not empty ones. Presets seed fields like `ANTHROPIC_AUTH_TOKEN` as present-but-empty placeholders, so a provider whose real key lives in a fallback field (`ANTHROPIC_API_KEY` / `OPENROUTER_API_KEY` / `GOOGLE_API_KEY`, or `GOOGLE_API_KEY` for Gemini) resolved to an empty key on the native balance/coding-plan path — while the frontend `a || b` (which skips empty strings) still found it, reproducing the same Test-works / refresh-fails divergence this PR removes. Add a `first_non_empty` helper that skips present-but-empty values, matching the frontend `||` semantics, and use it for both fallback chains. Tests cover empty primary + populated fallback for Claude and Gemini. --- src-tauri/src/codex_config.rs | 23 ++ src-tauri/src/commands/provider.rs | 82 ++++--- src-tauri/src/provider.rs | 294 +++++++++++++++++++++++++ src-tauri/src/proxy/providers/codex.rs | 19 +- src/components/UsageScriptModal.tsx | 119 ++++++---- 5 files changed, 439 insertions(+), 98 deletions(-) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 4b111daad..5b83daf22 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -205,6 +205,29 @@ pub fn extract_codex_api_key(auth: Option<&Value>, config_text: Option<&str>) -> .or_else(|| config_text.and_then(extract_codex_experimental_bearer_token)) } +/// 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. +pub fn extract_codex_base_url(config_text: &str) -> Option { + let doc = config_text.parse::().ok()?; + + if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) { + if let Some(base_url) = doc + .get("model_providers") + .and_then(|providers| providers.get(active_provider)) + .and_then(|provider| provider.get("base_url")) + .and_then(|v| v.as_str()) + { + return Some(base_url.to_string()); + } + } + + doc.get("base_url") + .and_then(|v| v.as_str()) + .map(ToString::to_string) +} + pub fn codex_auth_has_login_material(auth: &Value) -> bool { let Some(obj) = auth.as_object() else { return false; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index f65ae413f..fcd3ec233 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -409,6 +409,14 @@ pub async fn queryProviderUsage( inner } +/// Resolve `(base_url, api_key)` for native usage queries, delegating to the +/// per-app resolver on `Provider`. Missing provider → empty credentials. +fn resolve_native_credentials(app_type: &AppType, provider: Option<&Provider>) -> (String, String) { + provider + .map(|p| p.resolve_usage_credentials(app_type)) + .unwrap_or_default() +} + async fn query_provider_usage_inner( state: &AppState, copilot_state: &CopilotAuthState, @@ -466,25 +474,10 @@ async fn query_provider_usage_inner( // ── Coding Plan 专用路径 ── if template_type == TEMPLATE_TYPE_TOKEN_PLAN { - // 从供应商配置中提取 API Key 和 Base URL - let settings_config = provider - .map(|p| &p.settings_config) - .cloned() - .unwrap_or_default(); - let env = settings_config.get("env"); - let base_url = env - .and_then(|e| e.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let api_key = env - .and_then(|e| { - e.get("ANTHROPIC_AUTH_TOKEN") - .or_else(|| e.get("ANTHROPIC_API_KEY")) - }) - .and_then(|v| v.as_str()) - .unwrap_or(""); + // 从供应商配置中提取 API Key 和 Base URL(按 app 区分存储格式) + let (base_url, api_key) = resolve_native_credentials(&app_type, provider); - let quota = crate::services::coding_plan::get_coding_plan_quota(base_url, api_key) + let quota = crate::services::coding_plan::get_coding_plan_quota(&base_url, &api_key) .await .map_err(|e| format!("Failed to query coding plan: {e}"))?; @@ -526,24 +519,10 @@ async fn query_provider_usage_inner( // ── 官方余额查询路径 ── if template_type == TEMPLATE_TYPE_BALANCE { - let settings_config = provider - .map(|p| &p.settings_config) - .cloned() - .unwrap_or_default(); - let env = settings_config.get("env"); - let base_url = env - .and_then(|e| e.get("ANTHROPIC_BASE_URL")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let api_key = env - .and_then(|e| { - e.get("ANTHROPIC_AUTH_TOKEN") - .or_else(|| e.get("ANTHROPIC_API_KEY")) - }) - .and_then(|v| v.as_str()) - .unwrap_or(""); + // 按 app 区分的凭据存储格式提取 Base URL 与 API Key + let (base_url, api_key) = resolve_native_credentials(&app_type, provider); - return crate::services::balance::get_balance(base_url, api_key) + return crate::services::balance::get_balance(&base_url, &api_key) .await .map_err(|e| format!("Failed to query balance: {e}")); } @@ -968,3 +947,36 @@ mod import_claude_desktop_tests { ); } } + +#[cfg(test)] +mod native_query_credentials_tests { + use super::resolve_native_credentials; + use crate::app_config::AppType; + use crate::provider::Provider; + use serde_json::json; + + #[test] + fn delegates_to_provider_for_codex() { + let provider = Provider::with_id( + "test".to_string(), + "Test".to_string(), + json!({ + "auth": { "OPENAI_API_KEY": "sk-codex" }, + "config": "model_provider = \"deepseek\"\n\ + [model_providers.deepseek]\n\ + base_url = \"https://api.deepseek.com\"\n", + }), + None, + ); + let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, Some(&provider)); + assert_eq!(base_url, "https://api.deepseek.com"); + assert_eq!(api_key, "sk-codex"); + } + + #[test] + fn missing_provider_yields_empty() { + let (base_url, api_key) = resolve_native_credentials(&AppType::Codex, None); + assert!(base_url.is_empty()); + assert!(api_key.is_empty()); + } +} diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 510ae629d..c4fdc1458 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -107,6 +107,108 @@ impl Provider { .map(|s| s.enabled) .unwrap_or(false) } + + /// Resolve `(base_url, api_key)` for native usage queries (balance / + /// coding-plan) 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, + ) -> (String, String) { + use crate::app_config::AppType; + + let settings = &self.settings_config; + let str_at = + |value: Option<&Value>| value.and_then(|v| v.as_str()).unwrap_or("").to_string(); + + // First present, non-empty string among `keys`, mirroring the frontend's + // `a || b || c` — JS `||` skips empty strings, and presets seed fields like + // `ANTHROPIC_AUTH_TOKEN` as present-but-empty placeholders, so a plain + // `.get().or_else()` chain (which only skips *absent* keys) would stop short. + fn first_non_empty(env: Option<&Value>, keys: &[&str]) -> String { + let Some(env) = env else { + return String::new(); + }; + for key in keys { + if let Some(s) = env.get(key).and_then(|v| v.as_str()) { + if !s.is_empty() { + return s.to_string(); + } + } + } + String::new() + } + + let (base_url, api_key) = match app_type { + // Codex keeps its key in `auth.OPENAI_API_KEY` and its base URL + // inside a TOML `config` string, not in an `env` map. + AppType::Codex => { + let auth = settings.get("auth"); + let config_text = settings.get("config").and_then(|v| v.as_str()); + let api_key = crate::codex_config::extract_codex_api_key(auth, config_text) + .unwrap_or_default(); + let base_url = config_text + .and_then(crate::codex_config::extract_codex_base_url) + .unwrap_or_default(); + (base_url, api_key) + } + // Gemini uses Google-specific env keys (with a legacy GOOGLE_API_KEY fallback). + AppType::Gemini => { + let env = settings.get("env"); + let base_url = str_at(env.and_then(|e| e.get("GOOGLE_GEMINI_BASE_URL"))); + let api_key = first_non_empty(env, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]); + (base_url, api_key) + } + // Hermes (config.yaml) flattens credentials at the top level, snake_case. + AppType::Hermes => ( + str_at(settings.get("base_url")), + str_at(settings.get("api_key")), + ), + // OpenClaw (openclaw.json) flattens credentials at the top level, camelCase. + AppType::OpenClaw => ( + str_at(settings.get("baseUrl")), + str_at(settings.get("apiKey")), + ), + // OpenCode (OMO) nests credentials under `options` (the SDK options object). + AppType::OpenCode => { + let options = settings.get("options"); + ( + str_at(options.and_then(|o| o.get("baseURL"))), + str_at(options.and_then(|o| o.get("apiKey"))), + ) + } + // Claude and Claude Desktop both use the Anthropic-style env map, keeping + // the OpenRouter/Google key fallbacks the JS-script path relies on. + // Listed explicitly (not `_`) so a new AppType fails to compile here. + AppType::Claude | AppType::ClaudeDesktop => { + let env = settings.get("env"); + let base_url = str_at(env.and_then(|e| e.get("ANTHROPIC_BASE_URL"))); + let api_key = first_non_empty( + env, + &[ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "OPENROUTER_API_KEY", + "GOOGLE_API_KEY", + ], + ); + (base_url, api_key) + } + }; + + // Normalize like the JS-script path (extract_base_url_from_provider) so a + // future delegation from services/provider/usage.rs is behavior-preserving + // and `{{baseUrl}}/path` concatenation never produces a double slash. + (base_url.trim_end_matches('/').to_string(), api_key) + } } /// 供应商管理器 @@ -1150,4 +1252,196 @@ mod tests { assert!(toml.contains("base_url = \"https://example.com/openai\"")); assert!(!toml.contains("https://example.com/openai/v1")); } + + // ── resolve_usage_credentials (per-app credential extraction) ── + + use crate::app_config::AppType; + + fn provider_with(settings_config: serde_json::Value) -> Provider { + Provider::with_id("p".to_string(), "P".to_string(), settings_config, None) + } + + #[test] + fn resolve_credentials_claude_env() { + let p = provider_with(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "sk-claude", + } + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::Claude), + ( + "https://api.deepseek.com/anthropic".to_string(), + "sk-claude".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_claude_openrouter_fallback() { + // OpenRouter-on-Claude keeps its key in OPENROUTER_API_KEY; the superset + // fallback must still find it (regression guard for the per-app refactor). + let p = provider_with(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1", + "OPENROUTER_API_KEY": "sk-or", + } + })); + let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Claude); + assert_eq!(base_url, "https://openrouter.ai/api/v1"); + assert_eq!(api_key, "sk-or"); + } + + #[test] + fn resolve_credentials_codex_auth_and_toml() { + let p = provider_with(json!({ + "auth": { "OPENAI_API_KEY": "sk-codex" }, + "config": "model_provider = \"deepseek\"\n\ + [model_providers.deepseek]\n\ + base_url = \"https://api.deepseek.com\"\n", + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::Codex), + ( + "https://api.deepseek.com".to_string(), + "sk-codex".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_gemini_env_with_google_fallback() { + let p = provider_with(json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com", + "GOOGLE_API_KEY": "g-legacy", + } + })); + let (base_url, api_key) = p.resolve_usage_credentials(&AppType::Gemini); + assert_eq!(base_url, "https://generativelanguage.googleapis.com"); + assert_eq!(api_key, "g-legacy"); + } + + #[test] + fn resolve_credentials_claude_skips_empty_primary_key() { + // Presets seed ANTHROPIC_AUTH_TOKEN as a present-but-empty placeholder. + // The fallback chain must skip empty values (matching the frontend's + // `a || b` semantics), not just absent keys. + let p = provider_with(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://openrouter.ai/api/v1", + "ANTHROPIC_AUTH_TOKEN": "", + "ANTHROPIC_API_KEY": "", + "OPENROUTER_API_KEY": "sk-or", + } + })); + let (_, api_key) = p.resolve_usage_credentials(&AppType::Claude); + assert_eq!(api_key, "sk-or"); + } + + #[test] + fn resolve_credentials_gemini_skips_empty_primary_key() { + let p = provider_with(json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": "https://generativelanguage.googleapis.com", + "GEMINI_API_KEY": "", + "GOOGLE_API_KEY": "g-real", + } + })); + let (_, api_key) = p.resolve_usage_credentials(&AppType::Gemini); + assert_eq!(api_key, "g-real"); + } + + #[test] + fn resolve_credentials_hermes_snake_case() { + let p = provider_with(json!({ + "base_url": "https://api.deepseek.com", + "api_key": "sk-hermes", + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::Hermes), + ( + "https://api.deepseek.com".to_string(), + "sk-hermes".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_openclaw_camel_case() { + let p = provider_with(json!({ + "baseUrl": "https://api.deepseek.com", + "apiKey": "sk-openclaw", + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::OpenClaw), + ( + "https://api.deepseek.com".to_string(), + "sk-openclaw".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_opencode_options() { + // OpenCode (OMO) nests creds under options.{baseURL,apiKey}; useOpencodeFormState + // writes config.options.apiKey, so the stored provider keeps them there. + let p = provider_with(json!({ + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "https://api.deepseek.com/v1", + "apiKey": "sk-opencode", + "setCacheKey": true, + } + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::OpenCode), + ( + "https://api.deepseek.com/v1".to_string(), + "sk-opencode".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_claude_desktop_uses_env() { + // ClaudeDesktop persists the Anthropic env shape (ClaudeDesktopProviderForm + // reads env.ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN), so it resolves via + // the default env branch — it is NOT unsupported. + let p = provider_with(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic", + "ANTHROPIC_AUTH_TOKEN": "sk-desktop", + } + })); + assert_eq!( + p.resolve_usage_credentials(&AppType::ClaudeDesktop), + ( + "https://api.deepseek.com/anthropic".to_string(), + "sk-desktop".to_string() + ) + ); + } + + #[test] + fn resolve_credentials_trims_trailing_slash_on_base_url() { + let p = provider_with(json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/anthropic/", + "ANTHROPIC_AUTH_TOKEN": "sk-claude", + } + })); + let (base_url, _) = p.resolve_usage_credentials(&AppType::Claude); + assert_eq!(base_url, "https://api.deepseek.com/anthropic"); + } + + #[test] + fn resolve_credentials_missing_fields_yield_empty() { + let p = provider_with(json!({})); + assert_eq!( + p.resolve_usage_credentials(&AppType::Claude), + (String::new(), String::new()) + ); + } } diff --git a/src-tauri/src/proxy/providers/codex.rs b/src-tauri/src/proxy/providers/codex.rs index 827d7db8a..35ada150a 100644 --- a/src-tauri/src/proxy/providers/codex.rs +++ b/src-tauri/src/proxy/providers/codex.rs @@ -392,22 +392,9 @@ fn extract_codex_model_from_toml(config_text: &str) -> Option { } fn extract_codex_base_url_from_toml(config_text: &str) -> Option { - let doc = config_text.parse::().ok()?; - - if let Some(active_provider) = doc.get("model_provider").and_then(|v| v.as_str()) { - if let Some(base_url) = doc - .get("model_providers") - .and_then(|providers| providers.get(active_provider)) - .and_then(|provider| provider.get("base_url")) - .and_then(|v| v.as_str()) - { - return Some(base_url.to_string()); - } - } - - doc.get("base_url") - .and_then(|v| v.as_str()) - .map(ToString::to_string) + // Canonical parser lives in codex_config; keep this thin alias so the + // proxy hot path and the usage-credential resolver share one implementation. + crate::codex_config::extract_codex_base_url(config_text) } impl CodexAdapter { diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index 5ba970aea..9047e2c21 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -161,55 +161,80 @@ const UsageScriptModal: React.FC = ({ apiKey: string | undefined; baseUrl: string | undefined; } => { - try { - const config = provider.settingsConfig; - if (!config) return { apiKey: undefined, baseUrl: undefined }; + const trimTrailingSlash = (url: string | undefined) => + typeof url === "string" ? url.replace(/\/+$/, "") : url; + const raw = ((): { + apiKey: string | undefined; + baseUrl: string | undefined; + } => { + try { + const config = provider.settingsConfig; + if (!config) return { apiKey: undefined, baseUrl: undefined }; - // 处理不同应用的配置格式 - if (appId === "claude") { - // Claude: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } } - const env = (config as any).env || {}; - return { - apiKey: env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY, - baseUrl: env.ANTHROPIC_BASE_URL, - }; - } else if (appId === "codex") { - // Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url } - const auth = (config as any).auth || {}; - const configToml = (config as any).config || ""; - const apiKey = - typeof auth.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY.trim() - ? auth.OPENAI_API_KEY - : extractCodexExperimentalBearerToken(configToml); - return { - apiKey, - baseUrl: extractCodexBaseUrl(configToml), - }; - } else if (appId === "gemini") { - // Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } } - const env = (config as any).env || {}; - return { - apiKey: env.GEMINI_API_KEY, - baseUrl: env.GOOGLE_GEMINI_BASE_URL, - }; - } else if (appId === "hermes") { - // Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml) - return { - apiKey: (config as any).api_key, - baseUrl: (config as any).base_url, - }; - } else if (appId === "openclaw") { - // OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json) - return { - apiKey: (config as any).apiKey, - baseUrl: (config as any).baseUrl, - }; + // 处理不同应用的配置格式 + if (appId === "claude" || appId === "claude-desktop") { + // Claude / Claude Desktop: { env: { ANTHROPIC_AUTH_TOKEN | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL } } + // Key fallbacks mirror the backend resolver (Provider::resolve_usage_credentials). + const env = (config as any).env || {}; + return { + apiKey: + env.ANTHROPIC_AUTH_TOKEN || + env.ANTHROPIC_API_KEY || + env.OPENROUTER_API_KEY || + env.GOOGLE_API_KEY, + baseUrl: env.ANTHROPIC_BASE_URL, + }; + } else if (appId === "codex") { + // Codex: { auth: { OPENAI_API_KEY }, config: TOML string with base_url } + const auth = (config as any).auth || {}; + const configToml = (config as any).config || ""; + const apiKey = + typeof auth.OPENAI_API_KEY === "string" && + auth.OPENAI_API_KEY.trim() + ? auth.OPENAI_API_KEY + : extractCodexExperimentalBearerToken(configToml); + return { + apiKey, + baseUrl: extractCodexBaseUrl(configToml), + }; + } else if (appId === "gemini") { + // Gemini: { env: { GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL } } + // Key fallback mirrors the backend resolver (Provider::resolve_usage_credentials). + const env = (config as any).env || {}; + return { + apiKey: env.GEMINI_API_KEY || env.GOOGLE_API_KEY, + baseUrl: env.GOOGLE_GEMINI_BASE_URL, + }; + } else if (appId === "hermes") { + // Hermes: settingsConfig 顶层扁平(snake_case,对应 config.yaml) + return { + apiKey: (config as any).api_key, + baseUrl: (config as any).base_url, + }; + } else if (appId === "openclaw") { + // OpenClaw: settingsConfig 顶层扁平(camelCase,对应 openclaw.json) + return { + apiKey: (config as any).apiKey, + baseUrl: (config as any).baseUrl, + }; + } else if (appId === "opencode") { + // OpenCode (OMO): 凭据嵌在 options.{baseURL, apiKey}(SDK options 对象) + const options = (config as any).options || {}; + return { + apiKey: options.apiKey, + baseUrl: options.baseURL, + }; + } + return { apiKey: undefined, baseUrl: undefined }; + } catch (error) { + console.error("Failed to extract provider credentials:", error); + return { apiKey: undefined, baseUrl: undefined }; } - return { apiKey: undefined, baseUrl: undefined }; - } catch (error) { - console.error("Failed to extract provider credentials:", error); - return { apiKey: undefined, baseUrl: undefined }; - } + })(); + // Trim the trailing slash to mirror the backend resolver + // (Provider::resolve_usage_credentials), so `{{baseUrl}}/path` never + // produces a double slash regardless of which path runs the query. + return { apiKey: raw.apiKey, baseUrl: trimTrailingSlash(raw.baseUrl) }; }; const providerCredentials = getProviderCredentials();