feat: 新增部分套餐账单查询(v0.6.4)
- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天) - managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态) - 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询 - 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层) - 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射) - 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
This commit is contained in:
1 parent
12dcb17771
commit
ace9842556
26 files changed
+1822
-60
No files matched your search
Generated
+2
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1975,6 +1975,7 @@ dependencies = [
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-single-instance",
|
||||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -25,6 +25,7 @@ futures-util = "0.3"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
|
||||
tokio = { version = "1", features = ["sync"] }
|
||||
|
||||
[lib]
|
||||
name = "kimiswitch_lib"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::db;
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType};
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Model, Provider, ProviderType, UsageConfig};
|
||||
use crate::pi_io;
|
||||
use crate::services::{self, UsageKind, UsageResult};
|
||||
use indexmap::IndexMap;
|
||||
@@ -27,6 +27,10 @@ fn usage_kinds_key(provider_name: &str) -> String {
|
||||
format!("usage_kinds:{provider_name}")
|
||||
}
|
||||
|
||||
fn usage_config_key(provider_name: &str) -> String {
|
||||
format!("usage_config:{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.
|
||||
@@ -45,6 +49,11 @@ fn merge_usage_kinds(config: &mut Config) {
|
||||
Some(kinds.iter().map(|k| k.as_str().to_string()).collect())
|
||||
}
|
||||
});
|
||||
// Same pattern for the panel-edited usage config.
|
||||
p.usage_config = db::get_setting_pub(&usage_config_key(&p.name))
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|s| serde_json::from_str::<UsageConfig>(&s).ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +167,14 @@ pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), Str
|
||||
}
|
||||
_ => db::delete_setting_pub(&key).map_err(fmt_anyhow)?,
|
||||
}
|
||||
let cfg_key = usage_config_key(&provider.name);
|
||||
match &provider.usage_config {
|
||||
Some(cfg) => {
|
||||
let json = serde_json::to_string(cfg).map_err(|e| e.to_string())?;
|
||||
db::set_setting_pub(&cfg_key, &json).map_err(fmt_anyhow)?;
|
||||
}
|
||||
None => db::delete_setting_pub(&cfg_key).map_err(fmt_anyhow)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -614,9 +631,20 @@ pub async fn query_provider_usage(
|
||||
)));
|
||||
};
|
||||
|
||||
// Panel toggle: user disabled usage queries for this provider.
|
||||
if let Some(cfg) = &provider.usage_config {
|
||||
if !cfg.enabled {
|
||||
return Ok(UsageResult::failure(
|
||||
"usage query disabled in config panel".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The api_key only ever goes into request headers — never into logs,
|
||||
// error messages, or the cache key.
|
||||
let api_key = provider
|
||||
// error messages, or the cache key. Managed (OAuth-login) providers have
|
||||
// no static key; their credential comes from the Kimi Code OAuth session
|
||||
// file (refreshed on demand when the 15-min access token expires).
|
||||
let mut api_key = provider
|
||||
.api_key
|
||||
.clone()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
@@ -627,15 +655,48 @@ pub async fn query_provider_usage(
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
});
|
||||
let mut oauth_err: Option<String> = None;
|
||||
if api_key.is_none() && provider.managed {
|
||||
match crate::oauth::get_valid_access_token().await {
|
||||
Ok(token) => api_key = Some(token),
|
||||
Err(e) => oauth_err = Some(e),
|
||||
}
|
||||
}
|
||||
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()
|
||||
oauth_err.unwrap_or_else(|| {
|
||||
"no Kimi Code OAuth credentials found; run `kimi login` first".to_string()
|
||||
})
|
||||
} else {
|
||||
"no API key configured".to_string()
|
||||
}));
|
||||
};
|
||||
|
||||
let base_url = resolve_base_url(provider);
|
||||
|
||||
// NewAPI template: query the gateway's own /api/user/self with the
|
||||
// web-console access token, bypassing usage_kinds entirely.
|
||||
if let Some(cfg) = &provider.usage_config {
|
||||
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
|
||||
// Err = transient (network) → propagate for retry, same semantics
|
||||
// as the kinds loop below. Config errors surface as Ok(failure).
|
||||
let result = services::query_kind(
|
||||
UsageKind::BalanceNewapi,
|
||||
&base_url,
|
||||
&api_key,
|
||||
Some(cfg),
|
||||
)
|
||||
.await?;
|
||||
if result.success {
|
||||
usage_cache()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(cache_key, (Instant::now(), result.clone()));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
let kinds: Vec<UsageKind> = provider
|
||||
.usage_kinds
|
||||
.as_ref()
|
||||
@@ -660,7 +721,7 @@ pub async fn query_provider_usage(
|
||||
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 {
|
||||
match services::query_kind(kind, &base_url, &api_key, provider.usage_config.as_ref()).await {
|
||||
Ok(result) if result.success => {
|
||||
any_success = true;
|
||||
if let Some(d) = result.data {
|
||||
|
||||
@@ -159,6 +159,7 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
||||
.unwrap_or(Value::Null),
|
||||
// Merged from the settings table by load_agent_config_command.
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
icon_color,
|
||||
raw_other,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -508,6 +509,7 @@ api_key = ""
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let mut models = IndexMap::new();
|
||||
@@ -570,6 +572,7 @@ api_key = ""
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let mut models = IndexMap::new();
|
||||
@@ -676,6 +679,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
@@ -695,6 +699,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: Value::Null,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
@@ -734,6 +739,7 @@ max_context_size = 1048576
|
||||
icon_color: None,
|
||||
raw_other: serde_json::json!({"default_model": "some-alias"}),
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
||||
pub mod db;
|
||||
pub mod kimi_code_io;
|
||||
pub mod models;
|
||||
pub mod oauth;
|
||||
pub mod pi_io;
|
||||
pub mod services;
|
||||
|
||||
|
||||
+46
-2
@@ -96,9 +96,17 @@ pub struct Provider {
|
||||
/// `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")]
|
||||
/// frontend does carry it. Renamed to camelCase to match the TS
|
||||
/// `Provider.usageKinds` field — without the rename the frontend reads
|
||||
/// `undefined` and no usage footer ever renders.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageKinds")]
|
||||
pub usage_kinds: Option<Vec<String>>,
|
||||
/// Usage query configuration (template, credentials, auto interval),
|
||||
/// edited via the "配置用量查询" panel. Same persistence strategy as
|
||||
/// `usage_kinds`: SQLite settings `usage_config:<provider_name>`, never
|
||||
/// config.toml; IPC carries it as camelCase.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageConfig")]
|
||||
pub usage_config: Option<UsageConfig>,
|
||||
}
|
||||
|
||||
impl PartialEq for Provider {
|
||||
@@ -117,6 +125,7 @@ impl PartialEq for Provider {
|
||||
&& self.icon_color == other.icon_color
|
||||
&& self.raw_other == other.raw_other
|
||||
&& self.usage_kinds == other.usage_kinds
|
||||
&& self.usage_config == other.usage_config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +216,41 @@ impl PartialEq for Config {
|
||||
|
||||
impl Eq for Config {}
|
||||
|
||||
/// Usage query configuration edited via the "配置用量查询" panel.
|
||||
/// Mirrors cc-switch's `UsageScript` but trimmed to what Kimi Switch supports:
|
||||
/// auto-detected kinds plus the NewAPI/OneAPI template. Serialized camelCase
|
||||
/// to match the TS `UsageConfig` interface; persisted in SQLite settings, never
|
||||
/// in the agent's config.toml.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageConfig {
|
||||
/// Whether usage queries run for this provider at all.
|
||||
pub enabled: bool,
|
||||
/// "auto" = query the kinds in `usage_kinds` / host detection;
|
||||
/// "newapi" = query a NewAPI/OneAPI gateway with accessToken + userId.
|
||||
pub template_type: String,
|
||||
/// NewAPI query base URL (falls back to the provider's base_url).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
/// NewAPI web-console access token (NOT the sk- inference key).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub access_token: Option<String>,
|
||||
/// NewAPI user id, sent as the `New-Api-User` header.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub user_id: Option<String>,
|
||||
/// Auto query interval in minutes; 0/None = manual refresh only.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub auto_query_interval_minutes: Option<u32>,
|
||||
/// Per-request timeout in seconds; 0/None = default (8s).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl UsageConfig {
|
||||
pub const TEMPLATE_AUTO: &'static str = "auto";
|
||||
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Config {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Config")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Kimi Code OAuth credentials (login session) for usage queries.
|
||||
//!
|
||||
//! The official `kimi` CLI stores its OAuth session in
|
||||
//! `~/.kimi-code/credentials/kimi-code.json` after `kimi login`:
|
||||
//! ```json
|
||||
//! {
|
||||
//! "access_token": "eyJ...", // JWT, 15 min TTL (expires_in 900)
|
||||
//! "refresh_token": "eyJ...", // 30 day TTL, rotated on refresh
|
||||
//! "expires_at": 1785510484, // unix seconds
|
||||
//! "scope": "kimi-code",
|
||||
//! "token_type": "Bearer",
|
||||
//! "expires_in": 900
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! v2 refreshes expired tokens via the OAuth refresh_token grant and writes
|
||||
//! the rotated tokens back to the credentials file. Concurrency safety:
|
||||
//! - a process-wide single-flight mutex serializes refreshes, and the file is
|
||||
//! re-read under the lock so a refresh done by the running `kimi` CLI (or
|
||||
//! another waiter) is adopted instead of duplicated;
|
||||
//! - right before writing back, the file is re-read once more — if the CLI
|
||||
//! refreshed meanwhile its newer tokens win (refresh tokens rotate on use,
|
||||
//! so clobbering the CLI's write would break its next refresh).
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::kimi_code_io::kimi_code_config_dir;
|
||||
|
||||
/// Seconds of leeway when checking expiry, so a token that is about to die
|
||||
/// mid-request counts as expired.
|
||||
const EXPIRY_LEEWAY_SECS: i64 = 30;
|
||||
|
||||
/// OAuth token endpoint (confirmed in the official kimi.exe binary).
|
||||
const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token";
|
||||
/// Public OAuth client id used by the official CLI (from kimi.exe).
|
||||
const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
/// Refresh request timeout; refresh is rare, a bit more headroom than the 8s
|
||||
/// query default is fine.
|
||||
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct OAuthCredentials {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_at: Option<i64>,
|
||||
#[allow(dead_code)]
|
||||
pub scope: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub token_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
impl OAuthCredentials {
|
||||
/// True when the token is expired or expires within the leeway window.
|
||||
/// Missing `expires_at` is treated as valid (fail open to the API call,
|
||||
/// which returns a definitive 401 if the token is bad).
|
||||
pub fn is_expired(&self) -> bool {
|
||||
let Some(expires_at) = self.expires_at else {
|
||||
return false;
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
now >= expires_at - EXPIRY_LEEWAY_SECS
|
||||
}
|
||||
}
|
||||
|
||||
fn credentials_path() -> std::path::PathBuf {
|
||||
kimi_code_config_dir()
|
||||
.join("credentials")
|
||||
.join("kimi-code.json")
|
||||
}
|
||||
|
||||
/// Load the Kimi Code OAuth session. Errors are deterministic (missing file /
|
||||
/// unreadable JSON), never transient — the caller turns them into
|
||||
/// `Ok(success:false)`.
|
||||
pub fn load_kimi_code_credentials() -> Result<OAuthCredentials, String> {
|
||||
let path = credentials_path();
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| {
|
||||
format!(
|
||||
"Kimi Code OAuth credentials not found at {}: {e}. Run `kimi login` first.",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Failed to parse Kimi Code OAuth credentials: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: Option<i64>,
|
||||
scope: Option<String>,
|
||||
token_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Process-wide single-flight lock for token refresh. Refresh tokens rotate
|
||||
/// on use, so two concurrent refreshes would invalidate one of them.
|
||||
fn refresh_lock() -> &'static tokio::sync::Mutex<()> {
|
||||
static LOCK: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
|
||||
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
|
||||
}
|
||||
|
||||
/// Return a usable access token, refreshing via the refresh_token grant when
|
||||
/// the stored one is expired. Errors are deterministic (missing/dead session)
|
||||
/// — the caller surfaces them as `Ok(success:false)`.
|
||||
pub async fn get_valid_access_token() -> Result<String, String> {
|
||||
let creds = load_kimi_code_credentials()?;
|
||||
if !creds.is_expired() {
|
||||
return Ok(creds.access_token);
|
||||
}
|
||||
|
||||
let _guard = refresh_lock().lock().await;
|
||||
// Re-read under the lock: the CLI or a previous waiter may have refreshed
|
||||
// while we were waiting.
|
||||
let creds = load_kimi_code_credentials()?;
|
||||
if !creds.is_expired() {
|
||||
return Ok(creds.access_token);
|
||||
}
|
||||
refresh_credentials(&creds).await
|
||||
}
|
||||
|
||||
/// Merge a token endpoint response into the existing credentials JSON,
|
||||
/// preserving unrelated fields. Pure function for testability.
|
||||
fn merge_token_response(current_json: &str, resp: &TokenResponse) -> String {
|
||||
let mut merged: serde_json::Value =
|
||||
serde_json::from_str(current_json).unwrap_or_else(|_| serde_json::json!({}));
|
||||
let obj = match merged.as_object_mut() {
|
||||
Some(o) => o,
|
||||
None => return current_json.to_string(),
|
||||
};
|
||||
obj.insert(
|
||||
"access_token".to_string(),
|
||||
serde_json::Value::String(resp.access_token.clone()),
|
||||
);
|
||||
if let Some(rt) = &resp.refresh_token {
|
||||
obj.insert(
|
||||
"refresh_token".to_string(),
|
||||
serde_json::Value::String(rt.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(expires_in) = resp.expires_in {
|
||||
obj.insert(
|
||||
"expires_in".to_string(),
|
||||
serde_json::Value::Number(expires_in.into()),
|
||||
);
|
||||
obj.insert(
|
||||
"expires_at".to_string(),
|
||||
serde_json::Value::Number((chrono::Utc::now().timestamp() + expires_in).into()),
|
||||
);
|
||||
}
|
||||
if let Some(scope) = &resp.scope {
|
||||
obj.insert(
|
||||
"scope".to_string(),
|
||||
serde_json::Value::String(scope.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(token_type) = &resp.token_type {
|
||||
obj.insert(
|
||||
"token_type".to_string(),
|
||||
serde_json::Value::String(token_type.clone()),
|
||||
);
|
||||
}
|
||||
serde_json::to_string_pretty(&merged).unwrap_or_else(|_| current_json.to_string())
|
||||
}
|
||||
|
||||
/// Call the token endpoint with the refresh_token grant and persist the
|
||||
/// rotated tokens. Caller must hold [`refresh_lock`].
|
||||
async fn refresh_credentials(creds: &OAuthCredentials) -> Result<String, String> {
|
||||
let refresh_token = creds
|
||||
.refresh_token
|
||||
.clone()
|
||||
.filter(|t| !t.is_empty())
|
||||
.ok_or_else(|| {
|
||||
"Kimi Code session has no refresh token; run `kimi login`".to_string()
|
||||
})?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REFRESH_TIMEOUT)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
// Tokens only ever go into the request body — never into logs or errors.
|
||||
let resp = client
|
||||
.post(TOKEN_ENDPOINT)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", CLIENT_ID),
|
||||
("refresh_token", refresh_token.as_str()),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Token refresh request failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// OAuth error bodies are tiny ({"error":"invalid_grant", ...}); keep
|
||||
// the first 200 chars for diagnosability. They never contain tokens.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let body: String = body.chars().take(200).collect();
|
||||
return Err(format!(
|
||||
"Kimi Code login expired and refresh failed (HTTP {status}): {body}. Run `kimi login` to sign in again"
|
||||
));
|
||||
}
|
||||
|
||||
let token: TokenResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse token refresh response: {e}"))?;
|
||||
|
||||
// Re-read right before writing: if the CLI refreshed meanwhile, its newer
|
||||
// (rotated) tokens win — overwriting them would kill its next refresh.
|
||||
let path = credentials_path();
|
||||
let current = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
if let Ok(latest) = serde_json::from_str::<OAuthCredentials>(¤t) {
|
||||
if !latest.is_expired() && latest.access_token != creds.access_token {
|
||||
return Ok(latest.access_token);
|
||||
}
|
||||
}
|
||||
|
||||
let merged = merge_token_response(¤t, &token);
|
||||
if let Err(e) = std::fs::write(&path, merged) {
|
||||
// Non-fatal: the new access token is still usable for this query; the
|
||||
// next one will just refresh again.
|
||||
eprintln!("[oauth] failed to write refreshed credentials: {e}");
|
||||
}
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expired_token_detected_with_leeway() {
|
||||
let past = chrono::Utc::now().timestamp() - 60;
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(past),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_token_not_expired() {
|
||||
let future = chrono::Utc::now().timestamp() + 3600;
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: Some(future),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(!creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_expires_at_fails_open() {
|
||||
let creds = OAuthCredentials {
|
||||
access_token: "t".to_string(),
|
||||
refresh_token: None,
|
||||
expires_at: None,
|
||||
scope: None,
|
||||
token_type: None,
|
||||
expires_in: None,
|
||||
};
|
||||
assert!(!creds.is_expired());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credentials_file_shape_parses() {
|
||||
let json = serde_json::json!({
|
||||
"access_token": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"expires_at": 1785510484i64,
|
||||
"scope": "kimi-code",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900
|
||||
});
|
||||
let creds: OAuthCredentials = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(creds.access_token, "eyJhbGciOiJFUzI1NiIs...");
|
||||
assert_eq!(creds.expires_at, Some(1785510484));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_unrelated_fields_and_rotates() {
|
||||
let current = r#"{
|
||||
"access_token": "old-access",
|
||||
"refresh_token": "old-refresh",
|
||||
"expires_at": 1,
|
||||
"scope": "kimi-code",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 900,
|
||||
"custom_field": "keep-me"
|
||||
}"#;
|
||||
let resp = TokenResponse {
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: Some("new-refresh".to_string()),
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
};
|
||||
let merged = merge_token_response(current, &resp);
|
||||
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(v["access_token"], "new-access");
|
||||
assert_eq!(v["refresh_token"], "new-refresh");
|
||||
assert_eq!(v["custom_field"], "keep-me");
|
||||
assert_eq!(v["scope"], "kimi-code"); // resp.scope None → 原值保留
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let expires_at = v["expires_at"].as_i64().unwrap();
|
||||
assert!(expires_at > now + 800 && expires_at <= now + 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_without_rotated_refresh_keeps_old_refresh_token() {
|
||||
let current = r#"{"access_token": "old", "refresh_token": "old-refresh", "expires_at": 1}"#;
|
||||
let resp = TokenResponse {
|
||||
access_token: "new-access".to_string(),
|
||||
refresh_token: None,
|
||||
expires_in: Some(900),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
};
|
||||
let merged = merge_token_response(current, &resp);
|
||||
let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
|
||||
assert_eq!(v["refresh_token"], "old-refresh");
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
icon_color: None,
|
||||
raw_other: provider_raw,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
};
|
||||
|
||||
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
|
||||
/// 鉴权头形式:绝大多数供应商用 `Bearer <key>`;智谱套餐接口不加前缀(见 coding_plan)。
|
||||
pub(crate) enum AuthStyle {
|
||||
@@ -41,9 +41,10 @@ pub(crate) async fn get_json(
|
||||
url: &str,
|
||||
api_key: &str,
|
||||
auth: AuthStyle,
|
||||
timeout: Duration,
|
||||
) -> Result<Fetched, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
@@ -96,8 +97,8 @@ pub(crate) fn parse_f64_field(obj: &serde_json::Value, field: &str) -> Option<f6
|
||||
// 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? {
|
||||
pub async fn query_deepseek(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.deepseek.com/user/balance", api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_deepseek(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
@@ -132,8 +133,8 @@ fn parse_deepseek(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// 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? {
|
||||
pub async fn query_stepfun(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json("https://api.stepfun.com/v1/accounts", api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(UsageResult::ok(parse_stepfun(&body))),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
@@ -153,14 +154,14 @@ fn parse_stepfun(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// 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> {
|
||||
pub async fn query_siliconflow(api_key: &str, is_cn: bool, timeout: Duration) -> 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? {
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_siliconflow(&body, is_cn) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
@@ -196,11 +197,12 @@ fn parse_siliconflow(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageD
|
||||
// GET https://openrouter.ai/api/v1/credits
|
||||
// Response: { data: { total_credits, total_usage } }
|
||||
|
||||
pub async fn query_openrouter(api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_openrouter(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -230,11 +232,12 @@ fn parse_openrouter(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
// 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> {
|
||||
pub async fn query_novita(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.novita.ai/v3/user/balance",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -254,6 +257,251 @@ fn parse_novita(body: &serde_json::Value) -> Vec<UsageData> {
|
||||
}]
|
||||
}
|
||||
|
||||
// ── Kimi (Moonshot) 开放平台 ─────────────────────────────────
|
||||
// GET https://api.moonshot.cn/v1/users/me/balance(国内站;国际站 api.moonshot.ai)
|
||||
// 官方文档:https://platform.kimi.com/docs/api/balance
|
||||
// Response: { code, data: { available_balance, voucher_balance, cash_balance }, scode, status }
|
||||
// code != 0 或 status == false 为业务失败(确定性);available_balance 单位人民币元。
|
||||
// 注:国际站货币单位未见于官方文档,先按 USD 处理,待实测确认(与 SiliconFlow 做法一致)。
|
||||
|
||||
pub async fn query_kimi(api_key: &str, is_cn: bool, timeout: Duration) -> Result<UsageResult, String> {
|
||||
let domain = if is_cn {
|
||||
"api.moonshot.cn"
|
||||
} else {
|
||||
"api.moonshot.ai"
|
||||
};
|
||||
let url = format!("https://{domain}/v1/users/me/balance");
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_kimi(&body, is_cn) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
}),
|
||||
Fetched::Failed(err) => Ok(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kimi(body: &serde_json::Value, is_cn: bool) -> Result<Vec<UsageData>, UsageResult> {
|
||||
// 业务级失败:code != 0 / status == false
|
||||
if body.get("code").and_then(|v| v.as_i64()) != Some(0)
|
||||
|| body.get("status").and_then(|v| v.as_bool()) == Some(false)
|
||||
{
|
||||
let msg = body
|
||||
.get("scode")
|
||||
.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(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let available = parse_f64_field(data, "available_balance").unwrap_or(0.0);
|
||||
Ok(vec![UsageData {
|
||||
plan_name: Some("Kimi".to_string()),
|
||||
remaining: Some(available),
|
||||
unit: Some(if is_cn {
|
||||
"CNY".to_string()
|
||||
} else {
|
||||
"USD".to_string()
|
||||
}),
|
||||
is_valid: Some(available > 0.0),
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
// ── NewAPI / OneAPI 中转站 ────────────────────────────────────
|
||||
// GET {base_url}/api/user/self
|
||||
// 头:Authorization: Bearer <access_token>(网页后台 Access Token,非 sk- key)
|
||||
// New-Api-User: <user_id>
|
||||
// Response: { success, message, data: { quota, used_quota, username, ... } }
|
||||
// 金额换算:quota / quota_per_unit(从 {base_url}/api/status 拉取,站点可配,
|
||||
// 典型 500000 = 1 货币单位)。display_in_currency=true 时单位按
|
||||
// custom_currency_symbol(¥/$/¤),此处统一记 "credit" 由前端按 symbol 显示。
|
||||
//
|
||||
// 坑位(cc-switch 实测 + 本站验证):
|
||||
// - sk- 推理令牌不能查本接口(401 invalid access token),必须网页 Access Token。
|
||||
// - quota == 0 常表示"无限额度"(后台设置),不能显示成余额 0。
|
||||
|
||||
const DEFAULT_QUOTA_PER_UNIT: f64 = 500_000.0;
|
||||
|
||||
/// /api/status 的进程内缓存(key = base_url)。站点配置几乎不变,TTL 与
|
||||
/// usage 缓存一致(5 分钟)足够。
|
||||
static QUOTA_PER_UNIT_CACHE: std::sync::OnceLock<
|
||||
std::sync::Mutex<std::collections::HashMap<String, (std::time::Instant, f64, String)>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
|
||||
fn quota_cache() -> &'static std::sync::Mutex<
|
||||
std::collections::HashMap<String, (std::time::Instant, f64, String)>,
|
||||
> {
|
||||
QUOTA_PER_UNIT_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
|
||||
}
|
||||
|
||||
/// 拉取站点的 quota_per_unit 与货币符号;失败回退默认值。
|
||||
async fn fetch_newapi_status(base_url: &str, timeout: Duration) -> (f64, String) {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
{
|
||||
let cache = quota_cache().lock().unwrap();
|
||||
if let Some((ts, qpu, symbol)) = cache.get(base) {
|
||||
if ts.elapsed() < std::time::Duration::from_secs(300) {
|
||||
return (*qpu, symbol.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return (DEFAULT_QUOTA_PER_UNIT, "USD".to_string()),
|
||||
};
|
||||
let url = format!("{base}/api/status");
|
||||
let fallback = || (DEFAULT_QUOTA_PER_UNIT, "USD".to_string());
|
||||
let result = match client.get(&url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(raw) => match serde_json::from_slice::<serde_json::Value>(&raw) {
|
||||
Ok(v) => {
|
||||
let qpu = v
|
||||
.get("data")
|
||||
.and_then(|d| d.get("quota_per_unit"))
|
||||
.and_then(|v| v.as_f64())
|
||||
.filter(|&n| n > 0.0)
|
||||
.unwrap_or(DEFAULT_QUOTA_PER_UNIT);
|
||||
let symbol = v
|
||||
.get("data")
|
||||
.and_then(|d| d.get("custom_currency_symbol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("$")
|
||||
.to_string();
|
||||
(qpu, symbol)
|
||||
}
|
||||
Err(_) => fallback(),
|
||||
},
|
||||
Err(_) => fallback(),
|
||||
},
|
||||
_ => fallback(),
|
||||
};
|
||||
|
||||
let mut cache = quota_cache().lock().unwrap();
|
||||
cache.insert(
|
||||
base.to_string(),
|
||||
(std::time::Instant::now(), result.0, result.1.clone()),
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn query_newapi(
|
||||
base_url: &str,
|
||||
access_token: &str,
|
||||
user_id: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<UsageResult, String> {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/api/user/self");
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
let resp = match client
|
||||
.get(&url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.header("New-Api-User", user_id)
|
||||
.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(UsageResult::failure(format!(
|
||||
"Authentication failed (HTTP {status})"
|
||||
)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Ok(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}")),
|
||||
};
|
||||
let body: serde_json::Value = match serde_json::from_slice(&raw) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return Ok(UsageResult::failure(format!(
|
||||
"Failed to parse response: {e}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let (quota_per_unit, symbol) = fetch_newapi_status(base_url, timeout).await;
|
||||
Ok(match parse_newapi(&body, quota_per_unit, &symbol) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_newapi(
|
||||
body: &serde_json::Value,
|
||||
quota_per_unit: f64,
|
||||
symbol: &str,
|
||||
) -> Result<Vec<UsageData>, UsageResult> {
|
||||
if body.get("success").and_then(|v| v.as_bool()) == Some(false) {
|
||||
let msg = body
|
||||
.get("message")
|
||||
.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(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let quota_raw = parse_f64_field(data, "quota").unwrap_or(0.0);
|
||||
let used_raw = parse_f64_field(data, "used_quota").unwrap_or(0.0);
|
||||
|
||||
// quota == 0 && used == 0:站点把该令牌设为"无限额度",没有数值可显示。
|
||||
if quota_raw == 0.0 && used_raw == 0.0 {
|
||||
return Ok(vec![UsageData {
|
||||
plan_name: Some("NewAPI".to_string()),
|
||||
unit: Some(symbol.to_string()),
|
||||
is_valid: Some(true),
|
||||
..Default::default()
|
||||
}]);
|
||||
}
|
||||
|
||||
let remaining = quota_raw / quota_per_unit;
|
||||
let used = used_raw / quota_per_unit;
|
||||
let total = remaining + used;
|
||||
Ok(vec![UsageData {
|
||||
plan_name: Some("NewAPI".to_string()),
|
||||
remaining: Some(remaining),
|
||||
total: Some(total),
|
||||
used: Some(used),
|
||||
unit: Some(symbol.to_string()),
|
||||
is_valid: Some(remaining > 0.0),
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -323,4 +571,102 @@ mod tests {
|
||||
let zero = parse_novita(&json!({ "availableBalance": 0 }));
|
||||
assert_eq!(zero[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_parses_balance() {
|
||||
let body = json!({
|
||||
"code": 0,
|
||||
"data": {
|
||||
"available_balance": 49.58894,
|
||||
"voucher_balance": 46.58893,
|
||||
"cash_balance": 3.00001
|
||||
},
|
||||
"scode": "0x0",
|
||||
"status": true
|
||||
});
|
||||
let data = parse_kimi(&body, true).unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("Kimi"));
|
||||
assert_eq!(data[0].remaining, Some(49.58894));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("CNY"));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_en_uses_usd_unit() {
|
||||
let body = json!({ "code": 0, "data": { "available_balance": 12.5 }, "status": true });
|
||||
let data = parse_kimi(&body, false).unwrap();
|
||||
assert_eq!(data[0].unit.as_deref(), Some("USD"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "code": 401, "scode": "0x191", "status": false });
|
||||
let err = parse_kimi(&body, true).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("0x191"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_missing_data_is_deterministic_failure() {
|
||||
let body = json!({ "code": 0, "status": true });
|
||||
let err = parse_kimi(&body, true).unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("Missing 'data'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_zero_balance_is_invalid() {
|
||||
let body = json!({ "code": 0, "data": { "available_balance": 0.0 }, "status": true });
|
||||
let data = parse_kimi(&body, true).unwrap();
|
||||
assert_eq!(data[0].is_valid, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_parses_quota_with_unit_division() {
|
||||
let body = json!({
|
||||
"success": true,
|
||||
"data": { "quota": 36_125_000, "used_quota": 2_500_000, "username": "alice" }
|
||||
});
|
||||
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].plan_name.as_deref(), Some("NewAPI"));
|
||||
assert_eq!(data[0].remaining, Some(72.25));
|
||||
assert_eq!(data[0].used, Some(5.0));
|
||||
assert_eq!(data[0].total, Some(77.25));
|
||||
assert_eq!(data[0].unit.as_deref(), Some("¥"));
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_business_error_is_deterministic_failure() {
|
||||
let body = json!({ "success": false, "message": "Unauthorized, invalid access token" });
|
||||
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
|
||||
assert!(!err.success);
|
||||
assert!(err.error.unwrap().contains("invalid access token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_missing_data_is_deterministic_failure() {
|
||||
let body = json!({ "success": true });
|
||||
let err = parse_newapi(&body, 500_000.0, "¥").unwrap_err();
|
||||
assert!(err.error.unwrap().contains("Missing 'data'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_zero_quota_means_unlimited() {
|
||||
let body = json!({ "success": true, "data": { "quota": 0, "used_quota": 0 } });
|
||||
let data = parse_newapi(&body, 500_000.0, "$").unwrap();
|
||||
assert_eq!(data.len(), 1);
|
||||
assert_eq!(data[0].remaining, None);
|
||||
assert_eq!(data[0].is_valid, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_accepts_string_numbers() {
|
||||
let body = json!({ "success": true, "data": { "quota": "500000", "used_quota": "100000" } });
|
||||
let data = parse_newapi(&body, 500_000.0, "¥").unwrap();
|
||||
assert_eq!(data[0].remaining, Some(1.0));
|
||||
assert_eq!(data[0].used, Some(0.2));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
use super::balance::{get_json, AuthStyle, Fetched};
|
||||
use super::usage_types::{UsageData, UsageResult};
|
||||
use std::time::Duration;
|
||||
|
||||
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增)
|
||||
// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定
|
||||
// 做本地化映射——新增 tier id 时必须同步加映射。
|
||||
const TIER_FIVE_HOUR: &str = "five_hour";
|
||||
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
|
||||
|
||||
@@ -66,11 +70,12 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
|
||||
// Response: { limits: [{ detail: { limit, remaining, resetTime } }],
|
||||
// usage: { limit, remaining, resetTime } }
|
||||
|
||||
pub async fn query_kimi_coding(api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_kimi_coding(api_key: &str, timeout: Duration) -> Result<UsageResult, String> {
|
||||
match get_json(
|
||||
"https://api.kimi.com/coding/v1/usages",
|
||||
api_key,
|
||||
AuthStyle::Bearer,
|
||||
timeout,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -196,12 +201,12 @@ fn zhipu_quota_base(base_url: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, String> {
|
||||
pub async fn query_zhipu(base_url: &str, api_key: &str, timeout: Duration) -> 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? {
|
||||
match get_json(&url, api_key, AuthStyle::Raw, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_zhipu(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
@@ -215,14 +220,14 @@ pub async fn query_zhipu(base_url: &str, api_key: &str) -> Result<UsageResult, S
|
||||
// (海外站 api.minimax.io)
|
||||
// 接口直接给"剩余百分比",反转为已用百分比;只取 model_name == "general"。
|
||||
|
||||
pub async fn query_minimax(api_key: &str, is_cn: bool) -> Result<UsageResult, String> {
|
||||
pub async fn query_minimax(api_key: &str, is_cn: bool, timeout: Duration) -> 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? {
|
||||
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
|
||||
Fetched::Body(body) => Ok(match parse_minimax(&body) {
|
||||
Ok(data) => UsageResult::ok(data),
|
||||
Err(err) => err,
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum UsageKind {
|
||||
BalanceOpenrouter,
|
||||
BalanceStepfun,
|
||||
BalanceNovita,
|
||||
BalanceKimi,
|
||||
BalanceNewapi,
|
||||
PlanKimiCoding,
|
||||
PlanZhipu,
|
||||
PlanMinimax,
|
||||
@@ -35,18 +37,22 @@ impl UsageKind {
|
||||
UsageKind::BalanceOpenrouter => "balance:openrouter",
|
||||
UsageKind::BalanceStepfun => "balance:stepfun",
|
||||
UsageKind::BalanceNovita => "balance:novita",
|
||||
UsageKind::BalanceKimi => "balance:kimi",
|
||||
UsageKind::BalanceNewapi => "balance:newapi",
|
||||
UsageKind::PlanKimiCoding => "plan:kimi_coding",
|
||||
UsageKind::PlanZhipu => "plan:zhipu",
|
||||
UsageKind::PlanMinimax => "plan:minimax",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [UsageKind; 8] = [
|
||||
pub const ALL: [UsageKind; 10] = [
|
||||
UsageKind::BalanceDeepseek,
|
||||
UsageKind::BalanceSiliconflow,
|
||||
UsageKind::BalanceOpenrouter,
|
||||
UsageKind::BalanceStepfun,
|
||||
UsageKind::BalanceNovita,
|
||||
UsageKind::BalanceKimi,
|
||||
UsageKind::BalanceNewapi,
|
||||
UsageKind::PlanKimiCoding,
|
||||
UsageKind::PlanZhipu,
|
||||
UsageKind::PlanMinimax,
|
||||
@@ -63,6 +69,8 @@ impl std::str::FromStr for UsageKind {
|
||||
"balance:openrouter" => UsageKind::BalanceOpenrouter,
|
||||
"balance:stepfun" => UsageKind::BalanceStepfun,
|
||||
"balance:novita" => UsageKind::BalanceNovita,
|
||||
"balance:kimi" => UsageKind::BalanceKimi,
|
||||
"balance:newapi" => UsageKind::BalanceNewapi,
|
||||
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
|
||||
"plan:zhipu" => UsageKind::PlanZhipu,
|
||||
"plan:minimax" => UsageKind::PlanMinimax,
|
||||
@@ -91,6 +99,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
if url.contains("api.novita.ai") {
|
||||
kinds.push(UsageKind::BalanceNovita);
|
||||
}
|
||||
// Kimi 开放平台(Moonshot):国内站 api.moonshot.cn / 国际站 api.moonshot.ai。
|
||||
// 注意别与 api.kimi.com(Kimi Code 官方端点)混淆:后者只有 /coding 路径命中套餐。
|
||||
if url.contains("api.moonshot.cn") || url.contains("api.moonshot.ai") {
|
||||
kinds.push(UsageKind::BalanceKimi);
|
||||
}
|
||||
if url.contains("api.kimi.com") && url.contains("/coding") {
|
||||
kinds.push(UsageKind::PlanKimiCoding);
|
||||
}
|
||||
@@ -105,24 +118,53 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
|
||||
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
|
||||
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
|
||||
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
|
||||
/// 其余分支忽略;调用方在 templateType=="newapi" 时保证其存在。
|
||||
pub async fn query_kind(
|
||||
kind: UsageKind,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
usage_config: Option<&crate::models::UsageConfig>,
|
||||
) -> Result<UsageResult, String> {
|
||||
let lower = base_url.to_lowercase();
|
||||
// 用户配置的超时(秒);0/未配置回退默认 8s。
|
||||
let timeout = usage_config
|
||||
.and_then(|c| c.timeout_seconds)
|
||||
.filter(|&s| s > 0)
|
||||
.map(std::time::Duration::from_secs)
|
||||
.unwrap_or(balance::REQUEST_TIMEOUT);
|
||||
match kind {
|
||||
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key).await,
|
||||
UsageKind::BalanceDeepseek => balance::query_deepseek(api_key, timeout).await,
|
||||
UsageKind::BalanceSiliconflow => {
|
||||
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com")).await
|
||||
balance::query_siliconflow(api_key, !lower.contains("siliconflow.com"), timeout).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::BalanceOpenrouter => balance::query_openrouter(api_key, timeout).await,
|
||||
UsageKind::BalanceStepfun => balance::query_stepfun(api_key, timeout).await,
|
||||
UsageKind::BalanceNovita => balance::query_novita(api_key, timeout).await,
|
||||
UsageKind::BalanceKimi => {
|
||||
balance::query_kimi(api_key, !lower.contains("moonshot.ai"), timeout).await
|
||||
}
|
||||
UsageKind::BalanceNewapi => {
|
||||
let (token, uid) = usage_config
|
||||
.and_then(|c| {
|
||||
c.access_token
|
||||
.as_deref()
|
||||
.zip(c.user_id.as_deref())
|
||||
.filter(|(t, u)| !t.is_empty() && !u.is_empty())
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
"newapi template requires accessToken and userId".to_string()
|
||||
})?;
|
||||
let url = usage_config
|
||||
.and_then(|c| c.base_url.as_deref())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(base_url);
|
||||
balance::query_newapi(url, token, uid, timeout).await
|
||||
}
|
||||
UsageKind::PlanKimiCoding => coding_plan::query_kimi_coding(api_key, timeout).await,
|
||||
UsageKind::PlanZhipu => coding_plan::query_zhipu(base_url, api_key, timeout).await,
|
||||
UsageKind::PlanMinimax => {
|
||||
coding_plan::query_minimax(api_key, !lower.contains("minimax.io")).await
|
||||
coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,12 +175,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detect_provider_maps_known_hosts() {
|
||||
let cases: [(&str, UsageKind); 8] = [
|
||||
let cases: [(&str, UsageKind); 9] = [
|
||||
("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.moonshot.cn/v1", UsageKind::BalanceKimi),
|
||||
("https://api.kimi.com/coding/v1", UsageKind::PlanKimiCoding),
|
||||
(
|
||||
"https://open.bigmodel.cn/api/paas/v4",
|
||||
@@ -165,7 +208,7 @@ mod tests {
|
||||
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 路径 → 不命中套餐查询
|
||||
// api.kimi.com 但无 /coding 路径 → 不命中套餐查询,也不命中 Moonshot 余额
|
||||
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user