Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5864d661c1 | ||
|
|
34a9c4c2d9 | ||
|
|
4f0b0cae12 | ||
|
|
35fd2e7b0e | ||
|
|
86112ee90b | ||
|
|
e349fa5971 | ||
|
|
1de8b9ee4c | ||
|
|
36fc9c50a8 | ||
|
|
1a11cfbaaf | ||
|
|
81699e757d |
No files matched your search
@@ -69,6 +69,45 @@ jobs:
|
||||
libgtk-3-dev \
|
||||
libayatana-appindicator3-dev
|
||||
|
||||
- name: Compose release body
|
||||
id: notes
|
||||
shell: bash
|
||||
run: |
|
||||
FILE="release-notes-${{ github.ref_name }}.md"
|
||||
{
|
||||
echo 'body<<RELEASE_BODY_EOF'
|
||||
if [ -f "$FILE" ]; then
|
||||
cat "$FILE"
|
||||
else
|
||||
echo "## ${{ github.ref_name }}"
|
||||
echo
|
||||
echo "见提交记录(未找到 $FILE)。"
|
||||
fi
|
||||
echo
|
||||
cat <<'TAIL_EOF'
|
||||
### Downloads by platform
|
||||
|
||||
- **Windows**: `.msi` installer
|
||||
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
|
||||
- **Linux**: `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
|
||||
|
||||
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
|
||||
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
|
||||
|
||||
```bash
|
||||
bash install-macos.sh
|
||||
```
|
||||
|
||||
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
|
||||
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
|
||||
|
||||
Full changelog: see commit history.
|
||||
TAIL_EOF
|
||||
echo 'RELEASE_BODY_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and upload release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
@@ -76,36 +115,7 @@ jobs:
|
||||
with:
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: ${{ github.ref_name }}
|
||||
releaseBody: |
|
||||
## ${{ github.ref_name }}
|
||||
|
||||
### 本次更新
|
||||
|
||||
- **模型数据同步(models.dev 2026-08-27)**:7343 个模型 / 203 个供应商
|
||||
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare、DeepInfra、HuggingFace、火山引擎等约 20 条渠道;GLM-5.3-Flash 定价 $0.075 / $0.25(输入/输出,每百万 token)
|
||||
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
|
||||
- 新增 Qwen3.8 系列、火山引擎豆包 Seed 2.x 全系、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
|
||||
- 价格更新 58 处、能力信息修正 56 处(详见 release-notes-v0.7.9.md)
|
||||
|
||||
### Downloads by platform
|
||||
|
||||
- **Windows**: `.msi` installer
|
||||
- **macOS**: `.dmg` + `.app`(未签名,仅 Apple Silicon / M 系列芯片)
|
||||
- **Linux**: `.deb` / `.AppImage` / `.rpm`
|
||||
|
||||
### macOS 安装说明(无开发者账号签名,需一次手动绕过)
|
||||
|
||||
1. 下载 `Kimi.Switch_*.dmg`,双击挂载后把 `Kimi Switch.app` 拖入「应用程序」文件夹
|
||||
2. 下载本 Release 附带的 `install-macos.sh`,在终端运行:
|
||||
|
||||
```bash
|
||||
bash install-macos.sh
|
||||
```
|
||||
|
||||
脚本会自动清除「下载隔离」属性并启动应用,之后可正常使用。
|
||||
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
|
||||
|
||||
Full changelog: see commit history.
|
||||
releaseBody: ${{ steps.notes.outputs.body }}
|
||||
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
|
||||
overwrite: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kimiswitch",
|
||||
"private": true,
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+81007
-77499
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,16 @@
|
||||
# KimiSwitch v0.7.13
|
||||
|
||||
## 适配 kimi-code 0.41.0
|
||||
|
||||
- **实验 flag 清单回至 9 个**:上游 0.41.0 移除了 `file_history` 实验 flag(轮级文件历史转为恒开),KimiSwitch 镜像全链路同步删除;用户配置中残留的该键会被上游静默忽略,无副作用
|
||||
- **危险命令守卫文案更新**:Auto(永不询问)模式自 0.41.0 起不再拦截危险命令与无法静态分析的命令,仅 Manual/YOLO 模式强制询问——设置页描述文案(中英)同步修正
|
||||
|
||||
## 账单查询改进
|
||||
|
||||
- **CodingPlan.site 自动识别**:`codingplan.site` 系供应商自动识别为 NewAPI 余额查询,无需手动选模板
|
||||
- **修复凭据缺失误报「网络异常」**:NewAPI 模板未配置 Access Token/User ID 时,现在显示明确的本地化配置提示,而非误导性的网络错误
|
||||
- **金额显示统一**:配置弹窗与卡片底部走同一格式化(两位小数);NewAPI 站点未配置货币符号时的 `¤` 占位符不再显示
|
||||
|
||||
## 其它
|
||||
|
||||
- models-dev 快照随 0.7.12 线更新;新增 usage 错误本地化单元测试
|
||||
@@ -0,0 +1,10 @@
|
||||
# KimiSwitch v0.7.14
|
||||
|
||||
## 修复
|
||||
|
||||
- **关闭「启用用量查询」后不再残留查询行为**:此前关闭开关后前端仍会发起查询,卡片上持续显示「查询失败 · 用量查询已关闭」的红色错误行。现在关闭后立即停止查询、隐藏整行用量信息(卡片头部与底部两种展示均生效),重新开启后自动恢复
|
||||
- 在途的查询结果在开关切换瞬间作废,且不再写入共享缓存,避免过期的失败状态在 5 分钟内复活
|
||||
|
||||
## 数据
|
||||
|
||||
- models-dev 快照同步(上游格式与价格更新)
|
||||
@@ -0,0 +1,16 @@
|
||||
# KimiSwitch v0.7.15
|
||||
|
||||
## 新功能
|
||||
|
||||
- **用量统计新增「昨天」选项卡**:时间范围现为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点(不渗入今天),顶部概览卡同步扩展为 5 张
|
||||
- **新增「模型用量统计」趋势选项卡**:在「供应商模型用量趋势」之后,不按天、跨供应商按模型名汇总(同一模型经不同供应商合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用;子代理模型仍单独成行
|
||||
|
||||
## 修复
|
||||
|
||||
- **opencode Go 套餐子代理请求 400**:Go 网关(opencode.ai/zen/go)近期强制要求 `x-opencode-session` 请求头用于路由与提示词缓存,缺失即 400 拒绝。现在导出配置时,凡指向 Go 端点的供应商会自动补写 `custom_headers.x-opencode-session`(已有用户自定义值保留不覆盖);按量计费的 `zen/v1` 端点不受影响
|
||||
- 定价测试基准条目更新:models.dev 上游下架 `moonshotai/kimi-k2.5` 官方条目,测试基准切换到 `moonshotai/kimi-k3`
|
||||
|
||||
## 数据
|
||||
|
||||
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商),并补齐 `public/` 前端静态目录(原停留在 08-27)
|
||||
- 注意:k2.5 官方定价条目下架后,`kimi/k2.5` 费用估算暂解析到 302ai 转售价格(0.66 / 3.3),略高于原官方价
|
||||
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.12"
|
||||
version = "0.7.14"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.12"
|
||||
version = "0.7.15"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -690,8 +690,8 @@ pub async fn query_provider_usage(
|
||||
|
||||
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.
|
||||
// NewAPI template: query the gateway's own usage endpoints with its
|
||||
// dedicated credentials, 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
|
||||
@@ -1355,7 +1355,7 @@ pub async fn kimi_oauth_poll(
|
||||
/// the raw values are returned and the truthy check happens on the frontend.
|
||||
#[tauri::command]
|
||||
pub fn get_experimental_env_status() -> HashMap<String, String> {
|
||||
const VARS: [(&str, &str); 12] = [
|
||||
const VARS: [(&str, &str); 11] = [
|
||||
("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
|
||||
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
|
||||
(
|
||||
@@ -1368,7 +1368,6 @@ pub fn get_experimental_env_status() -> HashMap<String, String> {
|
||||
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
|
||||
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
|
||||
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
|
||||
("file_history", "KIMI_CODE_EXPERIMENTAL_FILE_HISTORY"),
|
||||
// Non-flag probes consumed by the frontend:
|
||||
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
|
||||
("legacy", "KIMI_CODE_LEGACY_FLAG"),
|
||||
|
||||
+146
-21
@@ -102,6 +102,9 @@ pub struct RangeStats {
|
||||
pub totals: TotalsRow,
|
||||
pub daily: Vec<DailyRow>,
|
||||
pub models: Vec<ModelRow>,
|
||||
/// Model totals keyed by bare model name (provider prefix stripped), so the
|
||||
/// same model served by multiple providers merges into one row.
|
||||
pub models_by_name: Vec<ModelRow>,
|
||||
pub recent: Vec<RecentRow>,
|
||||
pub recent_total: usize,
|
||||
pub recent_limit: usize,
|
||||
@@ -976,6 +979,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
totals: TotalsRow::default(),
|
||||
daily: vec![],
|
||||
models: vec![],
|
||||
models_by_name: vec![],
|
||||
recent: vec![],
|
||||
recent_total: 0,
|
||||
recent_limit: 500,
|
||||
@@ -985,6 +989,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
let mut totals = TotalsRow::default();
|
||||
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
|
||||
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||
let mut by_name: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
|
||||
|
||||
for r in &filtered {
|
||||
totals.add(r);
|
||||
@@ -1017,6 +1022,28 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
entry.1.add(r);
|
||||
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
|
||||
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
|
||||
|
||||
// Provider-agnostic bucket: secondary (subagent) records keep the
|
||||
// stable "__secondary__" marker; everything else keys on the bare
|
||||
// model name so the same model across providers merges into one row.
|
||||
let nk = if r.is_secondary { "__secondary__".to_string() } else { r.model_resolved.clone() };
|
||||
let name_entry = by_name.entry(nk.clone()).or_insert_with(|| {
|
||||
let m = ModelRow {
|
||||
model: nk.clone(),
|
||||
model_display: nk.clone(),
|
||||
model_resolved: nk.clone(),
|
||||
price_id: r.price_id.clone(),
|
||||
cost_estimated: r.cost_estimated,
|
||||
is_secondary: r.is_secondary,
|
||||
requests: 0, input_other: 0, output: 0,
|
||||
input_cache_read: 0, input_cache_creation: 0,
|
||||
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
|
||||
};
|
||||
(m, TotalsRow::default())
|
||||
});
|
||||
name_entry.1.add(r);
|
||||
name_entry.0.cost_estimated = name_entry.0.cost_estimated || r.cost_estimated;
|
||||
name_entry.0.is_secondary = name_entry.0.is_secondary || r.is_secondary;
|
||||
}
|
||||
|
||||
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
|
||||
@@ -1063,6 +1090,17 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
}).collect();
|
||||
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||
|
||||
let mut models_by_name: Vec<ModelRow> = by_name.into_iter().map(|(_, (mut m, t))| {
|
||||
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
|
||||
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
|
||||
m.requests = t.requests;
|
||||
m.input_other = t.input_other; m.output = t.output;
|
||||
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
|
||||
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
|
||||
m
|
||||
}).collect();
|
||||
models_by_name.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
|
||||
|
||||
let recent_total = filtered.len();
|
||||
let recent_limit = 500;
|
||||
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
|
||||
@@ -1081,6 +1119,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
|
||||
totals,
|
||||
daily,
|
||||
models,
|
||||
models_by_name,
|
||||
recent,
|
||||
recent_total,
|
||||
recent_limit,
|
||||
@@ -1109,24 +1148,40 @@ impl TotalsRow {
|
||||
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
|
||||
if range == "all" { return records.iter().collect(); }
|
||||
let start = range_start(range, now_ms);
|
||||
records.iter().filter(|r| r.time >= start).collect()
|
||||
match range_end(range, now_ms) {
|
||||
Some(end) => records.iter().filter(|r| r.time >= start && r.time < end).collect(),
|
||||
None => records.iter().filter(|r| r.time >= start).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound (exclusive) for bounded ranges. Only "yesterday" has one —
|
||||
/// it must not bleed into today.
|
||||
fn range_end(range: &str, now_ms: u64) -> Option<u64> {
|
||||
match range {
|
||||
"yesterday" => Some(local_midnight_ms(now_ms, 0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// LOCAL midnight `days_ago` days before today, in epoch ms. Not UTC midnight:
|
||||
/// without local-midnight anchoring, users east of UTC see day boundaries at
|
||||
/// the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
|
||||
fn local_midnight_ms(now_ms: u64, days_ago: i64) -> u64 {
|
||||
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
|
||||
let now_local = now_utc.with_timezone(&Local);
|
||||
let date = now_local.date_naive() - chrono::Duration::days(days_ago);
|
||||
let day_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
Local
|
||||
.from_local_datetime(&day_start)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp() as u64 * 1000)
|
||||
.unwrap_or_else(|| day_start.and_utc().timestamp() as u64 * 1000)
|
||||
}
|
||||
|
||||
fn range_start(range: &str, now_ms: u64) -> u64 {
|
||||
match range {
|
||||
"today" => {
|
||||
// LOCAL midnight today — not UTC midnight. Without this, users east of UTC see
|
||||
// "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
|
||||
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
|
||||
let now_local = now_utc.with_timezone(&Local);
|
||||
let today_date = now_local.date_naive();
|
||||
let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default();
|
||||
Local
|
||||
.from_local_datetime(&today_start)
|
||||
.single()
|
||||
.map(|dt| dt.timestamp() as u64 * 1000)
|
||||
.unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000)
|
||||
}
|
||||
"today" => local_midnight_ms(now_ms, 0),
|
||||
"yesterday" => local_midnight_ms(now_ms, 1),
|
||||
"7d" => now_ms - 7 * 24 * 3600 * 1000,
|
||||
"30d" => now_ms - 30 * 24 * 3600 * 1000,
|
||||
_ => now_ms - 30 * 24 * 3600 * 1000,
|
||||
@@ -1836,7 +1891,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
|
||||
|
||||
let all_model_count = all_models.len();
|
||||
let mut range_totals = HashMap::new();
|
||||
for r_k in ["today", "7d", "30d", "all"] {
|
||||
for r_k in ["today", "yesterday", "7d", "30d", "all"] {
|
||||
let s = aggregate(&records, r_k, now_ms);
|
||||
range_totals.insert(r_k.to_string(), s.totals);
|
||||
}
|
||||
@@ -1977,10 +2032,10 @@ mod pricing_tests {
|
||||
let idx = models_dev_cost_index();
|
||||
// The compiled-in snapshot must carry real models.dev prices.
|
||||
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
|
||||
let kimi = idx.get("moonshotai/kimi-k2.5").copied().expect("kimi-k2.5 present");
|
||||
assert_eq!(kimi.input, 0.6);
|
||||
assert_eq!(kimi.output, 3.0);
|
||||
assert_eq!(kimi.cache_read, 0.1);
|
||||
let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present");
|
||||
assert_eq!(kimi.input, 3.0);
|
||||
assert_eq!(kimi.output, 15.0);
|
||||
assert_eq!(kimi.cache_read, 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1992,9 +2047,9 @@ mod pricing_tests {
|
||||
assert_eq!(input, 0.6);
|
||||
assert_eq!(output, 2.2);
|
||||
|
||||
let (id, _, _, _, est) = match_price("kimi/k2.5");
|
||||
let (id, _, _, _, est) = match_price("kimi/k3");
|
||||
assert!(!est);
|
||||
assert_eq!(id, "moonshotai/kimi-k2.5");
|
||||
assert_eq!(id, "moonshotai/kimi-k3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2307,3 +2362,73 @@ mod session_tests {
|
||||
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod range_and_model_totals_tests {
|
||||
use super::*;
|
||||
|
||||
fn rec(time: u64, model: &str, resolved: &str, secondary: bool) -> UsageRecord {
|
||||
UsageRecord {
|
||||
time,
|
||||
model: model.to_string(),
|
||||
input_other: 100,
|
||||
output: 50,
|
||||
input_cache_read: 50,
|
||||
input_cache_creation: 0,
|
||||
cost_usd: 0.01,
|
||||
cost_estimated: false,
|
||||
price_id: String::new(),
|
||||
model_resolved: resolved.to_string(),
|
||||
model_display: model.to_string(),
|
||||
provider: None,
|
||||
from_env: false,
|
||||
is_secondary: secondary,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yesterday_range_is_bounded_to_yesterday() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
|
||||
let today_start = local_midnight_ms(now, 0);
|
||||
let yesterday_start = local_midnight_ms(now, 1);
|
||||
assert!(yesterday_start < today_start, "yesterday midnight precedes today's");
|
||||
assert_eq!(range_end("yesterday", now), Some(today_start));
|
||||
assert_eq!(range_end("today", now), None);
|
||||
assert_eq!(range_end("7d", now), None);
|
||||
|
||||
let records = vec![
|
||||
rec(yesterday_start + 1, "a/m1", "m1", false), // yesterday → in
|
||||
rec(today_start + 1, "a/m1", "m1", false), // today → out
|
||||
rec(yesterday_start.saturating_sub(1), "a/m1", "m1", false), // before → out
|
||||
];
|
||||
let stats = aggregate(&records, "yesterday", now);
|
||||
assert_eq!(stats.totals.requests, 1, "only yesterday's record counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_by_name_merges_providers_and_keeps_secondary() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
|
||||
let records = vec![
|
||||
rec(now, "openai/gpt-x", "gpt-x", false),
|
||||
rec(now, "azure/gpt-x", "gpt-x", false),
|
||||
rec(now, "__secondary__", "__secondary__", true),
|
||||
];
|
||||
let stats = aggregate(&records, "all", now);
|
||||
|
||||
// by_model keeps provider-qualified rows separate
|
||||
assert_eq!(stats.models.len(), 3);
|
||||
// by_name merges the two gpt-x rows, subagent stays its own row
|
||||
assert_eq!(stats.models_by_name.len(), 2);
|
||||
let gptx = stats.models_by_name.iter().find(|m| m.model == "gpt-x").expect("merged gpt-x row");
|
||||
assert_eq!(gptx.requests, 2);
|
||||
assert_eq!(gptx.total_tokens, 2 * 200);
|
||||
let sub = stats.models_by_name.iter().find(|m| m.model == "__secondary__").expect("secondary row");
|
||||
assert!(sub.is_secondary);
|
||||
// sorted by total_tokens desc — every record has equal tokens here, so
|
||||
// just assert the set is complete and totals add up.
|
||||
let total: u64 = stats.models_by_name.iter().map(|m| m.total_tokens).sum();
|
||||
assert_eq!(total, 3 * 200);
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,30 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// opencode Console Go (the "Go 套餐" gateway) rejects requests that lack an
|
||||
/// `x-opencode-session` header (400: required for routing and prompt-cache
|
||||
/// affinity). When exporting a provider pointing at the Go endpoint, ensure
|
||||
/// the header exists; an existing user-set value is kept as-is.
|
||||
fn ensure_opencode_go_session_header(provider: &Provider, pt: &mut Table) {
|
||||
let is_go = provider
|
||||
.base_url
|
||||
.as_deref()
|
||||
.map(|u| u.trim_end_matches('/').starts_with("https://opencode.ai/zen/go"))
|
||||
.unwrap_or(false);
|
||||
if !is_go {
|
||||
return;
|
||||
}
|
||||
let mut headers = pt
|
||||
.get("custom_headers")
|
||||
.and_then(|v| v.as_table())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
headers
|
||||
.entry("x-opencode-session".to_string())
|
||||
.or_insert_with(|| TomlValue::String("kimi-switch".to_string()));
|
||||
pt.insert("custom_headers".to_string(), TomlValue::Table(headers));
|
||||
}
|
||||
|
||||
/// Export Kimi Switch's internal `Config` to a Kimi Code TOML config value.
|
||||
///
|
||||
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
|
||||
@@ -316,6 +340,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
||||
pt.insert(k, v);
|
||||
}
|
||||
}
|
||||
ensure_opencode_go_session_header(provider, &mut pt);
|
||||
providers_table.insert(name.clone(), TomlValue::Table(pt));
|
||||
}
|
||||
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
||||
@@ -591,6 +616,70 @@ api_key = ""
|
||||
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_adds_opencode_go_session_header() {
|
||||
let make_provider = |base_url: &str, raw_other: Value| Provider {
|
||||
name: "p".to_string(),
|
||||
provider_type: ProviderType::Openai,
|
||||
base_url: Some(base_url.to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
active: false,
|
||||
icon: None,
|
||||
icon_color: None,
|
||||
raw_other,
|
||||
usage_kinds: None,
|
||||
usage_config: None,
|
||||
};
|
||||
let export = |p: Provider| {
|
||||
let mut providers = IndexMap::new();
|
||||
providers.insert("p".to_string(), p);
|
||||
let config = Config {
|
||||
default_model: None,
|
||||
providers,
|
||||
models: IndexMap::new(),
|
||||
raw_other: Value::Null,
|
||||
imported_section_keys: Vec::new(),
|
||||
};
|
||||
let exported = config_to_kimi_code(&config, None);
|
||||
let root = exported.as_table().unwrap().clone();
|
||||
root.get("providers")
|
||||
.and_then(|v| v.as_table())
|
||||
.and_then(|t| t.get("p"))
|
||||
.and_then(|v| v.as_table().cloned())
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Go endpoint without headers → header injected.
|
||||
let p = export(make_provider("https://opencode.ai/zen/go/v1", Value::Null));
|
||||
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
|
||||
assert_eq!(
|
||||
headers.get("x-opencode-session").and_then(|v| v.as_str()),
|
||||
Some("kimi-switch")
|
||||
);
|
||||
|
||||
// Existing user-set value is preserved, not overwritten.
|
||||
let raw = serde_json::json!({
|
||||
"custom_headers": { "x-opencode-session": "my-session" }
|
||||
});
|
||||
let p = export(make_provider("https://opencode.ai/zen/go/v1", raw));
|
||||
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
|
||||
assert_eq!(
|
||||
headers.get("x-opencode-session").and_then(|v| v.as_str()),
|
||||
Some("my-session")
|
||||
);
|
||||
|
||||
// PAYG zen endpoint and unrelated providers are left alone.
|
||||
let p = export(make_provider("https://opencode.ai/zen/v1", Value::Null));
|
||||
assert!(p.get("custom_headers").is_none());
|
||||
let p = export(make_provider("https://api.deepseek.com/v1", Value::Null));
|
||||
assert!(p.get("custom_headers").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_syncs_raw_other_top_level_sections() {
|
||||
// Regression: UI-managed top-level sections ([thinking],
|
||||
|
||||
@@ -97,6 +97,11 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
if url.contains("openrouter.ai") {
|
||||
kinds.push(UsageKind::BalanceOpenrouter);
|
||||
}
|
||||
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
|
||||
// 家族匹配意味着 ai. 子域也自然命中该规则。
|
||||
if url.contains("codingplan.site") {
|
||||
kinds.push(UsageKind::BalanceNewapi);
|
||||
}
|
||||
if url.contains("api.stepfun.com") {
|
||||
kinds.push(UsageKind::BalanceStepfun);
|
||||
}
|
||||
@@ -126,6 +131,19 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
|
||||
kinds
|
||||
}
|
||||
|
||||
/// BalanceNewapi 模板所需凭据(web 控制台 access token + user id)。
|
||||
/// 两者都必须存在且非空;返回 None 表示缺少凭据(确定性配置错误)。
|
||||
fn newapi_creds(
|
||||
usage_config: Option<&crate::models::UsageConfig>,
|
||||
) -> Option<(&str, &str)> {
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
/// 按 kind 路由到对应查询实现。`base_url` 用于消歧同一家供应商的
|
||||
/// 国内/海外站(SiliconFlow .cn/.com、MiniMax .com/.io、智谱 bigmodel/z.ai)。
|
||||
/// `usage_config` 仅 BalanceNewapi 分支读取(access_token / user_id),
|
||||
@@ -155,16 +173,13 @@ pub async fn query_kind(
|
||||
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()
|
||||
})?;
|
||||
// 缺凭据是确定性配置错误 → Ok(failure)(前端按前缀本地化提示),
|
||||
// 而非 Err(Err 语义 = 瞬时网络错误,见 commands.rs newapi 分支)。
|
||||
let Some((token, uid)) = newapi_creds(usage_config) else {
|
||||
return Ok(UsageResult::failure(
|
||||
"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())
|
||||
@@ -262,6 +277,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_provider_codingplan_site_family() {
|
||||
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
|
||||
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
|
||||
assert_eq!(
|
||||
detect_provider("https://codingplan.site"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://codingplan.site/v1"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://ai.codingplan.site"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
assert_eq!(
|
||||
detect_provider("https://ai.codingplan.site/v1"),
|
||||
vec![UsageKind::BalanceNewapi]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_kind_string_roundtrip() {
|
||||
use std::str::FromStr;
|
||||
@@ -272,4 +309,30 @@ mod tests {
|
||||
assert!(UsageKind::from_str("balance:unknown").is_err());
|
||||
assert!(UsageKind::from_str("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newapi_creds_requires_both_and_nonempty() {
|
||||
use crate::models::UsageConfig;
|
||||
let cfg = |token: Option<&str>, uid: Option<&str>| UsageConfig {
|
||||
enabled: true,
|
||||
template_type: "newapi".to_string(),
|
||||
base_url: None,
|
||||
access_token: token.map(str::to_string),
|
||||
user_id: uid.map(str::to_string),
|
||||
auto_query_interval_minutes: None,
|
||||
timeout_seconds: None,
|
||||
};
|
||||
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
|
||||
assert_eq!(newapi_creds(None), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(None, None))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), None))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(None, Some("uid")))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some(""), Some("uid")))), None);
|
||||
assert_eq!(newapi_creds(Some(&cfg(Some("tok"), Some("")))), None);
|
||||
// 两项齐全 → Some
|
||||
assert_eq!(
|
||||
newapi_creds(Some(&cfg(Some("tok"), Some("uid")))),
|
||||
Some(("tok", "uid"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.12",
|
||||
"version": "0.7.15",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -157,10 +157,13 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
|
||||
</Card>
|
||||
|
||||
<Card title={t("permissionRules")}>
|
||||
{/* kimi-code 0.40.1 `[permission] dangerous_command_guard`. The env
|
||||
{/* kimi-code `[permission] dangerous_command_guard`. The env
|
||||
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
|
||||
outranks this config at runtime; no env-lock UI here (deliberate
|
||||
minimal scope). Absent key = upstream default on. */}
|
||||
minimal scope). Absent key = upstream default on. Since
|
||||
kimi-code 0.41.0 the guard only gates Manual/YOLO modes —
|
||||
Auto (never-ask) mode no longer intercepts dangerous
|
||||
commands. */}
|
||||
<Checkbox
|
||||
label={t("permissionDangerousGuard")}
|
||||
checked={settings.permission?.dangerous_command_guard ?? true}
|
||||
|
||||
@@ -69,7 +69,8 @@ function ProviderCard({
|
||||
agent,
|
||||
provider.name,
|
||||
provider.usageKinds,
|
||||
provider.usageConfig?.autoQueryIntervalMinutes
|
||||
provider.usageConfig?.autoQueryIntervalMinutes,
|
||||
provider.usageConfig?.enabled === false
|
||||
);
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
|
||||
@@ -58,7 +58,6 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
|
||||
},
|
||||
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
|
||||
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
|
||||
file_history: { name: "flagFileHistory", desc: "flagFileHistoryDesc" },
|
||||
};
|
||||
|
||||
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
|
||||
|
||||
@@ -4,7 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
@@ -134,7 +134,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
|
||||
.map((d) => {
|
||||
const parts: string[] = [];
|
||||
if (d.planName) parts.push(planLabel(d.planName, t));
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${formatAmount(d.remaining, d.unit)}`);
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
|
||||
return parts.join(" ");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
|
||||
|
||||
interface UsageFooterProps {
|
||||
@@ -36,17 +36,6 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatAmount = (
|
||||
value: number | null | undefined,
|
||||
unit?: string | null
|
||||
): string => {
|
||||
if (value == null) return "—";
|
||||
const symbol =
|
||||
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit ? `${unit} ` : "";
|
||||
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return `${symbol}${num}`;
|
||||
};
|
||||
|
||||
const formatReset = (resetsAt?: string | null): string | null => {
|
||||
if (!resetsAt) return null;
|
||||
const ms = new Date(resetsAt).getTime() - now;
|
||||
|
||||
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
|
||||
import { DailyBars } from "./DailyBars";
|
||||
import { DailyDetailModal } from "./DailyDetailModal";
|
||||
import { Heatmap } from "./Heatmap";
|
||||
import { ModelTotalsChart } from "./ModelTotalsChart";
|
||||
import { TrendLineChart } from "./TrendLineChart";
|
||||
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
|
||||
|
||||
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
|
||||
const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
|
||||
|
||||
/** Per-request cache hit rate: cached tokens / total input tokens. */
|
||||
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
|
||||
@@ -59,13 +60,14 @@ export function DashboardPage() {
|
||||
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
|
||||
const [showAllModels, setShowAllModels] = useState(false);
|
||||
const [recentPage, setRecentPage] = useState(1);
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
|
||||
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily");
|
||||
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
|
||||
|
||||
const daily = data?.stats.daily ?? [];
|
||||
|
||||
const RANGE_LABELS: Record<DashboardRange, string> = {
|
||||
today: t("rangeToday"),
|
||||
yesterday: t("rangeYesterday"),
|
||||
"7d": t("range7d"),
|
||||
"30d": t("range30d"),
|
||||
all: t("rangeAll"),
|
||||
@@ -192,7 +194,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Range overview cards */}
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{RANGES.map((r) => {
|
||||
const tot = rangeTotals[r];
|
||||
if (!tot) return null;
|
||||
@@ -272,13 +274,26 @@ export function DashboardPage() {
|
||||
>
|
||||
{t("tabProviderTrend")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrendTab("totals")}
|
||||
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
|
||||
trendTab === "totals"
|
||||
? "border-blue-500 text-content-primary"
|
||||
: "border-transparent text-content-muted hover:text-content-primary"
|
||||
}`}
|
||||
>
|
||||
{t("tabModelTotals")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="pb-2 text-xs text-content-muted">
|
||||
{trendTab === "daily"
|
||||
? t("trendLineSub", { n: trendDaily.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
: trendTab === "totals"
|
||||
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
|
||||
: daily.length > 30
|
||||
? t("cardDailyTrendSubCapped", { n: daily.length })
|
||||
: t("cardDailyTrendSub", { n: daily.length })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Active tab content */}
|
||||
@@ -287,6 +302,10 @@ export function DashboardPage() {
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<TrendLineChart daily={trendDaily} />
|
||||
</div>
|
||||
) : trendTab === "totals" ? (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<ModelTotalsChart rows={data.stats.modelsByName} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[240px]">
|
||||
<DailyBars
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useTranslation } from "../../i18n";
|
||||
import { useCurrency } from "../../hooks/useCurrency";
|
||||
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
|
||||
import { modelColor } from "./DailyBars";
|
||||
import type { ModelRow } from "../../types/dashboard";
|
||||
|
||||
interface ModelTotalsChartProps {
|
||||
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
|
||||
rows: ModelRow[];
|
||||
}
|
||||
|
||||
/** Horizontal ranked bars: one row per bare model name, providers merged. */
|
||||
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const { money } = useCurrency();
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
|
||||
{t("noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1 overflow-y-auto">
|
||||
{rows.map((m, i) => {
|
||||
const color = i < 10 ? modelColor(i) : "#6b7280";
|
||||
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
|
||||
const tooltip = [
|
||||
name,
|
||||
`${t("kpiRequests")}: ${fmtInt(m.requests)}`,
|
||||
`${t("totalTokens")}: ${fmtInt(m.totalTokens)}`,
|
||||
`${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`,
|
||||
`${t("cost")}: ${money(m.costUsd)}`,
|
||||
].join("\n");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.model}
|
||||
title={tooltip}
|
||||
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
|
||||
{name}
|
||||
{m.costEstimated && (
|
||||
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
|
||||
<div
|
||||
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
|
||||
style={{
|
||||
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
|
||||
{fmtTokens(m.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type UsageKind =
|
||||
| "balance:stepfun"
|
||||
| "balance:novita"
|
||||
| "balance:kimi"
|
||||
| "balance:newapi"
|
||||
| "plan:kimi_coding"
|
||||
| "plan:zhipu"
|
||||
| "plan:minimax"
|
||||
@@ -30,6 +31,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
|
||||
"balance:stepfun",
|
||||
"balance:novita",
|
||||
"balance:kimi",
|
||||
"balance:newapi",
|
||||
"plan:kimi_coding",
|
||||
"plan:zhipu",
|
||||
"plan:minimax",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { SummaryResult } from "../types/dashboard";
|
||||
|
||||
export type DashboardRange = "today" | "7d" | "30d" | "all";
|
||||
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
|
||||
|
||||
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
|
||||
|
||||
@@ -10,7 +10,7 @@ export function useDashboard() {
|
||||
const [range, setRange] = useState<DashboardRange>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
|
||||
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
|
||||
return stored;
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -62,7 +62,8 @@ export function useUsageQuery(
|
||||
agent: Agent,
|
||||
providerName: string,
|
||||
usageKinds?: string[],
|
||||
autoIntervalMinutes?: number
|
||||
autoIntervalMinutes?: number,
|
||||
disabled?: boolean
|
||||
): UsageQueryState {
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
@@ -79,12 +80,13 @@ export function useUsageQuery(
|
||||
|
||||
const runQuery = useCallback(
|
||||
async (forceRefresh: boolean) => {
|
||||
if (disabled) return;
|
||||
const gen = ++genRef.current;
|
||||
setStatus("loading");
|
||||
setError(undefined);
|
||||
const finish = (entry: CacheEntry) => {
|
||||
cache.set(cacheKey, entry);
|
||||
if (genRef.current !== gen) return;
|
||||
cache.set(cacheKey, entry);
|
||||
setStatus(entry.status);
|
||||
setData(entry.data);
|
||||
setError(entry.error);
|
||||
@@ -129,11 +131,21 @@ export function useUsageQuery(
|
||||
});
|
||||
}
|
||||
},
|
||||
[agent, providerName, cacheKey]
|
||||
[agent, providerName, cacheKey, disabled]
|
||||
);
|
||||
|
||||
// On mount / provider change: serve fresh cache, otherwise query once.
|
||||
useEffect(() => {
|
||||
if (disabled) {
|
||||
// Toggle back off: stop immediately and return to idle so the footer
|
||||
// hides; bump gen so an in-flight response is dropped (no stale write).
|
||||
genRef.current += 1;
|
||||
setStatus("idle");
|
||||
setData([]);
|
||||
setError(undefined);
|
||||
setUpdatedAt(null);
|
||||
return;
|
||||
}
|
||||
if (!supported) return;
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
@@ -144,7 +156,7 @@ export function useUsageQuery(
|
||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||
}
|
||||
void runQuery(false);
|
||||
}, [supported, cacheKey, runQuery]);
|
||||
}, [supported, cacheKey, runQuery, disabled]);
|
||||
|
||||
// Ignore late responses after unmount.
|
||||
useEffect(() => {
|
||||
@@ -156,12 +168,12 @@ export function useUsageQuery(
|
||||
// Auto query interval — the hook is called once per provider card and both
|
||||
// variants read the same state, so there is no second mount to double-fire.
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
if (!supported || disabled) return;
|
||||
const mins = autoIntervalMinutes ?? 0;
|
||||
if (mins <= 0) return;
|
||||
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [supported, autoIntervalMinutes, runQuery]);
|
||||
}, [supported, autoIntervalMinutes, runQuery, disabled]);
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
+3
-4
@@ -153,7 +153,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
permissionAsk: "Ask",
|
||||
permissionDangerousGuard: "Dangerous Command Guard",
|
||||
permissionDangerousGuardDesc:
|
||||
"When on (default): Auto mode refuses dangerous commands (rm -rf, shutdown, dd of=, ...) and other modes always prompt; turning it off restores the previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD overrides this config",
|
||||
"When on (default): Manual/YOLO modes force a prompt for dangerous commands (rm -rf, shutdown, dd of=, ...) and commands that cannot be statically analyzed; Auto (never-ask) mode no longer intercepts them since kimi-code 0.41.0. Turning it off restores the previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD overrides this config",
|
||||
addRule: "+ Add rule",
|
||||
addCommonRules: "Add common rules",
|
||||
hooks: "Lifecycle Hooks",
|
||||
@@ -288,6 +288,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "Today",
|
||||
rangeYesterday: "Yesterday",
|
||||
range7d: "7d",
|
||||
range30d: "30d",
|
||||
rangeAll: "All",
|
||||
@@ -309,6 +310,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
|
||||
tabModelTrend: "Model usage trend",
|
||||
tabProviderTrend: "Provider–model usage trend",
|
||||
tabModelTotals: "Model usage totals",
|
||||
tabDailyTrend: "Daily usage trend",
|
||||
providerUnknown: "Unknown provider",
|
||||
cardDailyTrendSub: "{n} days",
|
||||
@@ -482,9 +484,6 @@ export const enTranslations: Record<TranslationKey, string> = {
|
||||
flagRemoteControlDesc: "Enable the experimental remote control feature",
|
||||
flagSearchWorker: "Search Worker",
|
||||
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
|
||||
flagFileHistory: "File History",
|
||||
flagFileHistoryDesc:
|
||||
"Snapshot edited files before/after each turn (last 5 turns across 30 sessions, stored in the session data dir) for real whole-file diffs",
|
||||
flagDefaultOn: "On by default",
|
||||
lockedByEnv: "Locked by env var",
|
||||
masterEnvOnHint:
|
||||
|
||||
+3
-4
@@ -151,7 +151,7 @@ export const zhTranslations = {
|
||||
permissionAsk: "询问",
|
||||
permissionDangerousGuard: "危险命令防护",
|
||||
permissionDangerousGuardDesc:
|
||||
"开启(默认)时:Auto 模式直接拒绝危险命令(rm -rf、shutdown、dd of= 等),其它模式强制询问;关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
|
||||
"开启(默认)时:Manual/YOLO 模式下,危险命令(rm -rf、shutdown、dd of= 等)和无法静态分析的命令会强制询问;自动(永不询问)模式自 kimi-code 0.41.0 起不再拦截。关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
|
||||
addRule: "+ 添加规则",
|
||||
addCommonRules: "添加常用规则",
|
||||
hooks: "生命周期钩子",
|
||||
@@ -283,6 +283,7 @@ export const zhTranslations = {
|
||||
|
||||
// Dashboard — ranges / status
|
||||
rangeToday: "今天",
|
||||
rangeYesterday: "昨天",
|
||||
range7d: "7 天",
|
||||
range30d: "30 天",
|
||||
rangeAll: "全部",
|
||||
@@ -304,6 +305,7 @@ export const zhTranslations = {
|
||||
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
|
||||
tabModelTrend: "模型用量趋势",
|
||||
tabProviderTrend: "供应商模型用量趋势",
|
||||
tabModelTotals: "模型用量统计",
|
||||
tabDailyTrend: "每日用量趋势",
|
||||
providerUnknown: "未知供应商",
|
||||
cardDailyTrendSub: "共 {n} 天",
|
||||
@@ -477,9 +479,6 @@ export const zhTranslations = {
|
||||
flagRemoteControlDesc: "启用实验性远程控制功能",
|
||||
flagSearchWorker: "搜索 Worker",
|
||||
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
|
||||
flagFileHistory: "文件历史",
|
||||
flagFileHistoryDesc:
|
||||
"记录每轮编辑文件的前后快照(最近 30 个会话×最近 5 轮,存于会话数据目录),用于真实的整文件 diff",
|
||||
flagDefaultOn: "默认开启",
|
||||
lockedByEnv: "被环境变量锁定",
|
||||
masterEnvOnHint:
|
||||
|
||||
+98651
-97147
File diff suppressed because it is too large.
Load diff
+81030
-79725
File diff suppressed because it is too large.
Load diff
@@ -455,11 +455,11 @@ describe("validateSubagentPool", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// [experimental] — flag registry (kimi-code 0.40.1) + write semantics
|
||||
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("experimental flag registry", () => {
|
||||
it("mirrors the 0.40.1 v2 registry (10 flags incl. file_history)", () => {
|
||||
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
|
||||
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
|
||||
"secondary-model",
|
||||
"tool-select",
|
||||
@@ -470,11 +470,10 @@ describe("experimental flag registry", () => {
|
||||
"auto_session_title",
|
||||
"remote-control",
|
||||
"search_worker",
|
||||
"file_history",
|
||||
]);
|
||||
const fh = EXPERIMENTAL_FLAGS.find((f) => f.id === "file_history");
|
||||
expect(fh?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_FILE_HISTORY");
|
||||
expect(fh?.defaultEnabled).toBeUndefined();
|
||||
// Upstream removed the flag in 0.41.0 (turn-level file history is
|
||||
// always on), so the adapter must not mirror it either.
|
||||
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
|
||||
});
|
||||
|
||||
it("secondary-model is on by default since 0.40.1", () => {
|
||||
|
||||
@@ -37,8 +37,9 @@ export interface ExperimentalFlagDef {
|
||||
* (the per-feature flag.ts files under packages/agent-core-v2/src:
|
||||
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
|
||||
* subagent_fork, wait_for, auto_session_title, remote-control,
|
||||
* search_worker, file_history). `acp-v2` was removed upstream and is
|
||||
* dropped here. Keep the env-var list in sync with
|
||||
* search_worker). `acp-v2` was removed upstream and is dropped here;
|
||||
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
|
||||
* history is always on, no flag). Keep the env-var list in sync with
|
||||
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
|
||||
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
{
|
||||
@@ -69,7 +70,6 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
|
||||
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{ id: "file_history", envVar: "KIMI_CODE_EXPERIMENTAL_FILE_HISTORY" },
|
||||
];
|
||||
|
||||
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAmount, localizeUsageError, planLabel } from "./usage-display";
|
||||
|
||||
// Minimal TranslateFn: return the key itself so assertions read the key.
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe("localizeUsageError", () => {
|
||||
it("maps known Rust error prefixes to their localization keys", () => {
|
||||
expect(
|
||||
localizeUsageError("unsupported provider: no usage query available", t)
|
||||
).toBe("usageUnsupportedProvider");
|
||||
expect(localizeUsageError("no API key configured", t)).toBe("usageErrNoKey");
|
||||
expect(localizeUsageError("usage query disabled in config panel", t)).toBe(
|
||||
"usageErrDisabled"
|
||||
);
|
||||
expect(
|
||||
localizeUsageError("no Kimi Code OAuth credentials found; run `kimi login` first", t)
|
||||
).toBe("usageErrNoOauth");
|
||||
expect(
|
||||
localizeUsageError("newapi template requires accessToken and userId", t)
|
||||
).toBe("usageErrNewapiCreds");
|
||||
expect(localizeUsageError("Authentication failed: 401 Unauthorized", t)).toBe(
|
||||
"usageInvalidKey"
|
||||
);
|
||||
});
|
||||
|
||||
it("login expired / refresh failed match case-insensitively anywhere", () => {
|
||||
expect(localizeUsageError("LOGIN expired at 12:00", t)).toBe("usageErrLoginExpired");
|
||||
expect(localizeUsageError("oauth refresh failed: 500", t)).toBe("usageErrLoginExpired");
|
||||
});
|
||||
|
||||
it("strips the kinds-loop `{kind}: ` wrapper before matching", () => {
|
||||
// commands.rs 的 kinds 循环把错误包成 "balance:newapi: ..." —— 必须仍命中。
|
||||
expect(
|
||||
localizeUsageError(
|
||||
"balance:newapi: newapi template requires accessToken and userId",
|
||||
t
|
||||
)
|
||||
).toBe("usageErrNewapiCreds");
|
||||
expect(localizeUsageError("plan:zhipu: Authentication failed: 401", t)).toBe(
|
||||
"usageInvalidKey"
|
||||
);
|
||||
expect(localizeUsageError("balance:deepseek: no API key configured", t)).toBe(
|
||||
"usageErrNoKey"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes unknown errors through unchanged (kind prefix retained)", () => {
|
||||
const raw = "balance:openrouter: HTTP 429 too many requests";
|
||||
expect(localizeUsageError(raw, t)).toBe(raw);
|
||||
expect(localizeUsageError("HTTP 500 Something broke", t)).toBe(
|
||||
"HTTP 500 Something broke"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planLabel", () => {
|
||||
it("maps known tier ids and passes unknown names through", () => {
|
||||
expect(planLabel("five_hour", t)).toBe("usageTier5h");
|
||||
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
|
||||
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
|
||||
expect(planLabel("NewAPI", t)).toBe("NewAPI");
|
||||
expect(planLabel("¥", t)).toBe("¥");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAmount", () => {
|
||||
it("maps CNY and USD to ¥/$ with two decimals", () => {
|
||||
expect(formatAmount(1.5, "CNY")).toBe("¥1.50");
|
||||
expect(formatAmount(1.5, "USD")).toBe("$1.50");
|
||||
});
|
||||
|
||||
it("treats ¤ (U+00A4) as no configured currency symbol", () => {
|
||||
expect(formatAmount(136907472.01013, "¤")).toBe("136907472.01");
|
||||
expect(formatAmount(136907472.01013, "\u{00A4}")).toBe("136907472.01");
|
||||
});
|
||||
|
||||
it("prefixes arbitrary units without a symbol", () => {
|
||||
// Non-integer so the two-decimal rule applies; rounds to 3.00.
|
||||
expect(formatAmount(3.004, "credit")).toBe("credit 3.00");
|
||||
});
|
||||
|
||||
it("renders null/undefined values as —", () => {
|
||||
expect(formatAmount(null, "¤")).toBe("—");
|
||||
expect(formatAmount(undefined, "CNY")).toBe("—");
|
||||
});
|
||||
|
||||
it("keeps integers without decimals", () => {
|
||||
expect(formatAmount(42, "CNY")).toBe("¥42");
|
||||
expect(formatAmount(100, "USD")).toBe("$100");
|
||||
expect(formatAmount(7, "")).toBe("7");
|
||||
});
|
||||
});
|
||||
@@ -35,12 +35,32 @@ export function planLabel(name: string, t: TranslateFn): string {
|
||||
* src-tauri/src/commands.rs and src-tauri/src/services/*.
|
||||
*/
|
||||
export function localizeUsageError(error: string, t: TranslateFn): string {
|
||||
if (error.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
|
||||
if (error.startsWith("no API key configured")) return t("usageErrNoKey");
|
||||
if (error.startsWith("usage query disabled")) return t("usageErrDisabled");
|
||||
if (/login expired|refresh failed/i.test(error)) return t("usageErrLoginExpired");
|
||||
if (error.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
|
||||
if (error.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
|
||||
if (error.startsWith("Authentication failed")) return t("usageInvalidKey");
|
||||
// commands.rs 的 kinds 循环把单条错误包成 `{kind}: {msg}`(kind 含数字,
|
||||
// 如 plan:zhipu),先剥离再按前缀匹配;未知错误仍回退按原文展示(保留 kind
|
||||
// 前缀,属诊断细节)。
|
||||
const stripped = error.replace(/^(balance|plan):[a-z0-9_]+:\s*/, "");
|
||||
if (stripped.startsWith("unsupported provider")) return t("usageUnsupportedProvider");
|
||||
if (stripped.startsWith("no API key configured")) return t("usageErrNoKey");
|
||||
if (stripped.startsWith("usage query disabled")) return t("usageErrDisabled");
|
||||
if (/login expired|refresh failed/i.test(stripped)) return t("usageErrLoginExpired");
|
||||
if (stripped.startsWith("no Kimi Code OAuth credentials")) return t("usageErrNoOauth");
|
||||
if (stripped.startsWith("newapi template requires")) return t("usageErrNewapiCreds");
|
||||
if (stripped.startsWith("Authentication failed")) return t("usageInvalidKey");
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a usage amount with its unit. `¤` (U+00A4) is NewAPI's placeholder
|
||||
* when the site has no custom currency symbol configured — treat it as "no
|
||||
* symbol" and render only the number.
|
||||
*/
|
||||
export function formatAmount(
|
||||
value: number | null | undefined,
|
||||
unit?: string | null
|
||||
): string {
|
||||
if (value == null) return "—";
|
||||
const symbol =
|
||||
unit === "CNY" ? "¥" : unit === "USD" ? "$" : unit && unit.trim() !== "¤" ? `${unit} ` : "";
|
||||
const num = Number.isInteger(value) ? String(value) : value.toFixed(2);
|
||||
return `${symbol}${num}`;
|
||||
}
|
||||
@@ -52,6 +52,8 @@ export interface RangeStats {
|
||||
totals: TotalsRow;
|
||||
daily: DailyRow[];
|
||||
models: ModelRow[];
|
||||
/** Model totals keyed by bare model name — the same model across providers merges into one row. */
|
||||
modelsByName: ModelRow[];
|
||||
recent: RecentRow[];
|
||||
recentTotal: number;
|
||||
recentLimit: number;
|
||||
|
||||
+5
-3
@@ -151,9 +151,11 @@ export interface AgentSettings {
|
||||
background?: BackgroundConfig;
|
||||
permission?: {
|
||||
rules?: PermissionRule[];
|
||||
/** kimi-code 0.40.1 `[permission]` key. Default true when the key is
|
||||
* absent: Auto mode refuses dangerous commands (rm -rf, shutdown,
|
||||
* dd of=, …) and other modes always prompt; false restores the
|
||||
/** kimi-code `[permission]` key. Default true when the key is
|
||||
* absent: since 0.41.0 the guard only gates Manual/YOLO modes —
|
||||
* dangerous commands (rm -rf, shutdown, dd of=, …) and commands
|
||||
* that cannot be statically analyzed force a prompt, while Auto
|
||||
* (never-ask) mode no longer intercepts them; false restores the
|
||||
* previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal
|
||||
* "true"/"false" only) outranks this config. */
|
||||
dangerous_command_guard?: boolean;
|
||||
|
||||
Reference in new issue
Block a user