v0.6.0: 预设供应商 + 供应商账单查询

## 新增

- 15 条主流供应商预设(Kimi Coding / Moonshot / Anthropic /
  DeepSeek / 智谱 GLM / z.ai / 阿里百炼 / MiniMax / StepFun /
  SiliconFlow / Novita / OpenRouter / OpenAI / Google AI Studio /
  火山方舟),通过 PresetPickerModal 一键填表
- 供应商余额/套餐查询(卡片底部 UsageFooter):余额类
  (DeepSeek / SiliconFlow / OpenRouter / StepFun / Novita) +
  套餐类(Kimi For Coding / 智谱 GLM / MiniMax),5min stale TTL +
  force_refresh + 并发 ≤3
- Rust 端 services/ 模块(balance / coding_plan / usage_types),
  参考 cc-switch(MIT,© Jason Young)实现
- query_provider_usage 命令:Rust 端从 config 加载 key,
  不经 IPC 序列化 API key
- usageKinds 持久化到 SQLite settings(JSON 数组),
  load 时合并回 config,导出 config.toml 时不写
- detect_provider 启发式:旧用户升级后自动获得账单支持
- save-time 校验:未完成供应商(缺 api_key / base_url / 模型)
  保存时弹 confirm 列出原因;新增未提交 back 时静默 drop
- docs/PROPOSAL-presets-and-usage.md(v0.4-draft 实施档)
- docs/VERIFICATION-CHECKLIST.md(70+ 项手动验证清单)

## 修复

- handleDuplicateProvider 的 alias.slice 漏洞:老数据
  非规范 alias 复制不再错位
- handleSelectPreset 不再 auto-save,半成品不会落盘
- handleSwitchProvider 校验取消时自动 refresh 回滚,
  不会出现内存改 / 盘没动的脏态

## 文档

- README / README_EN 致谢段补 cc-switch(预设 + 账单)
- README 已知限制与后续建议段(/reload 上游限制调研结论)
- i18n 补 5 条校验 key + 11 条预设 UI key + 12 条账单 key

详见 docs/PROPOSAL-presets-and-usage.md 与
docs/VERIFICATION-CHECKLIST.md
This commit is contained in:
KimiSwitch Dev committed 2026-07-29 20:59:53 +08:00
1 parent a1fdc6c93d
commit 2614f80d0f
27 files changed
+3442 -67

No files matched your search

