Files
CC-Switch/src-tauri/src/services/provider/usage.rs
T
Jason 2df2212ceb fix(usage): reject transient transport failures so retry and keep-last-good work
Usage/quota queries frequently showed spurious "query failed" states that
manual refresh could not clear (#3820). Root cause: all transport-level
failures were folded into Ok(success:false), so react-query's retry never
fired and the failure body poisoned the cache as regular data.

Backend:
- balance/coding_plan/subscription services now return Err for send
  failures and body-read failures (read body via bytes() before
  serde_json::from_slice; reqwest's json() wraps read errors as Decode,
  making error-kind checks on it dead code). Auth/4xx/parse errors stay
  Ok(success:false) and surface immediately.
- Script path maps transient AppError keys (request_failed /
  read_response_failed) to Err; Volcengine adds a Transient call variant.
- Command layer skips snapshot persistence, usage-cache-updated emit and
  tray refresh on Err so the cache bridge cannot overwrite retained data.
- Expired-credential retry propagates transient errors instead of
  rewriting them as "OAuth token has expired".

Frontend:
- resolveDisplayUsage generalized to subscription quotas; 5th param is now
  an options object with a `rejected` flag: stale success data retained by
  react-query across rejections is re-anchored to dataUpdatedAt and expires
  through the same 10-minute keep-last-good window instead of being shown
  indefinitely (a total outage used to mask longer than a single 5xx).
- useSubscriptionQuota / useCodexOauthQuota gain keep-last-good with
  per-scope snapshot reset; rejected queries with no displayable value
  synthesize a failure placeholder carrying the real error message so
  footers keep rendering a retry entry point.
- HTTP 429 is classified transient (retry-later) alongside 5xx.
- UsageScriptModal surfaces string rejections via extractErrorMessage.

Tests: 6 backend behavior tests drive real HTTP against a local listener
to pin the Err/Ok channel semantics; frontend suite extends
keepLastGoodUsage coverage (405 passing).
2026-07-07 20:05:29 +08:00

320 lines
10 KiB
Rust

//! Usage script execution
//!
//! Handles executing and formatting usage query results.
use crate::app_config::AppType;
use crate::error::AppError;
use crate::provider::{UsageData, UsageResult, UsageScript};
use crate::settings;
use crate::store::AppState;
use crate::usage_script;
/// Execute usage script and format result (private helper method)
pub(crate) async fn execute_and_format_usage_result(
script_code: &str,
api_key: &str,
base_url: &str,
timeout: u64,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
match usage_script::execute_usage_script(
script_code,
api_key,
base_url,
timeout,
access_token,
user_id,
template_type,
)
.await
{
Ok(data) => {
let usage_list: Vec<UsageData> = if data.is_array() {
serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?
} else {
let single: UsageData = serde_json::from_value(data).map_err(|e| {
AppError::localized(
"usage_script.data_format_error",
format!("数据格式错误: {e}"),
format!("Data format error: {e}"),
)
})?;
vec![single]
};
Ok(UsageResult {
success: true,
data: Some(usage_list),
error: None,
})
}
Err(err) => {
// 瞬时传输失败(send 失败/超时、读体中断)以 Err 传播,让前端 invoke
// reject → react-query retry 并保留上次成功值;按错误 key 判定而非
// 文案匹配。其余脚本/配置/HTTP 业务错误折叠成 success:false 展示文案。
if let AppError::Localized { key, .. } = &err {
if matches!(
*key,
"usage_script.request_failed" | "usage_script.read_response_failed"
) {
return Err(err);
}
}
let lang = settings::get_settings()
.language
.unwrap_or_else(|| "zh".to_string());
let msg = match err {
AppError::Localized { zh, en, .. } => {
if lang == "en" {
en
} else {
zh
}
}
other => other.to_string(),
};
Ok(UsageResult {
success: false,
data: None,
error: Some(msg),
})
}
}
}
/// Resolve `(api_key, base_url)` for the JS-script path: explicit non-empty
/// script values win, otherwise fall back to the provider's stored config via
/// `Provider::resolve_usage_credentials` — the same per-app resolver the
/// native balance/coding-plan path and the frontend `getProviderCredentials`
/// use, so `{{apiKey}}`/`{{baseUrl}}` match what the UI shows for them.
fn resolve_script_credentials(
app_type: &AppType,
provider: &crate::provider::Provider,
api_key: Option<&str>,
base_url: Option<&str>,
) -> (String, String) {
let (provider_base_url, provider_api_key) = provider.resolve_usage_credentials(app_type);
let api_key = api_key
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.unwrap_or(provider_api_key);
let base_url = base_url
.map(str::trim)
.filter(|value| !value.is_empty())
// Trim like the provider path so `{{baseUrl}}/path` never doubles the slash.
.map(|value| value.trim_end_matches('/').to_owned())
.unwrap_or(provider_base_url);
(api_key, base_url)
}
/// Query provider usage (using saved script configuration)
pub async fn query_usage(
state: &AppState,
app_type: AppType,
provider_id: &str,
) -> Result<UsageResult, AppError> {
let (script_code, timeout, api_key, base_url, access_token, user_id, template_type) = {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
let usage_script = provider
.meta
.as_ref()
.and_then(|m| m.usage_script.as_ref())
.ok_or_else(|| {
AppError::localized(
"provider.usage.script.missing",
"未配置用量查询脚本",
"Usage script is not configured",
)
})?;
if !usage_script.enabled {
return Err(AppError::localized(
"provider.usage.disabled",
"用量查询未启用",
"Usage query is disabled",
));
}
// Get credentials: prioritize UsageScript values, fallback to provider config
let (api_key, base_url) = resolve_script_credentials(
&app_type,
provider,
usage_script.api_key.as_deref(),
usage_script.base_url.as_deref(),
);
(
usage_script.code.clone(),
usage_script.timeout.unwrap_or(10),
api_key,
base_url,
usage_script.access_token.clone(),
usage_script.user_id.clone(),
usage_script.template_type.clone(),
)
};
execute_and_format_usage_result(
&script_code,
&api_key,
&base_url,
timeout,
access_token.as_deref(),
user_id.as_deref(),
template_type.as_deref(),
)
.await
}
/// Test usage script (using temporary script content, not saved)
#[allow(clippy::too_many_arguments)]
pub async fn test_usage_script(
state: &AppState,
app_type: AppType,
provider_id: &str,
script_code: &str,
timeout: u64,
api_key: Option<&str>,
base_url: Option<&str>,
access_token: Option<&str>,
user_id: Option<&str>,
template_type: Option<&str>,
) -> Result<UsageResult, AppError> {
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers.get(provider_id).ok_or_else(|| {
AppError::localized(
"provider.not_found",
format!("供应商不存在: {provider_id}"),
format!("Provider not found: {provider_id}"),
)
})?;
// Resolve like the real query so testing matches what a saved script does:
// explicit values win, empty ones fall back to the provider config.
let (api_key, base_url) = resolve_script_credentials(&app_type, provider, api_key, base_url);
execute_and_format_usage_result(
script_code,
&api_key,
&base_url,
timeout,
access_token,
user_id,
template_type,
)
.await
}
/// Validate UsageScript configuration (boundary checks)
pub(crate) fn validate_usage_script(script: &UsageScript) -> Result<(), AppError> {
// Validate auto query interval (0-1440 minutes, max 24 hours)
if let Some(interval) = script.auto_query_interval {
if interval > 1440 {
return Err(AppError::localized(
"usage_script.interval_too_large",
format!("自动查询间隔不能超过 1440 分钟(24小时),当前值: {interval}"),
format!(
"Auto query interval cannot exceed 1440 minutes (24 hours), current: {interval}"
),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::resolve_script_credentials;
use crate::app_config::AppType;
use crate::provider::Provider;
use serde_json::json;
fn provider_with_settings(settings_config: serde_json::Value) -> Provider {
Provider::with_id(
"provider-1".to_string(),
"Provider".to_string(),
settings_config,
None,
)
}
#[test]
fn script_values_override_provider_credentials() {
let provider = provider_with_settings(json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "provider-key",
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
}
}));
let (api_key, base_url) = resolve_script_credentials(
&AppType::Claude,
&provider,
Some(" script-key "),
Some(" https://script.example.com/ "),
);
assert_eq!(api_key, "script-key");
assert_eq!(base_url, "https://script.example.com");
}
#[test]
fn empty_script_values_fall_back_to_provider_credentials() {
let provider = provider_with_settings(json!({
"env": {
"ANTHROPIC_AUTH_TOKEN": "provider-key",
"ANTHROPIC_BASE_URL": "https://provider.example.com/"
}
}));
let (api_key, base_url) =
resolve_script_credentials(&AppType::Claude, &provider, Some(""), None);
assert_eq!(api_key, "provider-key");
assert_eq!(base_url, "https://provider.example.com");
}
#[test]
fn codex_fallback_reads_auth_and_config_toml() {
let provider = provider_with_settings(json!({
"auth": {
"OPENAI_API_KEY": "openai-key"
},
"config": r#"model_provider = "azure"
[model_providers.azure]
base_url = "https://azure.example.com/v1/"
[model_providers.other]
base_url = "https://other.example.com/v1"
"#
}));
let (api_key, base_url) =
resolve_script_credentials(&AppType::Codex, &provider, None, None);
assert_eq!(api_key, "openai-key");
assert_eq!(base_url, "https://azure.example.com/v1");
}
}