chore: init main branch
This commit is contained in:
commit
549c8b5b13
231 files changed
+27286
No files matched your search
@@ -0,0 +1,326 @@
|
||||
// Adapted from cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
//! 供应商余额查询服务
|
||||
//!
|
||||
//! 支持 DeepSeek、StepFun、SiliconFlow、OpenRouter、Novita AI 的账户余额查询。
|
||||
//!
|
||||
//! 错误通道语义(与 cc-switch 一致):
|
||||
//! - `Err(String)` = 瞬时传输失败(网络不可达/超时/读体中断)。前端 invoke reject,
|
||||
//! 触发 retry 并保留上一次成功的 data(keep-last-good)。
|
||||
//! - `Ok(success:false)` = 确定性失败(空 key/鉴权失败/非 2xx/响应体非法 JSON),
|
||||
//! 直接透出错误文案。
|
||||
//!
|
||||
//! HTTP 调用与 JSON→UsageData 解析拆成纯函数,便于无 mock 单元测试。
|
||||
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// 鉴权头形式:绝大多数供应商用 `Bearer <key>`;智谱套餐接口不加前缀(见 coding_plan)。
|
||||
pub(crate) enum AuthStyle {
|
||||
Bearer,
|
||||
Raw,
|
||||
}
|
||||
|
||||
/// GET 请求的归类结果。
|
||||
pub(crate) enum Fetched {
|
||||
/// 2xx 且响应体是合法 JSON。
|
||||
Body(serde_json::Value),
|
||||
/// 确定性失败(401/403/非 2xx/解析失败),调用方原样包成 Ok 返回。
|
||||
Failed(UsageResult),
|
||||
}
|
||||
|
||||
/// 统一的 GET + JSON 读取助手。瞬时失败(网络/超时/读体中断)返回 `Err`;
|
||||
/// 确定性失败收进 `Fetched::Failed`。
|
||||
///
|
||||
/// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
|
||||
/// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。
|
||||
pub(crate) async fn get_json(
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
auth: AuthStyle,
|
||||
) -> Result<Fetched, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
|
||||
let req = client.get(url).header("Accept", "application/json");
|
||||
let req = match auth {
|
||||
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {api_key}")),
|
||||
AuthStyle::Raw => req.header("Authorization", api_key),
|
||||
};
|
||||
|
||||
let resp = match req.send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Network error: {e}")),
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN {
|
||||
return Ok(Fetched::Failed(UsageResult::failure(format!(
|
||||
"Authentication failed (HTTP {status})"
|
||||
))));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Ok(Fetched::Failed(UsageResult::failure(format!(
|
||||
"API error (HTTP {status}): {body}"
|
||||
))));
|
||||
}
|
||||
|
||||
let raw = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(format!("Failed to read response: {e}")),
|
||||
};
|
||||
match serde_json::from_slice(&raw) {
|
||||
Ok(v) => Ok(Fetched::Body(v)),
|
||||
Err(e) => Ok(Fetched::Failed(UsageResult::failure(format!(
|
||||
"Failed to parse response: {e}"
|
||||
)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 JSON 字段为 f64,兼容数字和字符串格式。
|
||||
pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f64> {
|
||||
obj.get(field).and_then(|v| {
|
||||
v.as_f64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
}
|
||||
|
||||
// ── DeepSeek ────────────────────────────────────────────────
|
||||
// GET https://api.deepseek.com/user/balance
|
||||
// Response: { balance_infos: [{ currency, total_balance, ... }], is_available }
|
||||
|
||||
pub async fn query_deepseek(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_deepseek(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
let is_available = body
|
||||
.get("is_available")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
let mut data = Vec::new();
|
||||
|
||||
if let Some(infos) = body.get("balance_infos").and_then(|v| v.as_array()) {
|
||||
for info in infos {
|
||||
let currency = info
|
||||
.get("currency")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("CNY");
|
||||
data.push(UsageData {
|
||||
plan_name: Some(currency.to_string()),
|
||||
remaining: parse_f64_field(info, "total_balance"),
|
||||
is_valid: Some(is_available),
|
||||
unit: Some(currency.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// ── StepFun ─────────────────────────────────────────────────
|
||||
// GET https://api.stepfun.com/v1/accounts
|
||||
// Response: { object, type, balance, total_cash_balance, total_voucher_balance }
|
||||
|
||||
pub async fn query_stepfun(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_stepfun(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
vec![UsageData {
|
||||
plan_name: Some("StepFun".to_string()),
|
||||
remaining: Some(parse_f64_field(body, "balance").unwrap_or(0.0)),
|
||||
unit: Some("CNY".to_string()),
|
||||
is_valid: Some(true),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
// ── SiliconFlow ─────────────────────────────────────────────
|
||||
// GET https://api.siliconflow.cn/v1/user/info (.cn;海外站 .com 单位 USD)
|
||||
// Response: { code, data: { balance, chargeBalance, totalBalance, status } }
|
||||
|
||||
pub async fn query_siliconflow(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.siliconflow.cn"
|
||||
} else {
|
||||
"api.siliconflow.com"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/user/info");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer).await? {
|
||||
Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
}),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageData>, UsageResult> {
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(UsageResult::failure(
|
||||
"Missing 'data' field in response".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let (plan_name, unit) = if is_cn {
|
||||
("SiliconFlow", "CNY")
|
||||
} else {
|
||||
("SiliconFlow (EN)", "USD")
|
||||
};
|
||||
Ok(vec![UsageData {
|
||||
plan_name: Some(plan_name.to_string()),
|
||||
remaining: Some(parse_f64_field(data, "totalBalance").unwrap_or(0.0)),
|
||||
unit: Some(unit.to_string()),
|
||||
is_valid: Some(true),
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
// ── OpenRouter ──────────────────────────────────────────────
|
||||
// GET https://openrouter.ai/api/v1/credits
|
||||
// Response: { data: { total_credits, total_usage } }
|
||||
|
||||
pub async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_openrouter(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_openrouter(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
let data = body.get("data").unwrap_or(body);
|
||||
let total_credits = parse_f64_field(data, "total_credits").unwrap_or(0.0);
|
||||
let total_usage = parse_f64_field(data, "total_usage").unwrap_or(0.0);
|
||||
let remaining = total_credits - total_usage;
|
||||
|
||||
vec![UsageData {
|
||||
plan_name: Some("OpenRouter".to_string()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total_credits),
|
||||
used: Some(total_usage),
|
||||
unit: Some("USD".to_string()),
|
||||
is_valid: Some(remaining > 0.0),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
// ── Novita AI ───────────────────────────────────────────────
|
||||
// GET https://api.novita.ai/v3/user/balance
|
||||
// Response: { availableBalance, ... };金额单位 0.0001 USD,需 /10000。
|
||||
|
||||
pub async fn query_novita(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.novita.ai/v3/user/balance",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_novita(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_novita(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
let available = parse_f64_field(body, "availableBalance").unwrap_or(0.0) / 10000.0;
|
||||
vec![UsageData {
|
||||
plan_name: Some("Novita AI".to_string()),
|
||||
remaining: Some(available),
|
||||
unit: Some("USD".to_string()),
|
||||
is_valid: Some(available > 0.0),
|
||||
..Default::default()
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deepseek_maps_balance_infos() {
|
||||
let body = json!({
|
||||
"is_available": true,
|
||||
"balance_infos": [
|
||||
{ "currency": "CNY", "total_balance": "12.34" },
|
||||
{ "currency": "USD", "total_balance": 5.0 }
|
||||
]
|
||||
});
|
||||
let data = parse_deepseek(&body);
|
||||
assert_eq!(data.len(), 2);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("CNY"));
|
||||
assert_eq!(data[0].remaining, Some(12.34));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
assert_eq!(data[1].remaining, Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stepfun_reads_balance() {
|
||||
let body = json!({ "balance": 88.5 });
|
||||
let data = parse_stepfun(&body);
|
||||
assert_eq!(data[0].remaining, Some(88.5));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn siliconflow_missing_data_is_deterministic_failure() {
|
||||
let body = json!({ "code": 500 });
|
||||
let err = parse_siliconflow(&body, true).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("Missing 'data'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn siliconflow_cn_and_en_units() {
|
||||
let body = json!({ "data": { "totalBalance": "42.0" } });
|
||||
let cn = parse_siliconflow(&body, true).unwrap();
|
||||
let en = parse_siliconflow(&body, false).unwrap();
|
||||
assert_eq!(cn[0].unit.as_deref(), Some("CNY"));
|
||||
assert_eq!(en[0].unit.as_deref(), Some("USD"));
|
||||
assert_eq!(cn[0].remaining, Some(42.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openrouter_computes_remaining_credits() {
|
||||
let body = json!({ "data": { "total_credits": 20.0, "total_usage": 7.5 } });
|
||||
let data = parse_openrouter(&body);
|
||||
assert_eq!(data[0].remaining, Some(12.5));
|
||||
assert_eq!(data[0].total, Some(20.0));
|
||||
assert_eq!(data[0].used, Some(7.5));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn novita_converts_ten_thousandth_usd() {
|
||||
let body = json!({ "availableBalance": 123400 });
|
||||
let data = parse_novita(&body);
|
||||
assert_eq!(data[0].remaining, Some(12.34));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("USD"));
|
||||
|
||||
let zero = parse_novita(&json!({ "availableBalance": 0 }));
|
||||
assert_eq!(zero[0].is_valid, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Adapted from cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
//! Token Plan 套餐额度查询服务
|
||||
//!
|
||||
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。
|
||||
//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec<UsageData>`:
|
||||
//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" /
|
||||
//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100,
|
||||
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
|
||||
//!
|
||||
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
|
||||
|
||||
use super::balance::{get_json, AuthStyle, Fetched};
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
|
||||
const TIER_FIVE_HOUR: &str = "five_hour";
|
||||
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
/// 套餐条目的统一构造:按百分比表示用量。
|
||||
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
|
||||
UsageData {
|
||||
plan_name: Some(name.to_string()),
|
||||
remaining: Some((100.0 - used_percent).max(0.0)),
|
||||
total: Some(100.0),
|
||||
used: Some(used_percent),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
resets_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn millis_to_iso8601(ms: i64) -> Option<String> {
|
||||
let secs = ms / 1000;
|
||||
let nsecs = ((ms % 1000) * 1_000_000) as u32;
|
||||
chrono::DateTime::from_timestamp(secs, nsecs).map(|dt| dt.to_rfc3339())
|
||||
}
|
||||
|
||||
/// 从 JSON 值提取重置时间,兼容字符串和数字格式:
|
||||
/// - 字符串:直接返回(视为 ISO 8601)
|
||||
/// - 数字:自动判断秒/毫秒并转为 ISO 8601;0/负值视为无重置时间
|
||||
fn extract_reset_time(value: &serde_json::Value) -> Option<String> {
|
||||
if let Some(s) = value.as_str() {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
if let Some(n) = value.as_i64() {
|
||||
if n <= 0 {
|
||||
return None;
|
||||
}
|
||||
// 秒级时间戳 < 1e12,毫秒 >= 1e12
|
||||
let ms = if n < 1_000_000_000_000 { n * 1000 } else { n };
|
||||
return millis_to_iso8601(ms);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 解析 JSON 值为 f64,兼容数字和字符串格式(如 `100` 和 `"100"`)
|
||||
fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
value
|
||||
.as_f64()
|
||||
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
|
||||
// ── Kimi For Coding ─────────────────────────────────────────
|
||||
// GET https://api.kimi.com/coding/v1/usages
|
||||
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
|
||||
// usage: { limit, remaining, resetTime } }
|
||||
|
||||
pub async fn query_kimi_coding(api_key: &str) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_kimi_coding(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
// 5 小时窗口限额(优先显示)
|
||||
if let Some(limits) = body.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
if let Some(detail) = limit_item.get("detail") {
|
||||
tiers.push(kimi_limit_tier(TIER_FIVE_HOUR, detail));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 总体用量(周限额)
|
||||
if let Some(usage) = body.get("usage") {
|
||||
tiers.push(kimi_limit_tier(TIER_WEEKLY_LIMIT, usage));
|
||||
}
|
||||
|
||||
tiers
|
||||
}
|
||||
|
||||
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
|
||||
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
|
||||
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
|
||||
let resets_at = detail.get("resetTime").and_then(extract_reset_time);
|
||||
|
||||
let used = (limit - remaining).max(0.0);
|
||||
let utilization = if limit > 0.0 {
|
||||
(used / limit) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
percent_tier(name, utilization, resets_at)
|
||||
}
|
||||
|
||||
// ── 智谱 GLM ────────────────────────────────────────────────
|
||||
// GET {open.bigmodel.cn | api.z.ai}/api/monitor/usage/quota/limit
|
||||
// 注意:智谱鉴权不加 Bearer 前缀(cc-switch 实测行为,照搬)。
|
||||
|
||||
/// 智谱 TOKENS_LIMIT 条目按 `unit` 字段的显式窗口分类。
|
||||
/// 实测:`unit: 3` → 5 小时滚动窗口;`unit: 6` → 每周窗口。
|
||||
/// 缺失或不识别时走重置时间启发式兜底。
|
||||
fn parse_zhipu(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
|
||||
// 业务级别错误
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(UsageResult::failure(format!("API error: {msg}")));
|
||||
}
|
||||
|
||||
let data = match body.get("data") {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return Err(UsageResult::failure(
|
||||
"Missing 'data' field in response".to_string(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
type Entry = (Option<i64>, f64, Option<String>);
|
||||
let mut five_hour: Option<Entry> = None;
|
||||
let mut weekly: Option<Entry> = None;
|
||||
let mut unclassified: Vec<Entry> = Vec::new();
|
||||
|
||||
if let Some(limits) = data.get("limits").and_then(|v| v.as_array()) {
|
||||
for limit_item in limits {
|
||||
let limit_type = limit_item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !limit_type.eq_ignore_ascii_case("TOKENS_LIMIT") {
|
||||
continue;
|
||||
}
|
||||
let percentage = limit_item
|
||||
.get("percentage")
|
||||
.and_then(|v| v.as_f64())
|
||||
.unwrap_or(0.0);
|
||||
let reset_ms = limit_item.get("nextResetTime").and_then(|v| v.as_i64());
|
||||
let reset_iso = reset_ms.and_then(millis_to_iso8601);
|
||||
let entry = (reset_ms, percentage, reset_iso);
|
||||
match limit_item.get("unit").and_then(|v| v.as_i64()) {
|
||||
Some(3) if five_hour.is_none() => five_hour = Some(entry),
|
||||
Some(6) if weekly.is_none() => weekly = Some(entry),
|
||||
_ => unclassified.push(entry),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:无 nextResetTime 的优先归 five_hour,其余按 reset 升序填空槽。
|
||||
unclassified.sort_by_key(|(reset, _, _)| (reset.is_some(), reset.unwrap_or(i64::MIN)));
|
||||
for entry in unclassified {
|
||||
if five_hour.is_none() {
|
||||
five_hour = Some(entry);
|
||||
} else if weekly.is_none() {
|
||||
weekly = Some(entry);
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
for (name, slot) in [(TIER_FIVE_HOUR, five_hour), (TIER_WEEKLY_LIMIT, weekly)] {
|
||||
if let Some((_, percentage, resets_at)) = slot {
|
||||
tiers.push(percent_tier(name, percentage, resets_at));
|
||||
}
|
||||
}
|
||||
Ok(tiers)
|
||||
}
|
||||
|
||||
/// 额度接口与推理接口同 host:bigmodel.cn 与 z.ai 共用同一后端与 JSON shape。
|
||||
fn zhipu_quota_base(base_url: &str) -> &'static str {
|
||||
if base_url.to_lowercase().contains("bigmodel.cn") {
|
||||
"https://open.bigmodel.cn"
|
||||
} else {
|
||||
"https://api.z.ai"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||||
let url = format!(
|
||||
"{}/api/monitor/usage/quota/limit",
|
||||
zhipu_quota_base(base_url)
|
||||
);
|
||||
match get_json(&url, api_key, AuthStyle::Raw).await? {
|
||||
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
}),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
// ── MiniMax ─────────────────────────────────────────────────
|
||||
// GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains
|
||||
// (海外站 api.minimax.io)
|
||||
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
|
||||
|
||||
pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.minimaxi.com"
|
||||
} else {
|
||||
"api.minimax.io"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/api/openplatform/coding_plan/remains");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer).await? {
|
||||
Fetched::Body(body) => Ok(match parse_minimax(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
}),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_minimax(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult> {
|
||||
// 业务级别错误
|
||||
if let Some(base_resp) = body.get("base_resp") {
|
||||
let status_code = base_resp
|
||||
.get("status_code")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
if status_code != 0 {
|
||||
let msg = base_resp
|
||||
.get("status_msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
return Err(UsageResult::failure(format!(
|
||||
"API error (code {status_code}): {msg}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut tiers = Vec::new();
|
||||
|
||||
let Some(model_remains) = body.get("model_remains").and_then(|v| v.as_array()) else {
|
||||
return Ok(tiers);
|
||||
};
|
||||
// 只取 general(编程套餐),跳过 video 等其他模型
|
||||
let Some(item) = model_remains.iter().find(|item| {
|
||||
item.get("model_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == "general")
|
||||
.unwrap_or(false)
|
||||
}) else {
|
||||
return Ok(tiers);
|
||||
};
|
||||
|
||||
// 5h 桶:剩余百分比 → 已用百分比
|
||||
if let Some(remain_pct) = item
|
||||
.get("current_interval_remaining_percent")
|
||||
.and_then(|v| v.as_f64())
|
||||
{
|
||||
let resets_at = item
|
||||
.get("end_time")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
tiers.push(percent_tier(TIER_FIVE_HOUR, 100.0 - remain_pct, resets_at));
|
||||
}
|
||||
|
||||
// 周桶:仅 status=1 时激活;status=3 等表示该套餐无周限额,跳过
|
||||
if item.get("current_weekly_status").and_then(|v| v.as_i64()) == Some(1) {
|
||||
if let Some(remain_pct) = item
|
||||
.get("current_weekly_remaining_percent")
|
||||
.and_then(|v| v.as_f64())
|
||||
{
|
||||
let resets_at = item
|
||||
.get("weekly_end_time")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(millis_to_iso8601);
|
||||
tiers.push(percent_tier(
|
||||
TIER_WEEKLY_LIMIT,
|
||||
100.0 - remain_pct,
|
||||
resets_at,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tiers)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_flattens_limits_and_usage() {
|
||||
let body = json!({
|
||||
"limits": [
|
||||
{ "detail": { "limit": 100, "remaining": 40, "resetTime": 1_754_000_000_000i64 } }
|
||||
],
|
||||
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
|
||||
});
|
||||
let tiers = parse_kimi_coding(&body);
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
assert_eq!(tiers[0].used, Some(60.0));
|
||||
assert_eq!(tiers[0].total, Some(100.0));
|
||||
assert_eq!(tiers[0].remaining, Some(40.0));
|
||||
assert!(tiers[0].resets_at.is_some());
|
||||
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
|
||||
assert_eq!(tiers[1].used, Some(10.0));
|
||||
assert_eq!(
|
||||
tiers[1].resets_at.as_deref(),
|
||||
Some("2026-08-01T00:00:00Z")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_coding_reset_time_seconds_vs_millis() {
|
||||
// 秒级时间戳自动 ×1000
|
||||
let v = json!(1_754_000_000i64);
|
||||
assert!(extract_reset_time(&v).is_some());
|
||||
// 0 / 负值视为无重置时间
|
||||
assert_eq!(extract_reset_time(&json!(0)), None);
|
||||
assert_eq!(extract_reset_time(&json!(-1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_classifies_by_unit_field() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"limits": [
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 35.0, "unit": 3, "nextResetTime": 1_754_000_000_000i64 },
|
||||
{ "type": "TOKENS_LIMIT", "percentage": 80.0, "unit": 6, "nextResetTime": 1_754_500_000_000i64 }
|
||||
]
|
||||
}
|
||||
});
|
||||
let tiers = parse_zhipu(&body).unwrap();
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
assert_eq!(tiers[0].used, Some(35.0));
|
||||
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
|
||||
assert_eq!(tiers[1].used, Some(80.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zhipu_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "success": false, "msg": "token invalid" });
|
||||
let err = parse_zhipu(&body).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("token invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_picks_general_and_inverts_remaining() {
|
||||
let body = json!({
|
||||
"base_resp": { "status_code": 0, "status_msg": "success" },
|
||||
"model_remains": [
|
||||
{ "model_name": "video", "current_interval_remaining_percent": 50.0 },
|
||||
{
|
||||
"model_name": "general",
|
||||
"current_interval_remaining_percent": 70.0,
|
||||
"end_time": 1_754_000_000_000i64,
|
||||
"current_weekly_status": 1,
|
||||
"current_weekly_remaining_percent": 95.0,
|
||||
"weekly_end_time": 1_754_500_000_000i64
|
||||
}
|
||||
]
|
||||
});
|
||||
let tiers = parse_minimax(&body).unwrap();
|
||||
assert_eq!(tiers.len(), 2);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
assert_eq!(tiers[0].used, Some(30.0));
|
||||
assert_eq!(tiers[0].remaining, Some(70.0));
|
||||
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
|
||||
assert_eq!(tiers[1].used, Some(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_skips_inactive_weekly_bucket() {
|
||||
let body = json!({
|
||||
"model_remains": [
|
||||
{
|
||||
"model_name": "general",
|
||||
"current_interval_remaining_percent": 70.0,
|
||||
"current_weekly_status": 3,
|
||||
"current_weekly_remaining_percent": 100.0
|
||||
}
|
||||
]
|
||||
});
|
||||
let tiers = parse_minimax(&body).unwrap();
|
||||
assert_eq!(tiers.len(), 1);
|
||||
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimax_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "base_resp": { "status_code": 1002, "status_msg": "invalid key" } });
|
||||
let err = parse_minimax(&body).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("invalid key"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Adapted from cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
//! 供应商账单/用量查询统一入口。
|
||||
//!
|
||||
//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致
|
||||
//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。
|
||||
//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。
|
||||
//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。
|
||||
|
||||
pub mod balance;
|
||||
pub mod coding_plan;
|
||||
pub mod usage_types;
|
||||
|
||||
pub use usage_types::{UsageData, UsageResult};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum UsageKind {
|
||||
BalanceDeepseek,
|
||||
BalanceSiliconflow,
|
||||
BalanceOpenrouter,
|
||||
BalanceStepfun,
|
||||
BalanceNovita,
|
||||
PlanKimiCoding,
|
||||
PlanZhipu,
|
||||
PlanMinimax,
|
||||
}
|
||||
|
||||
impl UsageKind {
|
||||
/// 与前端 / SQLite settings(`usage_kinds:<provider_name>`)约定的字符串形式。
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
UsageKind::BalanceDeepseek => "balance:deepseek",
|
||||
UsageKind::BalanceSiliconflow => "balance:siliconflow",
|
||||
UsageKind::BalanceOpenrouter => "balance:openrouter",
|
||||
UsageKind::BalanceStepfun => "balance:stepfun",
|
||||
UsageKind::BalanceNovita => "balance:novita",
|
||||
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
||||
UsageKind::PlanZhipu => "plan:zhipu",
|
||||
UsageKind::PlanMinimax => "plan:minimax",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [UsageKind; 8] = [
|
||||
UsageKind::BalanceDeepseek,
|
||||
UsageKind::BalanceSiliconflow,
|
||||
UsageKind::BalanceOpenrouter,
|
||||
UsageKind::BalanceStepfun,
|
||||
UsageKind::BalanceNovita,
|
||||
UsageKind::PlanKimiCoding,
|
||||
UsageKind::PlanZhipu,
|
||||
UsageKind::PlanMinimax,
|
||||
];
|
||||
}
|
||||
|
||||
impl std::str::FromStr for UsageKind {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(match s {
|
||||
"balance:deepseek" => UsageKind::BalanceDeepseek,
|
||||
"balance:siliconflow" => UsageKind::BalanceSiliconflow,
|
||||
"balance:openrouter" => UsageKind::BalanceOpenrouter,
|
||||
"balance:stepfun" => UsageKind::BalanceStepfun,
|
||||
"balance:novita" => UsageKind::BalanceNovita,
|
||||
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
||||
"plan:zhipu" => UsageKind::PlanZhipu,
|
||||
"plan:minimax" => UsageKind::PlanMinimax,
|
||||
_ => return Err(()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 base_url 子串匹配可支持的查询类型;无匹配返回空 vec。
|
||||
/// 一个 base_url 理论上可同时命中多种(套餐 + 余额),故返回 Vec。
|
||||
pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
let url = base_url.to_lowercase();
|
||||
let mut kinds = Vec::new();
|
||||
if url.contains("api.deepseek.com") {
|
||||
kinds.push(UsageKind::BalanceDeepseek);
|
||||
}
|
||||
if url.contains("api.siliconflow.cn") {
|
||||
kinds.push(UsageKind::BalanceSiliconflow);
|
||||
}
|
||||
if url.contains("openrouter.ai") {
|
||||
kinds.push(UsageKind::BalanceOpenrouter);
|
||||
}
|
||||
if url.contains("api.stepfun.com") {
|
||||
kinds.push(UsageKind::BalanceStepfun);
|
||||
}
|
||||
if url.contains("api.novita.ai") {
|
||||
kinds.push(UsageKind::BalanceNovita);
|
||||
}
|
||||
if url.contains("api.kimi.com") && url.contains("/coding") {
|
||||
kinds.push(UsageKind::PlanKimiCoding);
|
||||
}
|
||||
if url.contains("open.bigmodel.cn") || url.contains("api.z.ai") {
|
||||
kinds.push(UsageKind::PlanZhipu);
|
||||
}
|
||||
if url.contains("api.minimaxi.com") {
|
||||
kinds.push(UsageKind::PlanMinimax);
|
||||
}
|
||||
kinds
|
||||
}
|
||||
|
||||
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
|
||||
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
|
||||
pub async fn query_kind(
|
||||
kind: UsageKind,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
) -> Result<UsageResult, String> {
|
||||
let lower = base_url.to_lowercase();
|
||||
match kind {
|
||||
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await,
|
||||
UsageKind::BalanceSiliconflow => {
|
||||
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await
|
||||
}
|
||||
UsageKind::BalanceOpenrouter => balance::query_openrouter(api_key).await,
|
||||
UsageKind::BalanceStepfun => balance::query_stepfun(api_key).await,
|
||||
UsageKind::BalanceNovita => balance::query_novita(api_key).await,
|
||||
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key).await,
|
||||
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key).await,
|
||||
UsageKind::PlanMinimax => {
|
||||
coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detect_provider_maps_known_hosts() {
|
||||
let cases: [(&str, UsageKind); 8] = [
|
||||
("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek),
|
||||
("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow),
|
||||
("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter),
|
||||
("https://api.stepfun.com/v1", UsageKind::BalanceStepfun),
|
||||
("https://api.novita.ai/v3", UsageKind::BalanceNovita),
|
||||
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
|
||||
(
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
UsageKind::PlanZhipu,
|
||||
),
|
||||
("https://api.minimaxi.com/v1", UsageKind::PlanMinimax),
|
||||
];
|
||||
for (url, expected) in cases {
|
||||
assert_eq!(
|
||||
detect_provider(url),
|
||||
vec![expected],
|
||||
"url: {url}"
|
||||
);
|
||||
}
|
||||
// z.ai 也命中智谱
|
||||
assert_eq!(
|
||||
detect_provider("https://api.z.ai/api/paas/v4"),
|
||||
vec![UsageKind::PlanZhipu]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_provider_unknown_url_returns_empty() {
|
||||
assert!(detect_provider("https://api.openai.com/v1").is_empty());
|
||||
assert!(detect_provider("https://example.com").is_empty());
|
||||
assert!(detect_provider("").is_empty());
|
||||
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询
|
||||
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_kind_string_roundtrip() {
|
||||
use std::str::FromStr;
|
||||
for kind in UsageKind::ALL {
|
||||
let s = kind.as_str();
|
||||
assert_eq!(UsageKind::from_str(s), Ok(kind), "kind: {s}");
|
||||
}
|
||||
assert!(UsageKind::from_str("balance:unknown").is_err());
|
||||
assert!(UsageKind::from_str("").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Adapted from cc-switch (MIT, © Jason Young)
|
||||
// https://github.com/farion1231/cc-switch
|
||||
|
||||
//! Shared return contract for provider billing/usage queries.
|
||||
//!
|
||||
//! MUST stay camelCase: the frontend reads `planName` / `resetsAt` /
|
||||
//! `isValid` — snake_case serialization would silently misalign every field.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize, Clone, Debug, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageData {
|
||||
pub plan_name: Option<String>,
|
||||
pub remaining: Option<f64>,
|
||||
pub total: Option<f64>,
|
||||
pub used: Option<f64>,
|
||||
pub unit: Option<String>,
|
||||
pub is_valid: Option<bool>,
|
||||
pub resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageResult {
|
||||
pub success: bool,
|
||||
pub data: Option<Vec<UsageData>>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl UsageResult {
|
||||
pub fn ok(data: Vec<UsageData>) -> Self {
|
||||
UsageResult {
|
||||
success: true,
|
||||
data: if data.is_empty() { None } else { Some(data) },
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failure(msg: String) -> Self {
|
||||
UsageResult {
|
||||
success: false,
|
||||
data: None,
|
||||
error: Some(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn usage_result_serializes_camel_case() {
|
||||
let result = UsageResult {
|
||||
success: true,
|
||||
data: Some(vec![UsageData {
|
||||
plan_name: Some("five_hour".to_string()),
|
||||
remaining: Some(65.0),
|
||||
total: Some(100.0),
|
||||
used: Some(35.0),
|
||||
unit: Some("%".to_string()),
|
||||
is_valid: Some(true),
|
||||
resets_at: Some("2026-07-29T12:00:00+00:00".to_string()),
|
||||
}]),
|
||||
error: None,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"planName\""), "json: {json}");
|
||||
assert!(json.contains("\"resetsAt\""), "json: {json}");
|
||||
assert!(json.contains("\"isValid\""), "json: {json}");
|
||||
assert!(json.contains("\"plan_name\"") == false, "json: {json}");
|
||||
assert!(json.contains("\"resets_at\"") == false, "json: {json}");
|
||||
assert!(json.contains("\"is_valid\"") == false, "json: {json}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user