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
+294
View File
@@ -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())
);
}
}