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
+23
View File
@@ -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.<model_provider>].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<String> {
let doc = config_text.parse::<toml::Value>().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;
+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());
}
}
+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())
);
}
}
+3 -16
View File
@@ -392,22 +392,9 @@ fn extract_codex_model_from_toml(config_text: &str) -> Option<String> {
}
fn extract_codex_base_url_from_toml(config_text: &str) -> Option<String> {
let doc = config_text.parse::<TomlValue>().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 {
+72 -47
View File
@@ -161,55 +161,80 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
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();