mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +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:
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user