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:
Siskon
2026-06-01 08:43:40 +08:00
committed by GitHub
parent 0960fd7179
commit afa09e127e
5 changed files with 439 additions and 98 deletions
+47 -35
View File
@@ -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());
}
}