mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 02:14:43 +08:00
fix(usage): resolve per-app credentials for native balance/coding-plan queries (#3355)
* 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.
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user