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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user