+212 -23
View File
@@ -1,8 +1,12 @@
use crate::db;
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
use crate::pi_io;
use crate::services::{self, UsageKind, UsageResult};
use indexmap::IndexMap;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tauri_plugin_opener::OpenerExt;
fn fmt_anyhow(err: anyhow::Error) -> String {
@@ -17,12 +21,60 @@ pub fn debug_log(message: String) {
eprintln!("[frontend] {}", message);
}
/// SQLite settings key for a provider's billing/usage query kinds
/// (JSON array of kind strings, e.g. `["balance:deepseek"]`).
fn usage_kinds_key(provider_name: &str) -> String {
format!("usage_kinds:{provider_name}")
}
/// Merge per-provider `usage_kinds` into a loaded config: explicit SQLite
/// settings first, host-based detection as fallback so existing installs get
/// billing support automatically. The field never enters config.toml.
fn merge_usage_kinds(config: &mut Config) {
for p in config.providers.values_mut() {
let from_settings = db::get_setting_pub(&usage_kinds_key(&p.name))
.ok()
.flatten()
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
.filter(|v| !v.is_empty());
p.usage_kinds = from_settings.or_else(|| {
let kinds = services::detect_provider(&resolve_base_url(p));
if kinds.is_empty() {
None
} else {
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
}
});
}
}
fn load_pi_native_config() -> Result<Config, String> {
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config
.models
.values()
.find(|m| m.provider == provider && m.model == model_id)
{
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
match agent {
let mut config = match agent {
Agent::KimiCode => {
// config.toml is the authoritative source for provider/model data
// because the user can add or edit providers at any time via the
@@ -69,33 +121,19 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
}
}
Ok(config)
config
}
Agent::Pi => {
// Pi: SQLite first, fall back to native config on first use.
if let Some(config) = db_config {
if !config.providers.is_empty() {
return Ok(config);
}
match db_config {
Some(config) if !config.providers.is_empty() => config,
_ => load_pi_native_config()?,
}
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config.models.values().find(|m| {
m.provider == provider && m.model == model_id
}) {
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
}
};
merge_usage_kinds(&mut config);
Ok(config)
}
#[tauri::command]
@@ -108,6 +146,19 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
if matches!(agent, Agent::KimiCode) {
crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?;
}
// Persist usage_kinds to the SQLite settings table (never config.toml;
// the field is skip_serializing and the TOML export is hand-built).
// None / empty array → delete the key.
for provider in config.providers.values() {
let key = usage_kinds_key(&provider.name);
match &provider.usage_kinds {
Some(kinds) if !kinds.is_empty() => {
let json = serde_json::to_string(kinds).map_err(|e| e.to_string())?;
db::set_setting_pub(&key, &json).map_err(fmt_anyhow)?;
}
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
}
}
Ok(())
}
@@ -511,6 +562,144 @@ pub fn set_app_setting(key: String, value: String) -> Result<(), String> {
db::set_setting_pub(&key, &value).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------
// Provider billing / usage query (cc-switch semantics)
// ---------------------------------------------------------------------------
/// 5-minute in-memory cache keyed by (agent, provider_name).
/// Only successful results are cached; failures are always re-queryable.
const USAGE_CACHE_TTL: Duration = Duration::from_secs(300);
type UsageCache = Mutex<HashMap<(String, String), (Instant, UsageResult)>>;
fn usage_cache() -> &'static UsageCache {
static CACHE: OnceLock<UsageCache> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Query a provider's balance / plan quota. The frontend passes only the
/// provider name — base_url, api_key and usage kinds are all resolved here,
/// so the API key never crosses IPC and the host routing cannot be spoofed.
///
/// Error channel semantics (cc-switch):
/// - `Err(_)` = transient failure (network/timeout/body read) → frontend
/// retries and keeps the last good value.
/// - `Ok(success:false)` = deterministic failure (no key / auth / non-2xx /
/// bad JSON / unsupported provider) → show the error text directly.
#[tauri::command]
pub async fn query_provider_usage(
agent: Agent,
provider_name: String,
force_refresh: Option<bool>,
) -> Result<UsageResult, String> {
let cache_key = (agent.as_str().to_string(), provider_name.clone());
if !force_refresh.unwrap_or(false) {
let cached = usage_cache()
.lock()
.unwrap()
.get(&cache_key)
.and_then(|(ts, result)| (ts.elapsed() < USAGE_CACHE_TTL).then(|| result.clone()));
if let Some(result) = cached {
return Ok(result);
}
}
// Load via the same path as load_agent_config_command so usage_kinds
// (SQLite merge + host-detect fallback) is already resolved.
let config = load_agent_config_command(agent)?;
let Some(provider) = config.providers.get(&provider_name) else {
return Ok(UsageResult::failure(format!(
"provider '{provider_name}' not found"
)));
};
// The api_key only ever goes into request headers — never into logs,
// error messages, or the cache key.
let api_key = provider
.api_key
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| {
provider
.env
.get(expected_api_key_key(&provider.provider_type))
.cloned()
.filter(|s| !s.is_empty())
});
let Some(api_key) = api_key else {
return Ok(UsageResult::failure(if provider.managed {
"provider uses managed OAuth; usage query requires an API key".to_string()
} else {
"no API key configured".to_string()
}));
};
let base_url = resolve_base_url(provider);
let kinds: Vec<UsageKind> = provider
.usage_kinds
.as_ref()
.filter(|v| !v.is_empty())
.map(|v| {
v.iter()
.filter_map(|s| s.parse::<UsageKind>().ok())
.collect::<Vec<_>>()
})
.filter(|v| !v.is_empty())
.unwrap_or_else(|| services::detect_provider(&base_url));
if kinds.is_empty() {
return Ok(UsageResult::failure(
"unsupported provider: no usage query available for this base URL".to_string(),
));
}
// A failing kind must not take down the others: collect successes,
// deterministic failures and transient failures separately.
let mut data: Vec<crate::services::UsageData> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let mut transient: Vec<String> = Vec::new();
let mut any_success = false;
for kind in kinds {
match services::query_kind(kind, &base_url, &api_key).await {
Ok(result) if result.success => {
any_success = true;
if let Some(d) = result.data {
data.extend(d);
}
}
Ok(result) => {
if let Some(e) = result.error {
errors.push(format!("{}: {e}", kind.as_str()));
}
}
Err(e) => transient.push(format!("{}: {e}", kind.as_str())),
}
}
if any_success {
let result = UsageResult {
success: true,
data: if data.is_empty() { None } else { Some(data) },
error: if errors.is_empty() {
None
} else {
Some(errors.join("; "))
},
};
usage_cache()
.lock()
.unwrap()
.insert(cache_key, (Instant::now(), result.clone()));
Ok(result)
} else if !transient.is_empty() {
// All kinds failed transiently → propagate Err so the frontend
// rejects and retries (keep-last-good).
Err(transient.join("; "))
} else {
Ok(UsageResult::failure(errors.join("; ")))
}
}
// ---------------------------------------------------------------------------
// Version check (lightweight: GET Gitea releases API, compare tag_name)
// ---------------------------------------------------------------------------
+9
View File
@@ -157,6 +157,8 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null),
// Merged from the settings table by load_agent_config_command.
usage_kinds: None,
})
})?;
@@ -317,6 +319,13 @@ pub fn set_setting_pub(key: &str, value: &str) -> DbResult<()> {
Ok(())
}
/// Public helper: delete a single setting (no-op if the key does not exist).
pub fn delete_setting_pub(key: &str) -> DbResult<()> {
let conn = init_db()?;
conn.execute("DELETE FROM settings WHERE key = ?1", params![key])?;
Ok(())
}
fn provider_type_for_str(s: &str) -> ProviderType {
match s {
"anthropic" => ProviderType::Anthropic,
+12
View File
@@ -209,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
icon,
icon_color,
raw_other,
usage_kinds: None,
},
);
}
@@ -506,6 +507,7 @@ api_key = ""
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let mut models = IndexMap::new();
@@ -567,6 +569,7 @@ api_key = ""
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let mut models = IndexMap::new();
@@ -669,7 +672,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
providers.insert(
@@ -685,7 +691,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: Value::Null,
usage_kinds: None,
},
);
let config = Config {
@@ -721,7 +730,10 @@ max_context_size = 1048576
managed: false,
enabled: true,
active: true,
icon: None,
icon_color: None,
raw_other: serde_json::json!({"default_model": "some-alias"}),
usage_kinds: None,
},
);
let config = Config {
+2
View File
@@ -5,6 +5,7 @@ pub mod db;
pub mod kimi_code_io;
pub mod models;
pub mod pi_io;
pub mod services;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder};
@@ -96,6 +97,7 @@ pub fn run() {
commands::get_app_version,
commands::list_provider_models,
commands::test_connectivity,
commands::query_provider_usage,
commands::debug_log,
commands::get_app_setting,
commands::set_app_setting,
+9
View File
@@ -91,6 +91,14 @@ pub struct Provider {
pub icon_color: Option<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
/// Billing/usage query kinds for this provider (e.g. "balance:deepseek",
/// "plan:kimi_coding"). Persisted in the SQLite settings table under
/// `usage_kinds:<provider_name>`, NOT in the agent's config.toml — both
/// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are
/// explicit and never serialize this field, while the IPC payload to the
/// frontend does carry it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage_kinds: Option<Vec<String>>,
}
impl PartialEq for Provider {
@@ -108,6 +116,7 @@ impl PartialEq for Provider {
&& self.icon == other.icon
&& self.icon_color == other.icon_color
&& self.raw_other == other.raw_other
&& self.usage_kinds == other.usage_kinds
}
}
+1
View File
@@ -339,6 +339,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
icon: None,
icon_color: None,
raw_other: provider_raw,
usage_kinds: None,
};
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
+326
View File
@@ -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));
}
}
+414
View File
@@ -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"));
}
}
+182
View File
@@ -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());
}
}
+76
View File
@@ -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}");
}
}