mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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).
This commit is contained in:
@@ -49,13 +49,14 @@ pub async fn get_codex_oauth_quota(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(query_codex_quota(
|
// 瞬时传输失败以 Err 传播(前端 reject → retry + 保留上次成功值)。
|
||||||
|
query_codex_quota(
|
||||||
&token,
|
&token,
|
||||||
Some(&id),
|
Some(&id),
|
||||||
"codex_oauth",
|
"codex_oauth",
|
||||||
"Codex OAuth access token expired or rejected. Please re-login via cc-switch.",
|
"Codex OAuth access token expired or rejected. Please re-login via cc-switch.",
|
||||||
)
|
)
|
||||||
.await)
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
|
/// 获取 Codex OAuth (ChatGPT Plus/Pro) 可用模型列表
|
||||||
|
|||||||
@@ -381,32 +381,30 @@ pub async fn queryProviderUsage(
|
|||||||
) -> Result<crate::provider::UsageResult, String> {
|
) -> Result<crate::provider::UsageResult, String> {
|
||||||
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
|
||||||
// inner 可能以两种形式失败:
|
// inner 可能以两种形式失败:
|
||||||
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 业务失败(401、脚本报错等)
|
// 1) 返回 Ok(UsageResult { success: false, .. }) —— 确定性失败(401、脚本
|
||||||
// 2) 返回 Err(String) —— RPC/DB/Copilot fetch_usage 等 transport 层失败
|
// 报错、未知供应商等)。写进 UsageCache 并刷新托盘,让
|
||||||
// 两种都要把"失败"写进 UsageCache 并刷新托盘,让 format_script_summary 的
|
// format_script_summary 的 success 守卫生效、suffix 自然消失。
|
||||||
// success 守卫生效、suffix 自然消失,避免旧 success 快照长期滞留。
|
// 2) 返回 Err(String) —— 瞬时传输失败(网络/超时)及 DB/Copilot fetch 等。
|
||||||
// 同时保持原始 Err 返回给前端 React Query 的 onError 回调,不吞错误。
|
// 不写失败快照、不 emit:保留上一份托盘快照,与前端 react-query reject
|
||||||
|
// 保留上次 data 的语义一致;否则失败快照会经 useUsageCacheBridge 盲写
|
||||||
|
// 回 query 缓存,抹掉 reject 本该保留的旧值。
|
||||||
let inner =
|
let inner =
|
||||||
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
query_provider_usage_inner(&state, &copilot_state, app_type.clone(), &providerId).await;
|
||||||
let snapshot = match &inner {
|
if let Ok(snapshot) = &inner {
|
||||||
Ok(r) => r.clone(),
|
let payload = serde_json::json!({
|
||||||
Err(err_msg) => crate::provider::UsageResult {
|
"kind": "script",
|
||||||
success: false,
|
"appType": app_type.as_str(),
|
||||||
data: None,
|
"providerId": &providerId,
|
||||||
error: Some(err_msg.clone()),
|
"data": snapshot,
|
||||||
},
|
});
|
||||||
};
|
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
|
||||||
let payload = serde_json::json!({
|
log::error!("emit usage-cache-updated (script) 失败: {e}");
|
||||||
"kind": "script",
|
}
|
||||||
"appType": app_type.as_str(),
|
state
|
||||||
"providerId": &providerId,
|
.usage_cache
|
||||||
"data": &snapshot,
|
.put_script(app_type, providerId, snapshot.clone());
|
||||||
});
|
crate::tray::schedule_tray_refresh(&app_handle);
|
||||||
if let Err(e) = app_handle.emit("usage-cache-updated", payload) {
|
|
||||||
log::error!("emit usage-cache-updated (script) 失败: {e}");
|
|
||||||
}
|
}
|
||||||
state.usage_cache.put_script(app_type, providerId, snapshot);
|
|
||||||
crate::tray::schedule_tray_refresh(&app_handle);
|
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ use std::str::FromStr;
|
|||||||
use tauri::{Emitter, State};
|
use tauri::{Emitter, State};
|
||||||
|
|
||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::services::subscription::{CredentialStatus, SubscriptionQuota};
|
use crate::services::subscription::SubscriptionQuota;
|
||||||
use crate::store::AppState;
|
use crate::store::AppState;
|
||||||
|
|
||||||
/// 查询官方订阅额度
|
/// 查询官方订阅额度
|
||||||
///
|
///
|
||||||
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
/// 读取 CLI 工具已有的 OAuth 凭据并调用官方 API 获取使用额度。
|
||||||
/// 结果(无论业务失败还是 transport 层 Err)都会写入 `UsageCache`、通知托盘
|
/// `Ok`(成功或确定性失败)写入 `UsageCache`、通知托盘刷新并 emit
|
||||||
/// 刷新,并 emit `usage-cache-updated`,让前端 React Query 与托盘共享同一份
|
/// `usage-cache-updated`,让前端 React Query 与托盘共享同一份最新数据;失败
|
||||||
/// 最新数据。失败快照写入后 `format_subscription_summary` 会通过 `success=false`
|
/// 快照写入后 `format_subscription_summary` 会通过 `success=false` 守卫返回
|
||||||
/// 守卫返回 `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
|
/// `None`,托盘 suffix 自然消失,避免长期滞留旧配额数字。
|
||||||
/// Err 原样向前端返回,React Query 的 onError 不会被吞掉。
|
/// `Err`(瞬时传输失败)不写快照、不 emit:保留上一份托盘快照,与前端
|
||||||
|
/// react-query reject 保留上次 data 的语义一致(emit 失败快照会经
|
||||||
|
/// `useUsageCacheBridge` 盲写回 query 缓存,抹掉本该保留的旧值)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_subscription_quota(
|
pub async fn get_subscription_quota(
|
||||||
app: tauri::AppHandle,
|
app: tauri::AppHandle,
|
||||||
@@ -20,22 +22,21 @@ pub async fn get_subscription_quota(
|
|||||||
tool: String,
|
tool: String,
|
||||||
) -> Result<SubscriptionQuota, String> {
|
) -> Result<SubscriptionQuota, String> {
|
||||||
let inner = crate::services::subscription::get_subscription_quota(&tool).await;
|
let inner = crate::services::subscription::get_subscription_quota(&tool).await;
|
||||||
let snapshot = match &inner {
|
if let Ok(snapshot) = &inner {
|
||||||
Ok(q) => q.clone(),
|
if let Ok(app_type) = AppType::from_str(&tool) {
|
||||||
// transport 层 Err —— 凭据状态不明,用 Valid 表达"凭据没问题,是通信/parse 出错"。
|
let payload = serde_json::json!({
|
||||||
Err(err_msg) => SubscriptionQuota::error(&tool, CredentialStatus::Valid, err_msg.clone()),
|
"kind": "subscription",
|
||||||
};
|
"appType": app_type.as_str(),
|
||||||
if let Ok(app_type) = AppType::from_str(&tool) {
|
"data": snapshot,
|
||||||
let payload = serde_json::json!({
|
});
|
||||||
"kind": "subscription",
|
if let Err(e) = app.emit("usage-cache-updated", payload) {
|
||||||
"appType": app_type.as_str(),
|
log::error!("emit usage-cache-updated (subscription) 失败: {e}");
|
||||||
"data": &snapshot,
|
}
|
||||||
});
|
state
|
||||||
if let Err(e) = app.emit("usage-cache-updated", payload) {
|
.usage_cache
|
||||||
log::error!("emit usage-cache-updated (subscription) 失败: {e}");
|
.put_subscription(app_type, snapshot.clone());
|
||||||
|
crate::tray::schedule_tray_refresh(&app);
|
||||||
}
|
}
|
||||||
state.usage_cache.put_subscription(app_type, snapshot);
|
|
||||||
crate::tray::schedule_tray_refresh(&app);
|
|
||||||
}
|
}
|
||||||
inner
|
inner
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
//!
|
//!
|
||||||
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
|
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
|
||||||
//! 返回 UsageResult 格式,与现有用量系统无缝对接。
|
//! 返回 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 crate::provider::{UsageData, UsageResult};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -65,7 +71,7 @@ fn make_auth_error(status: reqwest::StatusCode) -> UsageResult {
|
|||||||
// GET https://api.deepseek.com/user/balance
|
// GET https://api.deepseek.com/user/balance
|
||||||
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
|
// Response: { balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }], is_available }
|
||||||
|
|
||||||
async fn query_deepseek(api_key: &str) -> UsageResult {
|
async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -78,21 +84,27 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return make_auth_error(status);
|
return Ok(make_auth_error(status));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let is_available = body
|
let is_available = body
|
||||||
@@ -126,18 +138,18 @@ async fn query_deepseek(api_key: &str) -> UsageResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UsageResult {
|
Ok(UsageResult {
|
||||||
success: true,
|
success: true,
|
||||||
data: if data.is_empty() { None } else { Some(data) },
|
data: if data.is_empty() { None } else { Some(data) },
|
||||||
error: None,
|
error: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── StepFun ─────────────────────────────────────────────────
|
// ── StepFun ─────────────────────────────────────────────────
|
||||||
// GET https://api.stepfun.com/v1/accounts
|
// GET https://api.stepfun.com/v1/accounts
|
||||||
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
||||||
|
|
||||||
async fn query_stepfun(api_key: &str) -> UsageResult {
|
async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -150,26 +162,32 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return make_auth_error(status);
|
return Ok(make_auth_error(status));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
|
let balance = parse_f64_field(&body, "balance").unwrap_or(0.0);
|
||||||
|
|
||||||
UsageResult {
|
Ok(UsageResult {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(vec![UsageData {
|
data: Some(vec![UsageData {
|
||||||
plan_name: Some("StepFun".to_string()),
|
plan_name: Some("StepFun".to_string()),
|
||||||
@@ -182,14 +200,14 @@ async fn query_stepfun(api_key: &str) -> UsageResult {
|
|||||||
extra: None,
|
extra: None,
|
||||||
}]),
|
}]),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SiliconFlow ─────────────────────────────────────────────
|
// ── SiliconFlow ─────────────────────────────────────────────
|
||||||
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
|
// GET https://api.siliconflow.cn/v1/user/info (or .com for EN)
|
||||||
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
||||||
|
|
||||||
async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let domain = if is_cn {
|
let domain = if is_cn {
|
||||||
@@ -209,26 +227,32 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return make_auth_error(status);
|
return Ok(make_auth_error(status));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let data = match body.get("data") {
|
let data = match body.get("data") {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return make_error("Missing 'data' field in response".to_string()),
|
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 total_balance = parse_f64_field(data, "totalBalance").unwrap_or(0.0);
|
||||||
@@ -240,7 +264,7 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
|||||||
"SiliconFlow (EN)"
|
"SiliconFlow (EN)"
|
||||||
};
|
};
|
||||||
|
|
||||||
UsageResult {
|
Ok(UsageResult {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(vec![UsageData {
|
data: Some(vec![UsageData {
|
||||||
plan_name: Some(plan_name.to_string()),
|
plan_name: Some(plan_name.to_string()),
|
||||||
@@ -253,14 +277,14 @@ async fn query_siliconflow(api_key: &str, is_cn: bool) -> UsageResult {
|
|||||||
extra: None,
|
extra: None,
|
||||||
}]),
|
}]),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── OpenRouter ──────────────────────────────────────────────
|
// ── OpenRouter ──────────────────────────────────────────────
|
||||||
// GET https://openrouter.ai/api/v1/credits
|
// GET https://openrouter.ai/api/v1/credits
|
||||||
// Response: { data: { total_credits, total_usage } }
|
// Response: { data: { total_credits, total_usage } }
|
||||||
|
|
||||||
async fn query_openrouter(api_key: &str) -> UsageResult {
|
async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -273,21 +297,27 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return make_auth_error(status);
|
return Ok(make_auth_error(status));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let data = body.get("data").unwrap_or(&body);
|
let data = body.get("data").unwrap_or(&body);
|
||||||
@@ -295,7 +325,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
|
|||||||
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
|
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
|
||||||
let remaining = total_credits - total_usage;
|
let remaining = total_credits - total_usage;
|
||||||
|
|
||||||
UsageResult {
|
Ok(UsageResult {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(vec![UsageData {
|
data: Some(vec![UsageData {
|
||||||
plan_name: Some("OpenRouter".to_string()),
|
plan_name: Some("OpenRouter".to_string()),
|
||||||
@@ -312,7 +342,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
|
|||||||
extra: None,
|
extra: None,
|
||||||
}]),
|
}]),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Novita AI ───────────────────────────────────────────────
|
// ── Novita AI ───────────────────────────────────────────────
|
||||||
@@ -320,7 +350,7 @@ async fn query_openrouter(api_key: &str) -> UsageResult {
|
|||||||
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
|
// Response: { availableBalance, cashBalance, creditLimit, outstandingInvoices }
|
||||||
// 金额单位:0.0001 USD
|
// 金额单位:0.0001 USD
|
||||||
|
|
||||||
async fn query_novita(api_key: &str) -> UsageResult {
|
async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -333,27 +363,33 @@ async fn query_novita(api_key: &str) -> UsageResult {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return make_auth_error(status);
|
return Ok(make_auth_error(status));
|
||||||
}
|
}
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
|
// Novita 金额单位为 0.0001 USD,需除以 10000 转为 USD
|
||||||
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
|
let available = parse_f64_field(&body, "availableBalance").unwrap_or(0.0) / 10000.0;
|
||||||
|
|
||||||
UsageResult {
|
Ok(UsageResult {
|
||||||
success: true,
|
success: true,
|
||||||
data: Some(vec![UsageData {
|
data: Some(vec![UsageData {
|
||||||
plan_name: Some("Novita AI".to_string()),
|
plan_name: Some("Novita AI".to_string()),
|
||||||
@@ -370,7 +406,7 @@ async fn query_novita(api_key: &str) -> UsageResult {
|
|||||||
extra: None,
|
extra: None,
|
||||||
}]),
|
}]),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 工具函数 ────────────────────────────────────────────────
|
// ── 工具函数 ────────────────────────────────────────────────
|
||||||
@@ -385,6 +421,8 @@ fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
|
|||||||
|
|
||||||
// ── 公开入口 ────────────────────────────────────────────────
|
// ── 公开入口 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 查询余额。瞬时传输失败返回 `Err`(前端 reject → retry + 保留上次成功值),
|
||||||
|
/// 确定性失败返回 `Ok(success:false)`(见模块级文档)。
|
||||||
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||||||
if api_key.trim().is_empty() {
|
if api_key.trim().is_empty() {
|
||||||
return Ok(UsageResult {
|
return Ok(UsageResult {
|
||||||
@@ -405,14 +443,12 @@ pub async fn get_balance(base_url: &str, api_key: &str) -> Result<UsageResult, S
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = match provider {
|
match provider {
|
||||||
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
|
BalanceProvider::DeepSeek => query_deepseek(api_key).await,
|
||||||
BalanceProvider::StepFun => query_stepfun(api_key).await,
|
BalanceProvider::StepFun => query_stepfun(api_key).await,
|
||||||
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
|
BalanceProvider::SiliconFlow => query_siliconflow(api_key, true).await,
|
||||||
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
|
BalanceProvider::SiliconFlowEn => query_siliconflow(api_key, false).await,
|
||||||
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
|
BalanceProvider::OpenRouter => query_openrouter(api_key).await,
|
||||||
BalanceProvider::NovitaAI => query_novita(api_key).await,
|
BalanceProvider::NovitaAI => query_novita(api_key).await,
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ fn make_error(msg: String) -> SubscriptionQuota {
|
|||||||
|
|
||||||
// ── Kimi For Coding ─────────────────────────────────────────
|
// ── Kimi For Coding ─────────────────────────────────────────
|
||||||
|
|
||||||
async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
async fn query_kimi(api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -112,12 +112,12 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota {
|
return Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Expired,
|
credential_status: CredentialStatus::Expired,
|
||||||
credential_message: Some("Invalid API key".to_string()),
|
credential_message: Some("Invalid API key".to_string()),
|
||||||
@@ -126,17 +126,23 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tiers = Vec::new();
|
let mut tiers = Vec::new();
|
||||||
@@ -187,7 +193,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: None,
|
credential_message: None,
|
||||||
@@ -196,7 +202,7 @@ async fn query_kimi(api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||||
@@ -307,7 +313,7 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
async fn query_zhipu(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/api/monitor/usage/quota/limit",
|
"{}/api/monitor/usage/quota/limit",
|
||||||
@@ -325,12 +331,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota {
|
return Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Expired,
|
credential_status: CredentialStatus::Expired,
|
||||||
credential_message: Some("Invalid API key".to_string()),
|
credential_message: Some("Invalid API key".to_string()),
|
||||||
@@ -339,17 +345,23 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查业务级别错误
|
// 检查业务级别错误
|
||||||
@@ -358,12 +370,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
.get("msg")
|
.get("msg")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Unknown error");
|
.unwrap_or("Unknown error");
|
||||||
return make_error(format!("API error: {msg}"));
|
return Ok(make_error(format!("API error: {msg}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = match body.get("data") {
|
let data = match body.get("data") {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return make_error("Missing 'data' field in response".to_string()),
|
None => return Ok(make_error("Missing 'data' field in response".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let tiers = parse_zhipu_token_tiers(data);
|
let tiers = parse_zhipu_token_tiers(data);
|
||||||
@@ -374,7 +386,7 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: level,
|
credential_message: level,
|
||||||
@@ -383,12 +395,12 @@ async fn query_zhipu(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MiniMax ─────────────────────────────────────────────────
|
// ── MiniMax ─────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
async fn query_minimax(api_key: &str, is_cn: bool) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let api_domain = if is_cn {
|
let api_domain = if is_cn {
|
||||||
@@ -408,12 +420,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota {
|
return Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Expired,
|
credential_status: CredentialStatus::Expired,
|
||||||
credential_message: Some("Invalid API key".to_string()),
|
credential_message: Some("Invalid API key".to_string()),
|
||||||
@@ -422,17 +434,23 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查业务级别错误
|
// 检查业务级别错误
|
||||||
@@ -446,14 +464,14 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
|||||||
.get("status_msg")
|
.get("status_msg")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Unknown error");
|
.unwrap_or("Unknown error");
|
||||||
return make_error(format!("API error (code {status_code}): {msg}"));
|
return Ok(make_error(format!("API error (code {status_code}): {msg}")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
|
// 提取纯函数便于无 mock 单元测试;新接口直接给"剩余百分比",反转为已用百分比
|
||||||
let tiers = parse_minimax_tiers(&body);
|
let tiers = parse_minimax_tiers(&body);
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: None,
|
credential_message: None,
|
||||||
@@ -462,12 +480,12 @@ async fn query_minimax(api_key: &str, is_cn: bool) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ZenMux ──────────────────────────────────────────────────
|
// ── ZenMux ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
async fn query_zenmux(base_url: &str, api_key: &str) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -480,12 +498,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return make_error(format!("Network error: {e}")),
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota {
|
return Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Expired,
|
credential_status: CredentialStatus::Expired,
|
||||||
credential_message: Some("Invalid API key".to_string()),
|
credential_message: Some("Invalid API key".to_string()),
|
||||||
@@ -494,17 +512,23 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: Some(format!("Authentication failed (HTTP {status})")),
|
error: Some(format!("Authentication failed (HTTP {status})")),
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return make_error(format!("API error (HTTP {status}): {body}"));
|
return Ok(make_error(format!("API error (HTTP {status}): {body}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 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,
|
Ok(v) => v,
|
||||||
Err(e) => return make_error(format!("Failed to parse response: {e}")),
|
Err(e) => return Ok(make_error(format!("Failed to parse response: {e}"))),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查业务级别错误
|
// 检查业务级别错误
|
||||||
@@ -513,12 +537,12 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
.get("message")
|
.get("message")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or("Unknown error");
|
.unwrap_or("Unknown error");
|
||||||
return make_error(format!("API error: {msg}"));
|
return Ok(make_error(format!("API error: {msg}")));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = match body.get("data") {
|
let data = match body.get("data") {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return make_error("Missing 'data' field in response".to_string()),
|
None => return Ok(make_error("Missing 'data' field in response".to_string())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut tiers = Vec::new();
|
let mut tiers = Vec::new();
|
||||||
@@ -581,7 +605,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
String::new()
|
String::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "coding_plan".to_string(),
|
tool: "coding_plan".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: if plan_info.is_empty() {
|
credential_message: if plan_info.is_empty() {
|
||||||
@@ -594,7 +618,7 @@ async fn query_zenmux(base_url: &str, api_key: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。
|
/// 从 `/coding_plan/remains` 响应中解析 MiniMax 编程套餐的额度 tier。
|
||||||
@@ -690,8 +714,11 @@ enum VolcCall {
|
|||||||
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
|
/// 硬鉴权失败(HTTP 401/403 或 AccessDenied/Signature 等错误码)——两个 plan
|
||||||
/// 共用凭据,命中即停。
|
/// 共用凭据,命中即停。
|
||||||
Auth(String),
|
Auth(String),
|
||||||
/// 网络 / 非鉴权 HTTP 错误 / 解析失败——记录后可继续尝试另一个 plan。
|
/// 非鉴权 HTTP 错误 / 响应体非法 JSON——记录后可继续尝试另一个 plan。
|
||||||
Soft(String),
|
Soft(String),
|
||||||
|
/// 瞬时传输失败(网络/超时/读体中断)——同 host 的另一个 plan 大概率同样
|
||||||
|
/// 失败,调用方应立即以 `Err` 传播(前端 reject → retry + 保留上次成功值)。
|
||||||
|
Transient(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
|
/// 从数据面 base_url 提取控制面 OpenAPI 所需的 Region(如
|
||||||
@@ -889,7 +916,7 @@ async fn volcengine_openapi_call(
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => return VolcCall::Soft(format!("Network error: {e}")),
|
Err(e) => return VolcCall::Transient(format!("Network error: {e}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
@@ -916,7 +943,13 @@ async fn volcengine_openapi_call(
|
|||||||
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
|
return VolcCall::Soft(format!("API error (HTTP {status}): {raw}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 同 Bearer 路径:先 bytes() 再解析——读体失败是瞬时(Transient),解析失败
|
||||||
|
// 是确定性(Soft)。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||||
|
let raw = match resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return VolcCall::Transient(format!("Failed to read response: {e}")),
|
||||||
|
};
|
||||||
|
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
|
Err(e) => return VolcCall::Soft(format!("Failed to parse response: {e}")),
|
||||||
};
|
};
|
||||||
@@ -1058,7 +1091,7 @@ async fn query_volcengine(
|
|||||||
base_url: &str,
|
base_url: &str,
|
||||||
access_key_id: &str,
|
access_key_id: &str,
|
||||||
secret_access_key: &str,
|
secret_access_key: &str,
|
||||||
) -> SubscriptionQuota {
|
) -> Result<SubscriptionQuota, String> {
|
||||||
let region = volcengine_region(base_url);
|
let region = volcengine_region(base_url);
|
||||||
let mut soft_errors: Vec<String> = Vec::new();
|
let mut soft_errors: Vec<String> = Vec::new();
|
||||||
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
|
// 2xx + 无 Error 信封但解析不出额度时,截断原始响应用于诊断(区分"真没订阅"
|
||||||
@@ -1071,7 +1104,8 @@ async fn query_volcengine(
|
|||||||
|
|
||||||
// 1) Agent Plan:GetAFPUsage
|
// 1) Agent Plan:GetAFPUsage
|
||||||
match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await {
|
match volcengine_openapi_call(®ion, access_key_id, secret_access_key, "GetAFPUsage").await {
|
||||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
|
||||||
|
VolcCall::Transient(detail) => return Err(format!("GetAFPUsage: {detail}")),
|
||||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
|
VolcCall::Soft(detail) => soft_errors.push(format!("GetAFPUsage: {detail}")),
|
||||||
VolcCall::Body(body) => {
|
VolcCall::Body(body) => {
|
||||||
let result = body.get("Result").unwrap_or(&body);
|
let result = body.get("Result").unwrap_or(&body);
|
||||||
@@ -1083,7 +1117,7 @@ async fn query_volcengine(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| format!("Agent Plan {s}"));
|
.map(|s| format!("Agent Plan {s}"));
|
||||||
return volcengine_success(tiers, plan);
|
return Ok(volcengine_success(tiers, plan));
|
||||||
}
|
}
|
||||||
empty_responses.push(summarize("GetAFPUsage", &body));
|
empty_responses.push(summarize("GetAFPUsage", &body));
|
||||||
}
|
}
|
||||||
@@ -1098,32 +1132,33 @@ async fn query_volcengine(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
VolcCall::Auth(detail) => return volcengine_auth_error(detail),
|
VolcCall::Auth(detail) => return Ok(volcengine_auth_error(detail)),
|
||||||
|
VolcCall::Transient(detail) => return Err(format!("GetCodingPlanUsage: {detail}")),
|
||||||
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
|
VolcCall::Soft(detail) => soft_errors.push(format!("GetCodingPlanUsage: {detail}")),
|
||||||
VolcCall::Body(body) => {
|
VolcCall::Body(body) => {
|
||||||
let result = body.get("Result").unwrap_or(&body);
|
let result = body.get("Result").unwrap_or(&body);
|
||||||
let tiers = parse_coding_plan_tiers(result);
|
let tiers = parse_coding_plan_tiers(result);
|
||||||
if !tiers.is_empty() {
|
if !tiers.is_empty() {
|
||||||
return volcengine_success(tiers, Some("Coding Plan".to_string()));
|
return Ok(volcengine_success(tiers, Some("Coding Plan".to_string())));
|
||||||
}
|
}
|
||||||
empty_responses.push(summarize("GetCodingPlanUsage", &body));
|
empty_responses.push(summarize("GetCodingPlanUsage", &body));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !soft_errors.is_empty() {
|
if !soft_errors.is_empty() {
|
||||||
make_error(soft_errors.join("; "))
|
Ok(make_error(soft_errors.join("; ")))
|
||||||
} else if !empty_responses.is_empty() {
|
} else if !empty_responses.is_empty() {
|
||||||
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
|
// 签名已通过、请求到达业务层,但响应里没有可解析的额度。带上原始响应,
|
||||||
// 便于核对真实字段名/包裹层,或确认确实未订阅。
|
// 便于核对真实字段名/包裹层,或确认确实未订阅。
|
||||||
make_error(format!(
|
Ok(make_error(format!(
|
||||||
"No active subscription found (signature OK). Raw: {}",
|
"No active subscription found (signature OK). Raw: {}",
|
||||||
empty_responses.join(" || ")
|
empty_responses.join(" || ")
|
||||||
))
|
)))
|
||||||
} else {
|
} else {
|
||||||
make_error(
|
Ok(make_error(
|
||||||
"No active Agent Plan or Coding Plan subscription found for this credential"
|
"No active Agent Plan or Coding Plan subscription found for this credential"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1143,6 +1178,9 @@ fn coding_plan_not_found(error: &str) -> SubscriptionQuota {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询编程套餐额度。瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject →
|
||||||
|
/// retry + 保留上次成功值);确定性失败(凭据缺失/未知域名/鉴权/非 2xx/业务错误)
|
||||||
|
/// 返回 `Ok(success:false)` 立即透出文案。判定按 reqwest 错误种类在折叠点完成。
|
||||||
pub async fn get_coding_plan_quota(
|
pub async fn get_coding_plan_quota(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -1165,7 +1203,7 @@ pub async fn get_coding_plan_quota(
|
|||||||
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
|
"Volcengine usage query needs the account AccessKey ID + Secret (not the inference API key)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return Ok(query_volcengine(base_url, ak, sk).await);
|
return query_volcengine(base_url, ak, sk).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其余供应商:数据面 Bearer api_key。
|
// 其余供应商:数据面 Bearer api_key。
|
||||||
@@ -1174,7 +1212,7 @@ pub async fn get_coding_plan_quota(
|
|||||||
return Ok(coding_plan_not_found("API key is empty"));
|
return Ok(coding_plan_not_found("API key is empty"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let quota = match provider {
|
match provider {
|
||||||
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
CodingPlanProvider::Kimi => query_kimi(api_key).await,
|
||||||
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
CodingPlanProvider::ZhipuCn | CodingPlanProvider::ZhipuEn => {
|
||||||
query_zhipu(base_url, api_key).await
|
query_zhipu(base_url, api_key).await
|
||||||
@@ -1186,9 +1224,7 @@ pub async fn get_coding_plan_quota(
|
|||||||
CodingPlanProvider::Volcengine => {
|
CodingPlanProvider::Volcengine => {
|
||||||
unreachable!("volcengine handled via AK/SK branch above")
|
unreachable!("volcengine handled via AK/SK branch above")
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
Ok(quota)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1806,4 +1842,140 @@ mod tests {
|
|||||||
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
|
let ok_body = json!({ "ResponseMetadata": { "RequestId": "x" }, "Result": {} });
|
||||||
assert!(volcengine_response_error(&ok_body).is_none());
|
assert!(volcengine_response_error(&ok_body).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 传输层错误通道语义:瞬时 → Err(前端 reject/retry),确定性 → Ok(success:false) ──
|
||||||
|
//
|
||||||
|
// 借 ZenMux 分支可指向任意 base_url 的特性,用本地 listener 驱动真实 HTTP
|
||||||
|
// 路径,锁定 send 失败 / 读体中断 / 4xx / 非法 JSON 各自落在哪条通道。
|
||||||
|
// balance / subscription 服务与本文件共用同一折叠模式,这里的用例同时充当
|
||||||
|
// 三个服务的语义回归锚。
|
||||||
|
|
||||||
|
use super::get_coding_plan_quota;
|
||||||
|
use crate::services::subscription::CredentialStatus;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
/// 测试进程内可能有其他用例临时 set_var HTTP_PROXY(http_client 的
|
||||||
|
/// loopback 检测测试),NO_PROXY 保证本地回环请求始终直连。
|
||||||
|
fn ensure_no_proxy_for_loopback() {
|
||||||
|
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||||
|
ONCE.call_once(|| {
|
||||||
|
std::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||||
|
std::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 起一个只服务一次连接的本地 HTTP server。`response=None` 表示读完请求
|
||||||
|
/// 直接断开(模拟响应前连接中断)。返回可命中 ZenMux 分支的 base_url。
|
||||||
|
fn spawn_once_server(response: Option<String>) -> (String, std::thread::JoinHandle<()>) {
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind local listener");
|
||||||
|
let port = listener.local_addr().expect("local addr").port();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
if let Ok((mut stream, _)) = listener.accept() {
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
let _ = stream.read(&mut buf);
|
||||||
|
if let Some(resp) = response {
|
||||||
|
let _ = stream.write_all(resp.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(format!("http://127.0.0.1:{port}/zenmux"), handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn http_response(status_line: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"HTTP/1.1 {status_line}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_connection_refused_returns_err() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
// 绑定后立刻释放端口 → 连接被拒(send 失败)
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||||
|
let port = listener.local_addr().expect("local addr").port();
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
|
let result =
|
||||||
|
get_coding_plan_quota(&format!("http://127.0.0.1:{port}/zenmux"), "k", None, None)
|
||||||
|
.await;
|
||||||
|
let err = result.expect_err("send 失败必须走 Err 通道(瞬时,前端 reject 后重试)");
|
||||||
|
assert!(err.contains("Network error"), "err={err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_connection_closed_before_response_returns_err() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
let (base_url, handle) = spawn_once_server(None);
|
||||||
|
|
||||||
|
let result = get_coding_plan_quota(&base_url, "k", None, None).await;
|
||||||
|
let err = result.expect_err("响应前连接中断必须走 Err 通道(瞬时)");
|
||||||
|
assert!(err.contains("Network error"), "err={err}");
|
||||||
|
handle.join().expect("server thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_truncated_body_returns_err() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
// 声明 content-length: 100 但只写一小段就断开 → 读体中断。
|
||||||
|
// 锁定 bytes() 先于解析:这类失败必须走 Err(瞬时),不能因 reqwest 把
|
||||||
|
// 读体错误包成 decode 而被误判成确定性的 "Failed to parse response"。
|
||||||
|
let (base_url, handle) = spawn_once_server(Some(
|
||||||
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 100\r\n\r\npartial"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let result = get_coding_plan_quota(&base_url, "k", None, None).await;
|
||||||
|
let err = result.expect_err("读体中断必须走 Err 通道(瞬时,前端 reject 后重试)");
|
||||||
|
assert!(err.contains("Failed to read response"), "err={err}");
|
||||||
|
handle.join().expect("server thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deterministic_http_401_stays_ok_with_auth_error() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
let (base_url, handle) = spawn_once_server(Some(http_response("401 Unauthorized", "{}")));
|
||||||
|
|
||||||
|
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||||
|
.await
|
||||||
|
.expect("鉴权失败是确定性失败,必须保持 Ok(success:false) 展示文案");
|
||||||
|
assert!(!quota.success);
|
||||||
|
assert!(matches!(quota.credential_status, CredentialStatus::Expired));
|
||||||
|
let err = quota.error.expect("应有错误文案");
|
||||||
|
assert!(err.contains("Authentication failed (HTTP 401"), "err={err}");
|
||||||
|
handle.join().expect("server thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deterministic_http_429_stays_ok_with_status_in_error() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
let (base_url, handle) =
|
||||||
|
spawn_once_server(Some(http_response("429 Too Many Requests", "slow down")));
|
||||||
|
|
||||||
|
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||||
|
.await
|
||||||
|
.expect("非 2xx 保持 Ok(success:false),状态码留在文案里交前端分类");
|
||||||
|
assert!(!quota.success);
|
||||||
|
// 前端 isTransientUsageError 靠 /http\s+(\d{3})/ 提取状态码把 429 归瞬时,
|
||||||
|
// 文案格式是跨层契约,勿改。
|
||||||
|
let err = quota.error.expect("应有错误文案");
|
||||||
|
assert!(err.contains("HTTP 429"), "err={err}");
|
||||||
|
handle.join().expect("server thread");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deterministic_invalid_json_body_stays_ok_with_parse_error() {
|
||||||
|
ensure_no_proxy_for_loopback();
|
||||||
|
// 完整读到响应体但不是 JSON → is_decode → 确定性解析失败
|
||||||
|
let (base_url, handle) = spawn_once_server(Some(http_response("200 OK", "not-json")));
|
||||||
|
|
||||||
|
let quota = get_coding_plan_quota(&base_url, "k", None, None)
|
||||||
|
.await
|
||||||
|
.expect("完整但非法的响应体是确定性失败,必须保持 Ok(success:false)");
|
||||||
|
assert!(!quota.success);
|
||||||
|
let err = quota.error.expect("应有错误文案");
|
||||||
|
assert!(err.contains("Failed to parse response"), "err={err}");
|
||||||
|
handle.join().expect("server thread");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ pub(crate) async fn execute_and_format_usage_result(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(err) => {
|
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()
|
let lang = settings::get_settings()
|
||||||
.language
|
.language
|
||||||
.unwrap_or_else(|| "zh".to_string());
|
.unwrap_or_else(|| "zh".to_string());
|
||||||
|
|||||||
@@ -331,7 +331,11 @@ const KNOWN_TIERS: &[&str] = &[
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// 查询 Claude 官方订阅额度
|
/// 查询 Claude 官方订阅额度
|
||||||
async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
|
///
|
||||||
|
/// 瞬时传输失败(网络/超时/读体中断)返回 `Err`(前端 reject → retry + 保留上次
|
||||||
|
/// 成功值);确定性失败(鉴权/非 2xx/响应体非法 JSON)返回 `Ok(success:false)`。
|
||||||
|
/// codex/gemini 两个查询函数遵守同一约定。
|
||||||
|
async fn query_claude_quota(access_token: &str) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let resp = client
|
let resp = client
|
||||||
@@ -345,42 +349,42 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let resp = match resp {
|
let resp = match resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
return SubscriptionQuota::error(
|
|
||||||
"claude",
|
|
||||||
CredentialStatus::Valid,
|
|
||||||
format!("Network error: {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"claude",
|
"claude",
|
||||||
CredentialStatus::Expired,
|
CredentialStatus::Expired,
|
||||||
format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."),
|
format!("Authentication failed (HTTP {status}). Please re-login with Claude CLI."),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"claude",
|
"claude",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("API error (HTTP {status}): {body}"),
|
format!("API error (HTTP {status}): {body}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: serde_json::Value = match resp.json().await {
|
// 先 bytes() 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||||
|
// 后解析失败才是确定性。reqwest 的 json() 把读体错误也包成 decode,无法区分。
|
||||||
|
let raw = match resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return Err(format!("Failed to read API response: {e}")),
|
||||||
|
};
|
||||||
|
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"claude",
|
"claude",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("Failed to parse API response: {e}"),
|
format!("Failed to parse API response: {e}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -435,7 +439,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "claude".to_string(),
|
tool: "claude".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: None,
|
credential_message: None,
|
||||||
@@ -444,7 +448,7 @@ async fn query_claude_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
extra_usage,
|
extra_usage,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Codex 凭据读取 ──────────────────────────────────────
|
// ── Codex 凭据读取 ──────────────────────────────────────
|
||||||
@@ -670,7 +674,7 @@ pub(crate) async fn query_codex_quota(
|
|||||||
account_id: Option<&str>,
|
account_id: Option<&str>,
|
||||||
tool_label: &str,
|
tool_label: &str,
|
||||||
expired_message: &str,
|
expired_message: &str,
|
||||||
) -> SubscriptionQuota {
|
) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
let mut req = client
|
let mut req = client
|
||||||
@@ -685,42 +689,40 @@ pub(crate) async fn query_codex_quota(
|
|||||||
|
|
||||||
let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await {
|
let resp = match req.timeout(std::time::Duration::from_secs(15)).send().await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => return Err(format!("Network error: {e}")),
|
||||||
return SubscriptionQuota::error(
|
|
||||||
tool_label,
|
|
||||||
CredentialStatus::Valid,
|
|
||||||
format!("Network error: {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
|
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
tool_label,
|
tool_label,
|
||||||
CredentialStatus::Expired,
|
CredentialStatus::Expired,
|
||||||
format!("{expired_message} (HTTP {status})"),
|
format!("{expired_message} (HTTP {status})"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
tool_label,
|
tool_label,
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("API error (HTTP {status}): {body}"),
|
format!("API error (HTTP {status}): {body}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let body: CodexUsageResponse = match resp.json().await {
|
let raw = match resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return Err(format!("Failed to read API response: {e}")),
|
||||||
|
};
|
||||||
|
let body: CodexUsageResponse = match serde_json::from_slice(&raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
tool_label,
|
tool_label,
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("Failed to parse API response: {e}"),
|
format!("Failed to parse API response: {e}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -746,7 +748,7 @@ pub(crate) async fn query_codex_quota(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: tool_label.to_string(),
|
tool: tool_label.to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: None,
|
credential_message: None,
|
||||||
@@ -755,7 +757,7 @@ pub(crate) async fn query_codex_quota(
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gemini 凭据读取 ──────────────────────────────────────
|
// ── Gemini 凭据读取 ──────────────────────────────────────
|
||||||
@@ -1049,7 +1051,7 @@ fn classify_gemini_model(model_id: &str) -> &str {
|
|||||||
/// 两步 API 调用:
|
/// 两步 API 调用:
|
||||||
/// 1. loadCodeAssist → 获取 cloudaicompanionProject
|
/// 1. loadCodeAssist → 获取 cloudaicompanionProject
|
||||||
/// 2. retrieveUserQuota → 获取按模型分桶的配额数据
|
/// 2. retrieveUserQuota → 获取按模型分桶的配额数据
|
||||||
async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
|
async fn query_gemini_quota(access_token: &str) -> Result<SubscriptionQuota, String> {
|
||||||
let client = crate::proxy::http_client::get();
|
let client = crate::proxy::http_client::get();
|
||||||
|
|
||||||
// ── Step 1: loadCodeAssist 获取项目 ID ──
|
// ── Step 1: loadCodeAssist 获取项目 ID ──
|
||||||
@@ -1069,42 +1071,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let load_resp = match load_resp {
|
let load_resp = match load_resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => return Err(format!("Network error (loadCodeAssist): {e}")),
|
||||||
return SubscriptionQuota::error(
|
|
||||||
"gemini",
|
|
||||||
CredentialStatus::Valid,
|
|
||||||
format!("Network error (loadCodeAssist): {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let load_status = load_resp.status();
|
let load_status = load_resp.status();
|
||||||
if load_status == reqwest::StatusCode::UNAUTHORIZED
|
if load_status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|| load_status == reqwest::StatusCode::FORBIDDEN
|
|| load_status == reqwest::StatusCode::FORBIDDEN
|
||||||
{
|
{
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Expired,
|
CredentialStatus::Expired,
|
||||||
format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."),
|
format!("Authentication failed (HTTP {load_status}). Please re-login with Gemini CLI."),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
if !load_status.is_success() {
|
if !load_status.is_success() {
|
||||||
let body = load_resp.text().await.unwrap_or_default();
|
let body = load_resp.text().await.unwrap_or_default();
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("loadCodeAssist failed (HTTP {load_status}): {body}"),
|
format!("loadCodeAssist failed (HTTP {load_status}): {body}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let load_body: GeminiLoadCodeAssistResponse = match load_resp.json().await {
|
let load_raw = match load_resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return Err(format!("Failed to read loadCodeAssist response: {e}")),
|
||||||
|
};
|
||||||
|
let load_body: GeminiLoadCodeAssistResponse = match serde_json::from_slice(&load_raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("Failed to parse loadCodeAssist response: {e}"),
|
format!("Failed to parse loadCodeAssist response: {e}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1130,42 +1130,40 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
let quota_resp = match quota_resp {
|
let quota_resp = match quota_resp {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => return Err(format!("Network error (retrieveUserQuota): {e}")),
|
||||||
return SubscriptionQuota::error(
|
|
||||||
"gemini",
|
|
||||||
CredentialStatus::Valid,
|
|
||||||
format!("Network error (retrieveUserQuota): {e}"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let quota_status = quota_resp.status();
|
let quota_status = quota_resp.status();
|
||||||
if quota_status == reqwest::StatusCode::UNAUTHORIZED
|
if quota_status == reqwest::StatusCode::UNAUTHORIZED
|
||||||
|| quota_status == reqwest::StatusCode::FORBIDDEN
|
|| quota_status == reqwest::StatusCode::FORBIDDEN
|
||||||
{
|
{
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Expired,
|
CredentialStatus::Expired,
|
||||||
format!("Authentication failed (HTTP {quota_status})."),
|
format!("Authentication failed (HTTP {quota_status})."),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
if !quota_status.is_success() {
|
if !quota_status.is_success() {
|
||||||
let body = quota_resp.text().await.unwrap_or_default();
|
let body = quota_resp.text().await.unwrap_or_default();
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"),
|
format!("retrieveUserQuota failed (HTTP {quota_status}): {body}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let quota_data: GeminiQuotaResponse = match quota_resp.json().await {
|
let quota_raw = match quota_resp.bytes().await {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return Err(format!("Failed to read quota response: {e}")),
|
||||||
|
};
|
||||||
|
let quota_data: GeminiQuotaResponse = match serde_json::from_slice("a_raw) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return SubscriptionQuota::error(
|
return Ok(SubscriptionQuota::error(
|
||||||
"gemini",
|
"gemini",
|
||||||
CredentialStatus::Valid,
|
CredentialStatus::Valid,
|
||||||
format!("Failed to parse quota response: {e}"),
|
format!("Failed to parse quota response: {e}"),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1213,7 +1211,7 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
|
|
||||||
tiers.sort_by_key(|t| sort_order(&t.name));
|
tiers.sort_by_key(|t| sort_order(&t.name));
|
||||||
|
|
||||||
SubscriptionQuota {
|
Ok(SubscriptionQuota {
|
||||||
tool: "gemini".to_string(),
|
tool: "gemini".to_string(),
|
||||||
credential_status: CredentialStatus::Valid,
|
credential_status: CredentialStatus::Valid,
|
||||||
credential_message: None,
|
credential_message: None,
|
||||||
@@ -1222,12 +1220,16 @@ async fn query_gemini_quota(access_token: &str) -> SubscriptionQuota {
|
|||||||
extra_usage: None,
|
extra_usage: None,
|
||||||
error: None,
|
error: None,
|
||||||
queried_at: Some(now_millis()),
|
queried_at: Some(now_millis()),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 入口函数 ──────────────────────────────────────────────
|
// ── 入口函数 ──────────────────────────────────────────────
|
||||||
|
|
||||||
/// 查询指定 CLI 工具的官方订阅额度
|
/// 查询指定 CLI 工具的官方订阅额度
|
||||||
|
///
|
||||||
|
/// 瞬时传输失败以 `Err` 传播(前端 reject → retry + 保留上次成功值)。Expired
|
||||||
|
/// 分支的"过期也试一把"重试同样用 `?` 传播瞬时错误——不能折叠成"已过期",
|
||||||
|
/// 否则一次网络抖动会被误报成确定性的凭据过期。
|
||||||
pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, String> {
|
pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, String> {
|
||||||
match tool {
|
match tool {
|
||||||
"claude" => {
|
"claude" => {
|
||||||
@@ -1243,7 +1245,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
CredentialStatus::Expired => {
|
CredentialStatus::Expired => {
|
||||||
// 即使过期也尝试调用 API(token 可能实际上仍有效)
|
// 即使过期也尝试调用 API(token 可能实际上仍有效)
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
let result = query_claude_quota(&token).await;
|
let result = query_claude_quota(&token).await?;
|
||||||
if result.success {
|
if result.success {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@@ -1256,7 +1258,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
}
|
}
|
||||||
CredentialStatus::Valid => {
|
CredentialStatus::Valid => {
|
||||||
let token = token.expect("token must be Some when status is Valid");
|
let token = token.expect("token must be Some when status is Valid");
|
||||||
Ok(query_claude_quota(&token).await)
|
query_claude_quota(&token).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1279,7 +1281,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
"codex",
|
"codex",
|
||||||
"Authentication failed. Please re-login with Codex CLI.",
|
"Authentication failed. Please re-login with Codex CLI.",
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
if result.success {
|
if result.success {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@@ -1292,13 +1294,13 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
}
|
}
|
||||||
CredentialStatus::Valid => {
|
CredentialStatus::Valid => {
|
||||||
let token = token.expect("token must be Some when status is Valid");
|
let token = token.expect("token must be Some when status is Valid");
|
||||||
Ok(query_codex_quota(
|
query_codex_quota(
|
||||||
&token,
|
&token,
|
||||||
account_id.as_deref(),
|
account_id.as_deref(),
|
||||||
"codex",
|
"codex",
|
||||||
"Authentication failed. Please re-login with Codex CLI.",
|
"Authentication failed. Please re-login with Codex CLI.",
|
||||||
)
|
)
|
||||||
.await)
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1316,12 +1318,12 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
// Gemini access_token 仅 ~1h 有效,尝试用 refresh_token 刷新
|
// Gemini access_token 仅 ~1h 有效,尝试用 refresh_token 刷新
|
||||||
if let Some(ref rt) = refresh_token {
|
if let Some(ref rt) = refresh_token {
|
||||||
if let Some(new_token) = refresh_gemini_token(rt).await {
|
if let Some(new_token) = refresh_gemini_token(rt).await {
|
||||||
return Ok(query_gemini_quota(&new_token).await);
|
return query_gemini_quota(&new_token).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 刷新失败,尝试用旧 token
|
// 刷新失败,尝试用旧 token
|
||||||
if let Some(ref token) = token {
|
if let Some(ref token) = token {
|
||||||
let result = query_gemini_quota(token).await;
|
let result = query_gemini_quota(token).await?;
|
||||||
if result.success {
|
if result.success {
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@@ -1334,7 +1336,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
|||||||
}
|
}
|
||||||
CredentialStatus::Valid => {
|
CredentialStatus::Valid => {
|
||||||
let token = token.expect("token must be Some when status is Valid");
|
let token = token.expect("token must be Some when status is Valid");
|
||||||
Ok(query_gemini_quota(&token).await)
|
query_gemini_quota(&token).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
const {
|
const {
|
||||||
data: usage,
|
data: usage,
|
||||||
isFetching: loading,
|
isFetching: loading,
|
||||||
|
isError,
|
||||||
lastQueriedAt,
|
lastQueriedAt,
|
||||||
refetch,
|
refetch,
|
||||||
} = useUsageQuery(providerId, appId, {
|
} = useUsageQuery(providerId, appId, {
|
||||||
@@ -86,11 +87,13 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [lastQueriedAt]);
|
}, [lastQueriedAt]);
|
||||||
|
|
||||||
// 只在启用用量查询且有数据时显示
|
// 只在启用用量查询且有数据时显示。后端把瞬时传输失败转成了 reject:有缓存
|
||||||
if (!usageEnabled || !usage) return null;
|
// 成功值时 react-query 保留 data 照常展示;首次查询就失败则 data 为空——
|
||||||
|
// 此时(isError)仍要渲染失败态给出重试入口,否则 footer 整体消失、无从重查。
|
||||||
|
if (!usageEnabled || (!usage && !isError)) return null;
|
||||||
|
|
||||||
// 错误状态
|
// 错误状态(业务失败,或无缓存成功值的 reject)
|
||||||
if (!usage.success) {
|
if (!usage || !usage.success) {
|
||||||
if (inline) {
|
if (inline) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
|
<div className="inline-flex items-center gap-2 text-xs rounded-lg border border-border-default bg-card px-3 py-2 shadow-sm">
|
||||||
@@ -115,7 +118,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
<div className="flex items-center justify-between gap-2 text-xs">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
<div className="flex items-center gap-2 text-red-500 dark:text-red-400">
|
||||||
<AlertCircle size={14} />
|
<AlertCircle size={14} />
|
||||||
<span>{usage.error || t("usage.queryFailed")}</span>
|
<span>{usage?.error || t("usage.queryFailed")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 刷新按钮 */}
|
{/* 刷新按钮 */}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { usageApi, settingsApi, type AppId } from "@/lib/api";
|
|||||||
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
|
import { copilotGetUsage, copilotGetUsageForAccount } from "@/lib/api/copilot";
|
||||||
import { useSettingsQuery } from "@/lib/query";
|
import { useSettingsQuery } from "@/lib/query";
|
||||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||||
import {
|
import {
|
||||||
extractCodexBaseUrl,
|
extractCodexBaseUrl,
|
||||||
@@ -663,8 +664,10 @@ const UsageScriptModal: React.FC<UsageScriptModalProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
// 后端命令 Err(String) 时 invoke 以裸字符串 reject(如瞬时网络失败),
|
||||||
|
// 直接读 .message 会得到 undefined。
|
||||||
toast.error(
|
toast.error(
|
||||||
`${t("usageScript.testFailed")}: ${error?.message || t("common.unknown")}`,
|
`${t("usageScript.testFailed")}: ${extractErrorMessage(error) || t("common.unknown")}`,
|
||||||
{
|
{
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
},
|
},
|
||||||
|
|||||||
+80
-34
@@ -19,6 +19,7 @@ import type {
|
|||||||
SessionMessage,
|
SessionMessage,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { usageKeys } from "@/lib/query/usage";
|
import { usageKeys } from "@/lib/query/usage";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
const sortProviders = (
|
const sortProviders = (
|
||||||
providers: Record<string, Provider>,
|
providers: Record<string, Provider>,
|
||||||
@@ -100,34 +101,45 @@ export interface UseUsageQueryOptions {
|
|||||||
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
|
autoQueryInterval?: number; // 自动查询间隔(分钟),0 表示禁用
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 最近一次成功的用量结果快照(keep-last-good 用)。 */
|
/** keep-last-good 判定所需的最小结果形状(UsageResult / SubscriptionQuota 都满足)。 */
|
||||||
export interface LastGoodUsage {
|
export interface UsageLikeResult {
|
||||||
data: UsageResult;
|
success: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最近一次成功的结果快照(keep-last-good 用)。 */
|
||||||
|
export interface LastGoodSnapshot<T> {
|
||||||
|
data: T;
|
||||||
at: number; // 该成功结果的获取时刻(ms)
|
at: number; // 该成功结果的获取时刻(ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 脚本路径 UsageResult 的快照类型(历史别名)。 */
|
||||||
|
export type LastGoodUsage = LastGoodSnapshot<UsageResult>;
|
||||||
|
|
||||||
/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */
|
/** 在最近一次成功后多久内,失败仍继续展示该成功值。 */
|
||||||
export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟
|
export const KEEP_LAST_GOOD_MS = 10 * 60 * 1000; // 10 分钟
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断一次用量查询失败是否属于"瞬时/网络类"(可被 keep-last-good 短暂掩盖)。
|
* 判断一次用量/额度查询失败是否属于"瞬时"(可被 keep-last-good 短暂掩盖)。
|
||||||
*
|
*
|
||||||
* 仅瞬时失败才允许继续展示上一次成功;**确定性失败**(鉴权失败、空 API Key、
|
* 仅瞬时失败才允许继续展示上一次成功;**确定性失败**(鉴权失败、空 API Key、
|
||||||
* 未知供应商、4xx、脚本/解析错误等)必须立即透出——用户改/删凭据后要马上看到,
|
* 未知供应商、4xx、脚本/解析错误等)必须立即透出——用户改/删凭据后要马上看到,
|
||||||
* 否则会一直显示过期额度直到窗口结束。
|
* 否则会一直显示过期额度直到窗口结束。
|
||||||
*
|
*
|
||||||
* 采用**白名单**:只认后端稳定的网络类错误前缀 + HTTP 5xx,失败安全——任何未识别
|
* 注:后端已把纯传输层失败(send 失败/超时/读体中断)转成 Err → invoke reject,
|
||||||
* 的错误一律按"非瞬时"立即透出,绝不误掩盖确定性失败。需与后端错误文案保持同步:
|
* 不再折叠进 `Ok(success:false)`,到不了这里——那类失败由 [`resolveDisplayUsage`]
|
||||||
* - 原生 balance/coding_plan/subscription:reqwest send 失败/超时 → `"Network error: …"`;
|
* 的 `rejected` 分支按同一窗口处理。本白名单主要兜仍以 `success:false` 返回的
|
||||||
* 上游非 2xx → `"API error (HTTP <code>…)"`
|
* **HTTP 5xx/429**,网络类文案匹配仅作兼容冗余。
|
||||||
* - JS 脚本 usage_script:`request_failed` → "请求失败/Request failed"、
|
|
||||||
* `read_response_failed` → "读取响应失败/Failed to read response"(仅 zh/en 两种本地化);
|
|
||||||
* 上游非 2xx → `"HTTP <code> …"`
|
|
||||||
*
|
*
|
||||||
* HTTP 状态:**5xx**(服务端错误,通常瞬时)归为 transient;**4xx**(鉴权/客户端
|
* 采用**白名单**,失败安全——任何未识别的错误一律按"非瞬时"立即透出,绝不误掩盖
|
||||||
* 错误,如 401/403/404/429)保持确定性,立即透出。
|
* 确定性失败。需与后端错误文案保持同步:
|
||||||
|
* - 原生 balance/coding_plan/subscription:上游非 2xx → `"API error (HTTP <code>…)"`
|
||||||
|
* - JS 脚本 usage_script:上游非 2xx → `"HTTP <code> …"`
|
||||||
|
*
|
||||||
|
* HTTP 状态:**5xx**(服务端错误,通常瞬时)与 **429**(限流,稍后重试即可)归为
|
||||||
|
* transient;其余 **4xx**(鉴权/客户端错误,如 401/403/404)保持确定性,立即透出。
|
||||||
*/
|
*/
|
||||||
export function isTransientUsageError(result: UsageResult): boolean {
|
export function isTransientUsageError(result: UsageLikeResult): boolean {
|
||||||
if (result.success) return false;
|
if (result.success) return false;
|
||||||
const e = result.error?.toLowerCase() ?? "";
|
const e = result.error?.toLowerCase() ?? "";
|
||||||
if (!e) return false;
|
if (!e) return false;
|
||||||
@@ -143,12 +155,12 @@ export function isTransientUsageError(result: UsageResult): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP 状态码:5xx 视为瞬时,4xx 视为确定性。错误文案里第一处 "HTTP <code>" 即为
|
// HTTP 状态码:5xx 与 429(限流)视为瞬时,其余 4xx 视为确定性。错误文案里第一处
|
||||||
// 上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
|
// "HTTP <code>" 即为上游状态码(原生 "API error (HTTP 500…)"、JS 脚本 "HTTP 500 …")。
|
||||||
const httpMatch = e.match(/http\s+(\d{3})/);
|
const httpMatch = e.match(/http\s+(\d{3})/);
|
||||||
if (httpMatch) {
|
if (httpMatch) {
|
||||||
const status = Number(httpMatch[1]);
|
const status = Number(httpMatch[1]);
|
||||||
return status >= 500 && status <= 599;
|
return (status >= 500 && status <= 599) || status === 429;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -156,30 +168,54 @@ export function isTransientUsageError(result: UsageResult): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。
|
* Keep-last-good 的纯决策函数(无 ref、无时钟,`now` 注入以便测试)。
|
||||||
|
* 对 `UsageResult`(脚本路径)与 `SubscriptionQuota`(订阅系 hooks)通用。
|
||||||
*
|
*
|
||||||
* 用量端点面向跨境/第三方,单次网络抖动会让后端返回 `Ok(success:false)` 当作"新数据"
|
* 策略:当失败是**瞬时**(见 [`isTransientUsageError`],即 5xx/429 等)且最近一次
|
||||||
* 覆盖掉上一次成功,使卡片瞬间翻红——这是"一会成功一会失败"观感的主因。
|
* 成功在 `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该
|
||||||
* 策略:当失败是**瞬时/网络类**(见 [`isTransientUsageError`])且最近一次成功在
|
* 成功的时刻(相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录
|
||||||
* `keepMs` 内时,不抹掉它,继续展示该成功值,并把 `lastQueriedAt` 指向该成功的时刻
|
* 时照常展示失败。
|
||||||
* (相对时间自然走到"10 分钟前"后过期翻红);超出窗口、或从无成功记录时照常展示失败。
|
|
||||||
*
|
*
|
||||||
* **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**:
|
* **确定性失败**(鉴权/空 key/未知供应商/4xx 等)不仅立即透出,还会**清空 `lastGood`**:
|
||||||
* 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。
|
* 旧成功快照已不可信,否则随后一次网络抖动会把"配置/鉴权已失效"的旧额度重新复活。
|
||||||
*
|
*
|
||||||
* 注:会 reject 的传输层失败(Copilot/DB)react-query 本就保留上次 `data`,这里
|
* 会 reject 的传输层失败(网络/超时/读体中断,后端已转 Err)由 `rejected` 标志
|
||||||
* 主要修的是 `Ok(success:false)` 这条覆盖路径。
|
* 处理:react-query 保留的上次成功 `data` 是**陈旧**的,不能当新鲜成功无限期展示
|
||||||
|
* ——同样只在 `keepMs` 窗口内(锚定上次真实成功时刻)继续展示,超窗后 `data`
|
||||||
|
* 置空,由调用方合成失败占位透出。否则「彻底断网」反而比「单次 5xx」掩盖更久。
|
||||||
*/
|
*/
|
||||||
export function resolveDisplayUsage(
|
export interface ResolveDisplayUsageOptions {
|
||||||
raw: UsageResult | undefined,
|
/** 本次查询是否以 reject 告终(invoke Err;react-query 会保留上次成功 data)。 */
|
||||||
|
rejected?: boolean;
|
||||||
|
keepMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDisplayUsage<T extends UsageLikeResult>(
|
||||||
|
raw: T | undefined,
|
||||||
dataUpdatedAt: number,
|
dataUpdatedAt: number,
|
||||||
prevLastGood: LastGoodUsage | null,
|
prevLastGood: LastGoodSnapshot<T> | null,
|
||||||
now: number,
|
now: number,
|
||||||
keepMs: number = KEEP_LAST_GOOD_MS,
|
options: ResolveDisplayUsageOptions = {},
|
||||||
): {
|
): {
|
||||||
data: UsageResult | undefined;
|
data: T | undefined;
|
||||||
lastQueriedAt: number | null;
|
lastQueriedAt: number | null;
|
||||||
lastGood: LastGoodUsage | null;
|
lastGood: LastGoodSnapshot<T> | null;
|
||||||
} {
|
} {
|
||||||
|
const { rejected = false, keepMs = KEEP_LAST_GOOD_MS } = options;
|
||||||
|
|
||||||
|
if (rejected && raw?.success) {
|
||||||
|
// reject 时看到的"成功"是 react-query 保留的旧值。用它补种 lastGood——锚定
|
||||||
|
// 上次真实成功时刻(dataUpdatedAt),且从 query 缓存派生,组件重挂丢失 ref
|
||||||
|
// 后窗口判定依然成立。
|
||||||
|
const lastGood = { data: raw, at: dataUpdatedAt || now };
|
||||||
|
if (now - lastGood.at < keepMs) {
|
||||||
|
return { data: raw, lastQueriedAt: lastGood.at, lastGood };
|
||||||
|
}
|
||||||
|
// 超窗:陈旧成功不再展示(调用方合成失败占位)。快照保留而非清空——reject
|
||||||
|
// 属瞬时、不代表旧值失信(与 5xx 的"不清空"一致);窗口锚定成功时刻,
|
||||||
|
// 超窗后自然保持失效,直到下次成功刷新。
|
||||||
|
return { data: undefined, lastQueriedAt: lastGood.at, lastGood };
|
||||||
|
}
|
||||||
|
|
||||||
let lastGood = prevLastGood;
|
let lastGood = prevLastGood;
|
||||||
if (raw?.success) {
|
if (raw?.success) {
|
||||||
// 成功:刷新快照
|
// 成功:刷新快照
|
||||||
@@ -231,9 +267,9 @@ export const useUsageQuery = (
|
|||||||
refetchIntervalInBackground: true, // 后台也继续定时查询
|
refetchIntervalInBackground: true, // 后台也继续定时查询
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
// 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。
|
// 用量查询面向跨境/第三方端点,单次网络抖动或瞬时 5xx 不应直接判失败。
|
||||||
// 重试一次以吸收瞬时故障(与 useSubscriptionQuota 的 retry:1 保持一致)。
|
// 后端已把瞬时传输失败(网络/超时/读体中断)转成 Err → invoke reject,
|
||||||
// 注意:原生 balance/coding_plan 路径把网络错误折叠成 Ok(success:false),
|
// retry 在此真正生效;reject 保留的旧 data 与 Ok(success:false) 的 5xx/429
|
||||||
// 这类不会触发 react-query 重试;本项主要覆盖会 reject 的传输层失败(Copilot/DB 等)。
|
// 一样,只在 resolveDisplayUsage 的 keep-last-good 窗口内继续展示。
|
||||||
retry: 1,
|
retry: 1,
|
||||||
retryDelay: 1500,
|
retryDelay: 1500,
|
||||||
staleTime, // 使用动态计算的缓存时间
|
staleTime, // 使用动态计算的缓存时间
|
||||||
@@ -248,12 +284,22 @@ export const useUsageQuery = (
|
|||||||
query.dataUpdatedAt,
|
query.dataUpdatedAt,
|
||||||
lastGoodRef.current,
|
lastGoodRef.current,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
{ rejected: query.isError },
|
||||||
);
|
);
|
||||||
lastGoodRef.current = lastGood;
|
lastGoodRef.current = lastGood;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...query,
|
...query,
|
||||||
data,
|
// reject 且无可展示值(首次查询即失败,或保留的旧成功已超窗):合成失败占位,
|
||||||
|
// 让 footer/卡片渲染失败态 + 重试入口,并透出 reject 的错误文案。
|
||||||
|
data:
|
||||||
|
data ??
|
||||||
|
(query.isError
|
||||||
|
? {
|
||||||
|
success: false,
|
||||||
|
error: extractErrorMessage(query.error) || undefined,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
lastQueriedAt,
|
lastQueriedAt,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useRef } from "react";
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
import { subscriptionApi } from "@/lib/api/subscription";
|
import { subscriptionApi } from "@/lib/api/subscription";
|
||||||
import type { AppId } from "@/lib/api/types";
|
import type { AppId } from "@/lib/api/types";
|
||||||
import type { ProviderMeta } from "@/types";
|
import type { ProviderMeta } from "@/types";
|
||||||
|
import type { SubscriptionQuota } from "@/types/subscription";
|
||||||
import { resolveManagedAccountId } from "@/lib/authBinding";
|
import { resolveManagedAccountId } from "@/lib/authBinding";
|
||||||
import { PROVIDER_TYPES } from "@/config/constants";
|
import { PROVIDER_TYPES } from "@/config/constants";
|
||||||
|
import { resolveDisplayUsage, type LastGoodSnapshot } from "./queries";
|
||||||
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
||||||
|
|
||||||
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
const REFETCH_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@@ -12,6 +16,66 @@ export const subscriptionKeys = {
|
|||||||
quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const,
|
quota: (appId: AppId) => [...subscriptionKeys.all, "quota", appId] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reject 且无可展示值时的失败占位:首次查询就失败(data 为 undefined),或
|
||||||
|
* react-query 保留的旧成功已超出 keep-last-good 窗口——合成一个失败结果,让
|
||||||
|
* 订阅视图仍渲染「查询失败」+ 刷新按钮,而不是 footer 整体消失、无从手动重查。
|
||||||
|
*/
|
||||||
|
const QUERY_REJECTED_PLACEHOLDER: SubscriptionQuota = {
|
||||||
|
tool: "",
|
||||||
|
credentialStatus: "valid",
|
||||||
|
credentialMessage: null,
|
||||||
|
success: false,
|
||||||
|
tiers: [],
|
||||||
|
extraUsage: null,
|
||||||
|
error: null,
|
||||||
|
queriedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep-last-good:与 useUsageQuery 同一策略(resolveDisplayUsage)。
|
||||||
|
*
|
||||||
|
* 后端对纯传输失败(网络/超时/读体中断)已 reject——react-query 保留上次 data
|
||||||
|
* 并触发 retry,但那份 data 是陈旧的:以 `rejected` 标志交给 resolveDisplayUsage
|
||||||
|
* 按同一窗口处理(窗口内继续展示,超窗透出失败),与仍以 `Ok(success:false)`
|
||||||
|
* 返回的瞬时失败(HTTP 5xx/429)行为一致。确定性失败(过期/鉴权/解析)不掩盖,
|
||||||
|
* 立即透出。
|
||||||
|
*
|
||||||
|
* `scopeKey` 标识查询身份(appId / 绑定的账号 id):身份变化时丢弃旧快照,
|
||||||
|
* 避免用上一个账号的额度掩盖新账号的瞬时失败。
|
||||||
|
*/
|
||||||
|
function useQuotaKeepLastGood(
|
||||||
|
query: UseQueryResult<SubscriptionQuota>,
|
||||||
|
scopeKey: string,
|
||||||
|
) {
|
||||||
|
const lastGoodRef = useRef<{
|
||||||
|
key: string;
|
||||||
|
snap: LastGoodSnapshot<SubscriptionQuota> | null;
|
||||||
|
}>({ key: scopeKey, snap: null });
|
||||||
|
if (lastGoodRef.current.key !== scopeKey) {
|
||||||
|
lastGoodRef.current = { key: scopeKey, snap: null };
|
||||||
|
}
|
||||||
|
const { data, lastGood } = resolveDisplayUsage(
|
||||||
|
query.data,
|
||||||
|
query.dataUpdatedAt,
|
||||||
|
lastGoodRef.current.snap,
|
||||||
|
Date.now(),
|
||||||
|
{ rejected: query.isError },
|
||||||
|
);
|
||||||
|
lastGoodRef.current.snap = lastGood;
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
data:
|
||||||
|
data ??
|
||||||
|
(query.isError
|
||||||
|
? {
|
||||||
|
...QUERY_REJECTED_PLACEHOLDER,
|
||||||
|
error: extractErrorMessage(query.error) || null,
|
||||||
|
}
|
||||||
|
: undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useSubscriptionQuota(
|
export function useSubscriptionQuota(
|
||||||
appId: AppId,
|
appId: AppId,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
@@ -23,7 +87,7 @@ export function useSubscriptionQuota(
|
|||||||
? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000
|
? Math.max(autoQueryIntervalMinutes, 1) * 60 * 1000
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: subscriptionKeys.quota(appId),
|
queryKey: subscriptionKeys.quota(appId),
|
||||||
queryFn: () => subscriptionApi.getQuota(appId),
|
queryFn: () => subscriptionApi.getQuota(appId),
|
||||||
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
|
enabled: enabled && ["claude", "codex", "gemini"].includes(appId),
|
||||||
@@ -36,6 +100,8 @@ export function useSubscriptionQuota(
|
|||||||
: REFETCH_INTERVAL,
|
: REFETCH_INTERVAL,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return useQuotaKeepLastGood(query, appId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCodexOauthQuotaOptions {
|
export interface UseCodexOauthQuotaOptions {
|
||||||
@@ -59,7 +125,7 @@ export function useCodexOauthQuota(
|
|||||||
) {
|
) {
|
||||||
const { enabled = true, autoQuery = false } = options;
|
const { enabled = true, autoQuery = false } = options;
|
||||||
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
const accountId = resolveManagedAccountId(meta, PROVIDER_TYPES.CODEX_OAUTH);
|
||||||
return useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
queryKey: ["codex_oauth", "quota", accountId ?? "default"],
|
||||||
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
queryFn: () => subscriptionApi.getCodexOauthQuota(accountId),
|
||||||
enabled,
|
enabled,
|
||||||
@@ -69,4 +135,6 @@ export function useCodexOauthQuota(
|
|||||||
staleTime: REFETCH_INTERVAL,
|
staleTime: REFETCH_INTERVAL,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return useQuotaKeepLastGood(query, accountId ?? "default");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ describe("isTransientUsageError", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false)", () => {
|
it("HTTP 5xx / 429(限流)→ 瞬时(true);其余 4xx → 非瞬时(false)", () => {
|
||||||
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
|
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -60,12 +60,19 @@ describe("isTransientUsageError", () => {
|
|||||||
expect(
|
expect(
|
||||||
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
|
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
// 429 是限流:稍后重试即可恢复,归瞬时——且不应清空 keep-last-good 快照
|
||||||
expect(
|
expect(
|
||||||
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
|
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
|
||||||
).toBe(false);
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isTransientUsageError(fail("HTTP 429 Too Many Requests : x")),
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
|
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isTransientUsageError(fail("Authentication failed (HTTP 401)")),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("成功 / 无错误信息 → false", () => {
|
it("成功 / 无错误信息 → false", () => {
|
||||||
@@ -173,4 +180,111 @@ describe("resolveDisplayUsage (keep-last-good)", () => {
|
|||||||
const r = resolveDisplayUsage(success, 0, null, now);
|
const r = resolveDisplayUsage(success, 0, null, now);
|
||||||
expect(r.lastGood).toEqual({ data: success, at: now });
|
expect(r.lastGood).toEqual({ data: success, at: now });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("429 限流:窗口内继续展示上次成功,且不清空 lastGood", () => {
|
||||||
|
const prev: LastGoodUsage = { data: ok(42), at: T0 };
|
||||||
|
const now = T0 + 1000;
|
||||||
|
const r = resolveDisplayUsage(
|
||||||
|
fail("API error (HTTP 429): rate limited"),
|
||||||
|
now,
|
||||||
|
prev,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(r.data).toBe(prev.data); // 掩盖:继续展示上次成功
|
||||||
|
expect(r.lastGood).toBe(prev); // 快照保留,抖动过去后可继续兜底
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅系 hooks(useSubscriptionQuota / useCodexOauthQuota)复用同一决策函数,
|
||||||
|
// 这里用 SubscriptionQuota 的形状锁定泛型行为。
|
||||||
|
describe("resolveDisplayUsage(SubscriptionQuota 形状)", () => {
|
||||||
|
interface QuotaLike {
|
||||||
|
success: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
credentialStatus: string;
|
||||||
|
queriedAt: number | null;
|
||||||
|
}
|
||||||
|
const quotaOk = (queriedAt: number): QuotaLike => ({
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
credentialStatus: "valid",
|
||||||
|
queriedAt,
|
||||||
|
});
|
||||||
|
const quotaFail = (error: string): QuotaLike => ({
|
||||||
|
success: false,
|
||||||
|
error,
|
||||||
|
credentialStatus: "valid",
|
||||||
|
queriedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("瞬时 5xx:窗口内继续展示上次成功的 quota(含其 queriedAt)", () => {
|
||||||
|
const prev = { data: quotaOk(T0), at: T0 };
|
||||||
|
const now = T0 + 1000;
|
||||||
|
const r = resolveDisplayUsage(
|
||||||
|
quotaFail("API error (HTTP 502): bad gateway"),
|
||||||
|
now,
|
||||||
|
prev,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(r.data).toBe(prev.data);
|
||||||
|
expect(r.data?.queriedAt).toBe(T0); // 展示的相对时间指向旧成功
|
||||||
|
});
|
||||||
|
|
||||||
|
it("确定性失败(token 过期):立即透出并清空 lastGood", () => {
|
||||||
|
const prev = { data: quotaOk(T0), at: T0 };
|
||||||
|
const now = T0 + 1000;
|
||||||
|
const failure = quotaFail("OAuth token has expired");
|
||||||
|
const r = resolveDisplayUsage(failure, now, prev, now);
|
||||||
|
expect(r.data).toBe(failure);
|
||||||
|
expect(r.lastGood).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// invoke reject(后端 Err:网络/超时/读体中断)时 react-query 保留上次成功 data,
|
||||||
|
// `rejected` 标志让这份"陈旧成功"走同一 keep-last-good 窗口(锚定上次成功时刻
|
||||||
|
// dataUpdatedAt),而不是无限期被当作新鲜成功展示——否则「彻底断网」反而比
|
||||||
|
// 「单次 5xx」掩盖更久。
|
||||||
|
describe("resolveDisplayUsage(rejected:reject 保留的旧成功按窗口过期)", () => {
|
||||||
|
it("窗口内:继续展示旧成功;prevLastGood=null(组件重挂丢 ref)也从 dataUpdatedAt 补种", () => {
|
||||||
|
const stale = ok(42); // react-query 保留的旧成功,dataUpdatedAt = 其成功时刻
|
||||||
|
const now = T0 + KEEP_LAST_GOOD_MS - 1;
|
||||||
|
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
|
||||||
|
expect(r.data).toBe(stale);
|
||||||
|
expect(r.lastQueriedAt).toBe(T0); // 相对时间指向上次真实成功
|
||||||
|
expect(r.lastGood).toEqual({ data: stale, at: T0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("超窗(>= keepMs):不再展示旧成功(data 置空由调用方合成失败占位),快照保留不清空", () => {
|
||||||
|
const stale = ok(42);
|
||||||
|
const now = T0 + KEEP_LAST_GOOD_MS;
|
||||||
|
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
|
||||||
|
expect(r.data).toBeUndefined();
|
||||||
|
expect(r.lastQueriedAt).toBe(T0);
|
||||||
|
expect(r.lastGood).toEqual({ data: stale, at: T0 }); // reject 属瞬时,不清快照
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reject 且从无成功(raw=undefined,首次查询即失败):data 为 undefined,lastGood 不变", () => {
|
||||||
|
const r = resolveDisplayUsage(undefined, 0, null, T0, { rejected: true });
|
||||||
|
expect(r.data).toBeUndefined();
|
||||||
|
expect(r.lastQueriedAt).toBeNull();
|
||||||
|
expect(r.lastGood).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reject 但保留的是确定性失败结果:照旧立即透出并清空 lastGood(rejected 只作用于成功值)", () => {
|
||||||
|
const prev: LastGoodUsage = { data: ok(42), at: T0 };
|
||||||
|
const failure = fail("Authentication failed (HTTP 401)");
|
||||||
|
const r = resolveDisplayUsage(failure, T0 + 1000, prev, T0 + 1000, {
|
||||||
|
rejected: true,
|
||||||
|
});
|
||||||
|
expect(r.data).toBe(failure);
|
||||||
|
expect(r.lastGood).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejected 未传(默认 false):成功照常作为新鲜结果,既有语义不变", () => {
|
||||||
|
const success = ok(7);
|
||||||
|
const now = T0 + KEEP_LAST_GOOD_MS * 3; // 距 T0 很久也无妨:非 reject 的成功就是新鲜的
|
||||||
|
const r = resolveDisplayUsage(success, now, null, now);
|
||||||
|
expect(r.data).toBe(success);
|
||||||
|
expect(r.lastGood).toEqual({ data: success, at: now });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user