mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
2df2212ceb
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).
455 lines
16 KiB
Rust
455 lines
16 KiB
Rust
//! 供应商余额查询服务
|
||
//!
|
||
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
|
||
//! 返回 UsageResult 格式,与现有用量系统无缝对接。
|
||
//!
|
||
//! 错误通道语义(与 coding_plan / subscription 两个服务保持一致):
|
||
//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject,
|
||
//! react-query 触发 retry 并保留上一次成功的 data(天然 keep-last-good)。
|
||
//! - `Ok(success:false)` = 确定性失败(空 key/未知供应商/鉴权/非 2xx/响应体非法 JSON),
|
||
//! 立即透出错误文案。判定按 reqwest 错误种类在折叠点完成,不依赖错误文案匹配。
|
||
|
||
use crate::provider::{UsageData, UsageResult};
|
||
use std::time::Duration;
|
||
|
||
// ── 供应商检测 ──────────────────────────────────────────────
|
||
|
||
enum BalanceProvider {
|
||
DeepSeek,
|
||
StepFun,
|
||
SiliconFlow,
|
||
SiliconFlowEn,
|
||
OpenRouter,
|
||
NovitaAI,
|
||
}
|
||
|
||
fn detect_provider(base_url: &str) -> Option<BalanceProvider> {
|
||
let url = base_url.to_lowercase();
|
||
if url.contains("api.deepseek.com") {
|
||
Some(BalanceProvider::DeepSeek)
|
||
} else if url.contains("api.stepfun.ai") || url.contains("api.stepfun.com") {
|
||
Some(BalanceProvider::StepFun)
|
||
} else if url.contains("api.siliconflow.cn") {
|
||
Some(BalanceProvider::SiliconFlow)
|
||
} else if url.contains("api.siliconflow.com") {
|
||
Some(BalanceProvider::SiliconFlowEn)
|
||
} else if url.contains("openrouter.ai") {
|
||
Some(BalanceProvider::OpenRouter)
|
||
} else if url.contains("api.novita.ai") {
|
||
Some(BalanceProvider::NovitaAI)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
fn make_error(msg: String) -> UsageResult {
|
||
UsageResult {
|
||
success: false,
|
||
data: None,
|
||
error: Some(msg),
|
||
}
|
||
}
|
||
|
||
fn make_auth_error(status: reqwest::StatusCode) -> UsageResult {
|
||
UsageResult {
|
||
success: false,
|
||
data: Some(vec![UsageData {
|
||
plan_name: None,
|
||
remaining: None,
|
||
total: None,
|
||
used: None,
|
||
unit: None,
|
||
is_valid: Some(false),
|
||
invalid_message: Some(format!("Authentication failed (HTTP {status})")),
|
||
extra: None,
|
||
}]),
|
||
error: Some(format!("Authentication failed (HTTP {status})")),
|
||
}
|
||
}
|
||
|
||
// ── DeepSeek ────────────────────────────────────────────────
|
||
// GET https://api.deepseek.com/user/balance
|
||
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
|
||
|
||
async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
let resp = client
|
||
.get("https://api.deepseek.com/user/balance")
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.header("Accept", "application/json")
|
||
.timeout(Duration::from_secs(15))
|
||
.send()
|
||
.await;
|
||
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(e) => return Err(format!("Network error: {e}")),
|
||
};
|
||
|
||
let status = resp.status();
|
||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||
return Ok(make_auth_error(status));
|
||
}
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||
}
|
||
|
||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||
let raw = match resp.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||
};
|
||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||
};
|
||
|
||
let is_available = body
|
||
.get("is_available")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(true);
|
||
let mut data = Vec::new();
|
||
|
||
if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) {
|
||
for info in infos {
|
||
let currency = info
|
||
.get("currency")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("CNY");
|
||
let total = parse_f64_field(info, "total_balance");
|
||
|
||
data.push(UsageData {
|
||
plan_name: Some(currency.to_string()),
|
||
remaining: total,
|
||
total: None,
|
||
used: None,
|
||
unit: Some(currency.to_string()),
|
||
is_valid: Some(is_available),
|
||
invalid_message: if !is_available {
|
||
Some("Insufficient balance".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
extra: None,
|
||
});
|
||
}
|
||
}
|
||
|
||
Ok(UsageResult {
|
||
success: true,
|
||
data: if data.is_empty() { None } else { Some(data) },
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
// ── StepFun ─────────────────────────────────────────────────
|
||
// GET https://api.stepfun.com/v1/accounts
|
||
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
||
|
||
async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
let resp = client
|
||
.get("https://api.stepfun.com/v1/accounts")
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.header("Accept", "application/json")
|
||
.timeout(Duration::from_secs(15))
|
||
.send()
|
||
.await;
|
||
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(e) => return Err(format!("Network error: {e}")),
|
||
};
|
||
|
||
let status = resp.status();
|
||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||
return Ok(make_auth_error(status));
|
||
}
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||
}
|
||
|
||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||
let raw = match resp.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||
};
|
||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||
};
|
||
|
||
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
|
||
|
||
Ok(UsageResult {
|
||
success: true,
|
||
data: Some(vec![UsageData {
|
||
plan_name: Some("StepFun".to_string()),
|
||
remaining: Some(balance),
|
||
total: None,
|
||
used: None,
|
||
unit: Some("CNY".to_string()),
|
||
is_valid: Some(true),
|
||
invalid_message: None,
|
||
extra: None,
|
||
}]),
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
// ── SiliconFlow ─────────────────────────────────────────────
|
||
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
|
||
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
||
|
||
async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
let domain = if is_cn {
|
||
"api.siliconflow.cn"
|
||
} else {
|
||
"api.siliconflow.com"
|
||
};
|
||
let url = format!("https://{domain}/v1/user/info");
|
||
|
||
let resp = client
|
||
.get(&url)
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.header("Accept", "application/json")
|
||
.timeout(Duration::from_secs(15))
|
||
.send()
|
||
.await;
|
||
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(e) => return Err(format!("Network error: {e}")),
|
||
};
|
||
|
||
let status = resp.status();
|
||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||
return Ok(make_auth_error(status));
|
||
}
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||
}
|
||
|
||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||
let raw = match resp.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||
};
|
||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||
};
|
||
|
||
let data = match body.get("data") {
|
||
Some(d) => d,
|
||
None => return Ok(make_error("Missing 'data' field in response".to_string())),
|
||
};
|
||
|
||
let total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
|
||
|
||
let unit = if is_cn { "CNY" } else { "USD" };
|
||
let plan_name = if is_cn {
|
||
"SiliconFlow"
|
||
} else {
|
||
"SiliconFlow (EN)"
|
||
};
|
||
|
||
Ok(UsageResult {
|
||
success: true,
|
||
data: Some(vec![UsageData {
|
||
plan_name: Some(plan_name.to_string()),
|
||
remaining: Some(total_balance),
|
||
total: None,
|
||
used: None,
|
||
unit: Some(unit.to_string()),
|
||
is_valid: Some(true),
|
||
invalid_message: None,
|
||
extra: None,
|
||
}]),
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
// ── OpenRouter ──────────────────────────────────────────────
|
||
// GET https://openrouter.ai/api/v1/credits
|
||
// Response: { data: { total_credits, total_usage } }
|
||
|
||
async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
let resp = client
|
||
.get("https://openrouter.ai/api/v1/credits")
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.header("Accept", "application/json")
|
||
.timeout(Duration::from_secs(15))
|
||
.send()
|
||
.await;
|
||
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(e) => return Err(format!("Network error: {e}")),
|
||
};
|
||
|
||
let status = resp.status();
|
||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||
return Ok(make_auth_error(status));
|
||
}
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||
}
|
||
|
||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||
let raw = match resp.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||
};
|
||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||
};
|
||
|
||
let data = body.get("data").unwrap_or(&body);
|
||
let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0);
|
||
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
|
||
let remaining = total_credits - total_usage;
|
||
|
||
Ok(UsageResult {
|
||
success: true,
|
||
data: Some(vec![UsageData {
|
||
plan_name: Some("OpenRouter".to_string()),
|
||
remaining: Some(remaining),
|
||
total: Some(total_credits),
|
||
used: Some(total_usage),
|
||
unit: Some("USD".to_string()),
|
||
is_valid: Some(remaining > 0.0),
|
||
invalid_message: if remaining <= 0.0 {
|
||
Some("No credits remaining".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
extra: None,
|
||
}]),
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
// ── Novita AI ───────────────────────────────────────────────
|
||
// GET https://api.novita.ai/v3/user/balance
|
||
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
|
||
// 金额单位:0.0001 USD
|
||
|
||
async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
|
||
let client = crate::proxy::http_client::get();
|
||
|
||
let resp = client
|
||
.get("https://api.novita.ai/v3/user/balance")
|
||
.header("Authorization", format!("Bearer {api_key}"))
|
||
.header("Accept", "application/json")
|
||
.timeout(Duration::from_secs(15))
|
||
.send()
|
||
.await;
|
||
|
||
let resp = match resp {
|
||
Ok(r) => r,
|
||
Err(e) => return Err(format!("Network error: {e}")),
|
||
};
|
||
|
||
let status = resp.status();
|
||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||
return Ok(make_auth_error(status));
|
||
}
|
||
if !status.is_success() {
|
||
let body = resp.text().await.unwrap_or_default();
|
||
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||
}
|
||
|
||
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||
let raw = match resp.bytes().await {
|
||
Ok(b) => b,
|
||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||
};
|
||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||
};
|
||
|
||
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
|
||
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
|
||
|
||
Ok(UsageResult {
|
||
success: true,
|
||
data: Some(vec![UsageData {
|
||
plan_name: Some("Novita AI".to_string()),
|
||
remaining: Some(available),
|
||
total: None,
|
||
used: None,
|
||
unit: Some("USD".to_string()),
|
||
is_valid: Some(available > 0.0),
|
||
invalid_message: if available <= 0.0 {
|
||
Some("No balance remaining".to_string())
|
||
} else {
|
||
None
|
||
},
|
||
extra: None,
|
||
}]),
|
||
error: None,
|
||
})
|
||
}
|
||
|
||
// ── 工具函数 ────────────────────────────────────────────────
|
||
|
||
/// 解析 JSON 字段为 f64,兼容数字和字符串格式
|
||
fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
|
||
obj.get(field).and_then(|v| {
|
||
v.as_f64()
|
||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||
})
|
||
}
|
||
|
||
// ── 公开入口 ────────────────────────────────────────────────
|
||
|
||
/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值),
|
||
/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。
|
||
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||
if api_key.trim().is_empty() {
|
||
return Ok(UsageResult {
|
||
success: false,
|
||
data: None,
|
||
error: Some("API key is empty".to_string()),
|
||
});
|
||
}
|
||
|
||
let provider = match detect_provider(base_url) {
|
||
Some(p) => p,
|
||
None => {
|
||
return Ok(UsageResult {
|
||
success: false,
|
||
data: None,
|
||
error: Some("Unknown balance provider".to_string()),
|
||
})
|
||
}
|
||
};
|
||
|
||
match provider {
|
||
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
|
||
BalanceProvider::StepFun => query_stepfun(api_key).await,
|
||
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
|
||
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
|
||
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
|
||
BalanceProvider::NovitaAI => query_novita(api_key).await,
|
||
}
|
||
}
|