mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user