Compare commits

...
4 Commits
Author SHA1 Message Date
KimiSwitch Dev fcd08eae32 feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-20 09:49:49 +08:00
KimiSwitch Dev 35581103ed feat(usage): plan:kimi_coding 兼容 kimi-code 0.43.1 quota 用量模型
服务端 api.kimi.com/coding/v1/usages 已由绝对 used/limit 行切换为
quota 模型(usages.limit_5h/limit_7d/limit_month_total/limit_month_code,
条目为 used_ratio 0-1 比率 + reset_time),见上游 #3787。

- parse_kimi_coding 优先解析新结构,旧 limits/usage 结构保留为回退
- used_ratio >1 时按已是百分数处理(防御)
- 新 tier id month_code + 前端 planLabel/i18n 中英文案
- 新增 3 个测试:四窗口解析 / 比率防御 / 空窗口回退旧结构
2026-09-16 14:14:29 +08:00
KimiSwitch Dev 1b80d66239 chore(flags): 适配 kimi-code 0.43.0 — 移除转正 auto_session_title 实验 flag — bump to v0.7.19
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-14 22:40:44 +08:00
KimiSwitch Dev 0f421b4e44 feat(sessions): 一键归档 + 归档用量快照落库 — bump to v0.7.18
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
- archive_sessions_before:按 updatedAt 截止点批量元数据归档(1月/半月/周/指定日期)
- 归档瞬间聚合 wire.jsonl 用量按天×模型快照进 SQLite archived_sessions 表
- 统计合并:文件已删除的归档会话由快照合成 UsageRecord 注入
  get_summary/日明细,趋势/热力图/模型统计全兼容且绝不双计
- models-dev 快照同步 2026-09-14(7784 模型);pricing 测试跟随
  deepseek-v4-flash 调价(0.15/0.60)
- cargo test 129 通过(含端到端归档→删目录→统计保留用例)、tsc 零错误
2026-09-14 01:52:21 +08:00
46 changed files with 9047 additions and 1007 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.17", "version": "0.7.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+14
View File
@@ -0,0 +1,14 @@
# KimiSwitch v0.7.18
## 新增
- **会话管理「一键归档」**:支持按 1 个月前 / 半个月前 / 1 星期前 / 指定日期,一键归档所有最后活跃时间早于所选时间点的会话(一次批量操作,替代逐个选择);结果反馈已归档 / 跳过数量
- **归档用量快照落库**:归档瞬间把每个会话的 token 用量与费用按「天 × 模型」粒度快照进 SQLite(`archived_sessions` 表)。之后删除已归档会话(释放磁盘)不再损失历史统计——仪表盘的趋势图、热力图、模型统计、区间合计均会自动合并快照数据,且与现存文件实时统计严格去重(只合并文件已删除的归档会话,绝不双计)
## 修复
- models-dev 快照同步至 2026-09-14(7784 模型 / 213 供应商),`public/` 前端静态目录同步;pricing 测试价格断言跟随 DeepSeek 官方调价(deepseek-v4-flash input 0.14→0.15 / output 0.28→0.60)
## 备注
- 归档仍是元数据语义(文件原地保留),删除文件才会触发快照合并;用量配置、账单查询等其余功能不受影响
+10
View File
@@ -0,0 +1,10 @@
# KimiSwitch v0.7.19
## 适配
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title` 开关(上游 #3749,AI 会话标题不再需要 flag,首轮对话后自动生成、可在重命名处重新生成)。设置页「实验性 flag」、环境变量探测(`get_experimental_env_status`)、中英文案与测试断言同步清理;上游 0.43.0 注册表现剩 5 个 flag: `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
- **兼容性核查结论(无需改动)**:0.43.0 的 wire 持久化重建(#3737)不影响用量统计——`usage.record` / `token_counting.measured` / `context.apply_compaction` / `turn.prompt` 记录全部保留(`turn.prompt` 仅新增可选 `turnId` 字段);新增配置项 `loop_control.compaction_max_attempts`(#3750)经现有合并式写入验证保存不丢失;MCP `deferred` 字段(#3667)不涉及本应用管理的配置节
## 备注
- 若此前在 `[experimental]` 中显式开启过 `auto_session_title`,残留键会被上游静默忽略,无需手动清理
+23
View File
@@ -0,0 +1,23 @@
# KimiSwitch v0.7.20
## 新增
- **套餐用量阈值预警**:用量配置面板新增「预警阈值 %」(0-100,0 或空 = 关闭)。每次账单查询后,任一百分比用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除;自动查询间隔会周期性重复评估
- **Kimi For Coding 加力钱包**:0.43.1 起服务端 quota 响应中的 `boosterWallet`(加力钱包)现在解析展示为一行余额(💰 月度用量 / 总额 / 余额,含币种),与四个用量窗口并列
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762——供应商编辑页「凭证来源」可切换「直接填写 / 环境变量」,密钥不写进 config.toml;导出时与 `api_key` 互斥;账单查询 / 获取模型列表会实时解析环境变量(变量未设置给出明确错误);SQLite 新增列(含自动迁移)
- **Kimi Code 版本跟踪器**:设置页新增「Kimi Code 版本」卡片——本机已装版本(`kimi --version` 检测)、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 KimiSwitch / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
- **供应商一键体检**:供应商列表新增「一键体检」按钮——并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重新查询账单,每行状态点 + 延迟 tooltip + 顶部汇总。状态判定为三态:账单或探活任一真实通过 = 绿;鉴权失败必红;端点不支持模型列表(404)计中性不误报
## 设置
- **压缩重试次数**:Agent 设置新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5,0/空不写键)
- **文件监听开关**:新增 `[watch] enabled` 开关(上游 2.0.1 #3892,2.0.2 起上游默认关闭),并探测 `KIMI_CODE_WATCH` 环境变量
## 适配
- **kimi-code 2.0.2 已适配确认**:wire 记录格式、实验 flag(5 个)、config 模式均无变化;`api_key_env` / `[watch]` / `[database]` 新增配置节经现有合并式写入验证无损往返
## 备注
- 版本跟踪器的「最新版本」查询直连 GitHub,受限网络环境下可能显示 `—`(已装版本与状态判定不受影响)
- 体检跳过大模型平台类供应商(anthropic / google-genai / vertexai 无 `/v1/models` 路由),显示灰点「未检测」
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.17" version = "0.7.20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.17" version = "0.7.20"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+951 -19
View File
File diff suppressed because it is too large. Load diff
+555 -11
View File
@@ -1,4 +1,5 @@
use chrono::{DateTime, Datelike, Local, TimeZone}; use chrono::{DateTime, Datelike, Local, TimeZone};
use crate::db::{ArchivedSessionSnapshot, DayModelStat, DayStat};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -277,6 +278,15 @@ pub struct ActionResponse {
pub deleted: Option<bool>, pub deleted: Option<bool>,
} }
/// Outcome of the bulk "archive everything older than X" command.
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct BulkArchiveResult {
pub archived: usize,
pub skipped: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PreviewResult { pub struct PreviewResult {
@@ -853,8 +863,30 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
Ok(c) => c, Ok(c) => c,
Err(_) => continue, Err(_) => continue,
}; };
lines_seen += content.lines().count();
records.extend(parse_wire_usage_records(&content, &secondary_alias, &alias2prov));
}
records.sort_by(|a, b| b.time.cmp(&a.time));
let count = records.len();
(records, ScanMeta {
files_scanned, lines_seen, record_count: count,
home: home.to_string_lossy().to_string(),
sessions_root: root.to_string_lossy().to_string(),
errors: errors.into_iter().take(20).collect(),
})
}
/// Parse one `wire.jsonl` body into usage records. Extracted from
/// `scan_usage` so a single session can be snapshotted without walking the
/// whole sessions tree; the record filter chain is unchanged.
fn parse_wire_usage_records(
content: &str,
secondary_alias: &Option<String>,
alias2prov: &HashMap<String, String>,
) -> Vec<UsageRecord> {
let mut records = Vec::new();
for line in content.lines() { for line in content.lines() {
lines_seen += 1;
if !line.contains("\"usage.record\"") { continue; } if !line.contains("\"usage.record\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(line) { let obj: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v, Ok(v) => v,
@@ -914,16 +946,109 @@ fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
price_id: pid, price_id: pid,
}); });
} }
} records
}
records.sort_by(|a, b| b.time.cmp(&a.time)); /// Aggregate raw usage records into the per-day, per-model snapshot stored
let count = records.len(); /// for an archived session.
(records, ScanMeta { fn snapshot_from_records(records: &[UsageRecord]) -> Vec<DayStat> {
files_scanned, lines_seen, record_count: count, let mut by_day: HashMap<String, (u64, HashMap<String, DayModelStat>)> = HashMap::new();
home: home.to_string_lossy().to_string(), for r in records {
sessions_root: root.to_string_lossy().to_string(), let entry = by_day
errors: errors.into_iter().take(20).collect(), .entry(day_key(r.time))
.or_insert_with(|| (local_midnight_ms(r.time, 0), HashMap::new()));
let m = entry.1.entry(r.model.clone()).or_insert_with(|| DayModelStat {
model: r.model.clone(),
requests: 0,
input_other: 0,
output: 0,
input_cache_read: 0,
input_cache_creation: 0,
cost_usd: 0.0,
});
m.requests += 1;
m.input_other += r.input_other;
m.output += r.output;
m.input_cache_read += r.input_cache_read;
m.input_cache_creation += r.input_cache_creation;
m.cost_usd += r.cost_usd;
}
let mut days: Vec<DayStat> = by_day
.into_iter()
.map(|(day, (day_start_ms, models))| {
let mut models: Vec<DayModelStat> = models.into_values().collect();
models.sort_by(|a, b| a.model.cmp(&b.model));
DayStat { day, day_start_ms, models }
}) })
.collect();
days.sort_by(|a, b| a.day.cmp(&b.day));
days
}
/// Synthesize usage records from stored archive snapshots. Only snapshots
/// whose session directory is gone are synthesized: sessions still on disk
/// are covered by the live wire.jsonl scan, and emitting both would double
/// count their usage.
fn synthesize_snapshot_records(rows: &[ArchivedSessionSnapshot], home: &Path) -> Vec<UsageRecord> {
let root = sessions_root(home);
let legacy_root = root.join(".kcd-archive");
let alias2prov = build_alias_provider_map(home);
let secondary_alias = read_secondary_model_alias(home);
let mut out = Vec::new();
for row in rows {
if root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
if legacy_root.join(&row.workspace_id).join(&row.session_id).exists() { continue; }
for day in &row.day_stats {
for m in &day.models {
let is_secondary = m.model == "__secondary__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| m.model.clone())
} else {
m.model.clone()
};
let bare = m.model.rsplit_once('/').map(|x| x.1).unwrap_or(&m.model).to_string();
out.push(UsageRecord {
time: day.day_start_ms,
model: m.model.clone(),
model_resolved: bare,
model_display: m.model.clone(),
provider: resolve_provider(&price_model, &alias2prov),
from_env: m.model == "__kimi_env_model__",
is_secondary,
input_other: m.input_other,
output: m.output,
input_cache_read: m.input_cache_read,
input_cache_creation: m.input_cache_creation,
cost_usd: m.cost_usd,
cost_estimated: true,
price_id: String::new(),
});
}
}
}
out
}
/// Append the synthesized records for `rows` to a live scan result and
/// restore the newest-first ordering `scan_usage` produces.
fn merge_snapshot_records(
mut records: Vec<UsageRecord>,
rows: &[ArchivedSessionSnapshot],
home: &Path,
) -> Vec<UsageRecord> {
// Nothing archived yet: keep the scan result untouched (and skip the
// config reads the synthesis would do).
if rows.is_empty() { return records; }
records.extend(synthesize_snapshot_records(rows, home));
records.sort_by(|a, b| b.time.cmp(&a.time));
records
}
/// Live scan records plus the stored usage of archived sessions whose files
/// have been deleted, so dashboard stats survive session deletion.
fn records_with_archive_merge(home: &Path, records: Vec<UsageRecord>) -> Vec<UsageRecord> {
let rows = crate::db::list_archived_sessions().unwrap_or_default();
merge_snapshot_records(records, &rows, home)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1332,6 +1457,147 @@ fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), S
Ok(()) Ok(())
} }
/// Parse a `state.json` timestamp into epoch ms. kimi-code writes epoch-ms
/// numbers, other builds write ISO-8601 strings — accept both, plus numeric
/// strings.
fn parse_state_time_ms(value: &serde_json::Value) -> Option<u64> {
match value {
serde_json::Value::Number(n) => n.as_u64().or_else(|| n.as_f64().map(|f| f.max(0.0) as u64)),
serde_json::Value::String(s) => parse_time_string_ms(s),
_ => None,
}
}
fn parse_time_string_ms(s: &str) -> Option<u64> {
let t = s.trim();
if t.is_empty() { return None; }
if let Ok(n) = t.parse::<u64>() {
// Numeric string: epoch ms when it is already millisecond-sized.
return Some(if n >= 1e12 as u64 { n } else { n * 1000 });
}
if let Ok(dt) = DateTime::parse_from_rfc3339(t) {
return Some(dt.timestamp_millis().max(0) as u64);
}
for fmt in [
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
] {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(t, fmt) {
return Some(naive.and_utc().timestamp_millis().max(0) as u64);
}
}
None
}
/// Read a session's `state.json` as a JSON object (None when missing or
/// unparseable).
fn read_state_obj(session_dir: &Path) -> Option<serde_json::Value> {
let content = fs::read_to_string(session_dir.join("state.json")).ok()?;
let obj: serde_json::Value = serde_json::from_str(&content).ok()?;
if obj.is_object() { Some(obj) } else { None }
}
/// Session directory mtime in epoch ms — the fallback when `state.json` has
/// no parseable `updatedAt`.
fn dir_mtime_ms(dir: &Path) -> Option<u64> {
dir.metadata().ok().and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
}
/// Usage records of one session: every `wire.jsonl` below its directory
/// (agent subdirectories included), with the same blob/task filtering the
/// full-tree scan applies.
fn parse_session_wire_usage(
dir: &Path,
secondary_alias: &Option<String>,
alias2prov: &HashMap<String, String>,
) -> Vec<UsageRecord> {
let mut records = Vec::new();
for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) {
if entry.file_name() != "wire.jsonl" || !entry.file_type().is_file() { continue; }
let p = entry.path();
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") { continue; }
let Ok(content) = fs::read_to_string(p) else { continue };
records.extend(parse_wire_usage_records(&content, secondary_alias, alias2prov));
}
records
}
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
/// (epoch ms) and store a usage snapshot per session in SQLite, so dashboard
/// stats keep counting their history after the directories are deleted.
/// Sessions already archived, in `.kcd-archive`, or without a readable
/// `state.json` are left alone.
fn archive_sessions_before_cmd(home: &Path, cutoff_ms: u64) -> BulkArchiveResult {
let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home);
let secondary_alias = read_secondary_model_alias(home);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let mut result = BulkArchiveResult { archived: 0, skipped: 0, errors: Vec::new() };
for wid in list_workspace_dirs(home) {
for entry in safe_read_dir(&root.join(&wid)) {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let sid = entry.file_name().to_string_lossy().to_string();
if !session_re().is_match(&sid) { continue; }
let dir = entry.path();
let Some(state) = read_state_obj(&dir) else {
result.skipped += 1;
continue;
};
if state.get("archived").and_then(|v| v.as_bool()).unwrap_or(false) { continue; }
let Some(updated_ms) = state
.get("updatedAt")
.and_then(parse_state_time_ms)
.or_else(|| dir_mtime_ms(&dir))
else {
result.skipped += 1;
continue;
};
if updated_ms >= cutoff_ms {
result.skipped += 1;
continue;
}
if let Err(e) = set_session_archived_meta(&dir, true) {
result.errors.push(format!("{wid}/{sid}: {e}"));
continue;
}
let day_stats = snapshot_from_records(&parse_session_wire_usage(
&dir, &secondary_alias, &alias2prov,
));
let mut total_tokens = 0u64;
let mut total_cost_usd = 0.0f64;
for day in &day_stats {
for m in &day.models {
total_tokens += m.input_other + m.output + m.input_cache_read + m.input_cache_creation;
total_cost_usd += m.cost_usd;
}
}
let snapshot = ArchivedSessionSnapshot {
session_id: sid.clone(),
workspace_id: wid.clone(),
title: state.get("title").and_then(|v| v.as_str()).map(|s| s.to_string()),
archived_at_ms: now_ms,
updated_at_ms: Some(updated_ms),
created_at_ms: state.get("createdAt").and_then(parse_state_time_ms),
total_tokens,
total_cost_usd,
day_stats,
};
result.archived += 1;
if let Err(e) = crate::db::upsert_archived_session(&snapshot) {
result.errors.push(format!("{wid}/{sid}: snapshot: {e}"));
}
}
}
result
}
fn humanize_workspace(id: &str) -> String { fn humanize_workspace(id: &str) -> String {
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap(); let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
if let Some(caps) = re.captures(id) { if let Some(caps) = re.captures(id) {
@@ -1876,6 +2142,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
let r = range.unwrap_or_else(|| "30d".into()); let r = range.unwrap_or_else(|| "30d".into());
let (records, meta) = scan_usage_cached(&home, refresh); let (records, meta) = scan_usage_cached(&home, refresh);
let records = records_with_archive_merge(&home, records);
let t1 = std::time::Instant::now(); let t1 = std::time::Instant::now();
let stats = aggregate(&records, &r, now_ms); let stats = aggregate(&records, &r, now_ms);
let all_stats = aggregate(&records, "all", now_ms); let all_stats = aggregate(&records, "all", now_ms);
@@ -1983,6 +2250,7 @@ fn build_day_detail(date: &str, records: &[UsageRecord]) -> Option<DailyRow> {
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> { pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
let home = resolve_kimi_home(home_override); let home = resolve_kimi_home(home_override);
let (records, _) = scan_usage_cached(&home, false); let (records, _) = scan_usage_cached(&home, false);
let records = records_with_archive_merge(&home, records);
build_day_detail(&date, &records) build_day_detail(&date, &records)
} }
@@ -1998,6 +2266,14 @@ pub fn archive_session(home_override: Option<String>, workspace_id: String, sess
archive_session_cmd(&home, &workspace_id, &session_id) archive_session_cmd(&home, &workspace_id, &session_id)
} }
/// Bulk-archive every active session whose last activity predates `cutoff_ms`
/// (epoch ms) across all workspaces, storing a usage snapshot per session.
#[tauri::command]
pub fn archive_sessions_before(home_override: Option<String>, cutoff_ms: u64) -> BulkArchiveResult {
let home = resolve_kimi_home(home_override);
archive_sessions_before_cmd(&home, cutoff_ms)
}
#[tauri::command] #[tauri::command]
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> { pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override); let home = resolve_kimi_home(home_override);
@@ -2073,8 +2349,8 @@ mod pricing_tests {
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash"); let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
assert!(!est, "resolved via models.dev, not estimated"); assert!(!est, "resolved via models.dev, not estimated");
assert_eq!(id, "deepseek/deepseek-v4-flash"); assert_eq!(id, "deepseek/deepseek-v4-flash");
assert_eq!(input, 0.14); assert_eq!(input, 0.15);
assert_eq!(output, 0.28); assert_eq!(output, 0.6);
} }
#[test] #[test]
@@ -2432,3 +2708,271 @@ mod range_and_model_totals_tests {
assert_eq!(total, 3 * 200); assert_eq!(total, 3 * 200);
} }
} }
#[cfg(test)]
mod archive_snapshot_tests {
use super::*;
const WID: &str = "wd_proj_a1b2c3d4e5f6";
const LIVE_SID: &str = "session_11111111-2222-3333-4444-555555555555";
const GONE_SID: &str = "session_99999999-8888-7777-6666-555555555555";
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64
}
fn rec(time: u64, model: &str, tokens: (u64, u64, u64, u64), cost: f64) -> UsageRecord {
UsageRecord {
time,
model: model.to_string(),
input_other: tokens.0,
output: tokens.1,
input_cache_read: tokens.2,
input_cache_creation: tokens.3,
cost_usd: cost,
cost_estimated: false,
price_id: String::new(),
model_resolved: model.rsplit_once('/').map(|x| x.1).unwrap_or(model).to_string(),
model_display: model.to_string(),
provider: None,
from_env: false,
is_secondary: false,
}
}
fn snapshot_row(
sid: &str,
day_start_ms: u64,
model: &str,
tokens: u64,
) -> ArchivedSessionSnapshot {
ArchivedSessionSnapshot {
session_id: sid.to_string(),
workspace_id: WID.to_string(),
title: None,
archived_at_ms: 0,
updated_at_ms: None,
created_at_ms: None,
total_tokens: tokens,
total_cost_usd: 0.0,
day_stats: vec![DayStat {
day: day_key(day_start_ms),
day_start_ms,
models: vec![DayModelStat {
model: model.to_string(),
requests: 2,
input_other: tokens,
output: 0,
input_cache_read: 0,
input_cache_creation: 0,
cost_usd: 1.5,
}],
}],
}
}
fn write_state(dir: &Path, updated_ms: u64, archived: bool) {
let state = serde_json::json!({
"id": dir.file_name().unwrap().to_string_lossy(),
"createdAt": updated_ms,
"updatedAt": updated_ms,
"archived": archived,
"title": "archived candidate",
});
fs::write(dir.join("state.json"), state.to_string()).unwrap();
}
fn read_state(dir: &Path) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(dir.join("state.json")).unwrap()).unwrap()
}
#[test]
fn snapshot_groups_records_by_local_day_and_model() {
let now = now_ms();
let d1 = local_midnight_ms(now, 2);
let d2 = local_midnight_ms(now, 1);
let records = vec![
rec(d1 + 3_600_000, "openai/gpt-x", (100, 10, 5, 1), 0.5),
rec(d1 + 7_200_000, "openai/gpt-x", (200, 20, 0, 0), 0.25),
rec(d1 + 60_000, "moonshotai/kimi-k3", (1, 2, 3, 4), 0.01),
rec(d2 + 1_000, "openai/gpt-x", (7, 7, 7, 7), 0.07),
];
let days = snapshot_from_records(&records);
assert_eq!(days.len(), 2, "one bucket per local day");
let first = &days[0];
assert_eq!(first.day, day_key(d1));
assert_eq!(first.day_start_ms, d1, "day bucket starts at local midnight");
assert_eq!(first.models.len(), 2);
let gpt = first.models.iter().find(|m| m.model == "openai/gpt-x").unwrap();
assert_eq!(gpt.requests, 2);
assert_eq!(gpt.input_other, 300);
assert_eq!(gpt.output, 30);
assert_eq!(gpt.input_cache_read, 5);
assert_eq!(gpt.input_cache_creation, 1);
assert_eq!(gpt.cost_usd, 0.75);
// Deterministic ordering: models sorted by raw model key.
assert_eq!(first.models[0].model, "moonshotai/kimi-k3");
assert_eq!(days[1].day, day_key(d2));
assert_eq!(days[1].day_start_ms, d2);
assert_eq!(days[1].models.len(), 1);
assert_eq!(days[1].models[0].input_other, 7);
}
#[test]
fn synthesize_only_covers_sessions_whose_files_are_gone() {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
let day_start = 1_700_000_000_000u64;
let rows = vec![
snapshot_row(LIVE_SID, day_start, "openai/gpt-x", 100),
snapshot_row(GONE_SID, day_start, "openai/gpt-x", 200),
];
let out = synthesize_snapshot_records(&rows, &home);
assert_eq!(out.len(), 1, "live session dirs stay with the live scan");
let r = &out[0];
assert_eq!(r.time, day_start);
assert_eq!(r.model, "openai/gpt-x");
assert_eq!(r.model_resolved, "gpt-x");
assert_eq!(r.model_display, "openai/gpt-x");
assert_eq!(r.provider.as_deref(), Some("openai"));
assert_eq!(r.input_other, 200);
assert_eq!(r.cost_usd, 1.5);
assert!(r.cost_estimated, "snapshot costs are estimates");
assert_eq!(r.price_id, "");
assert!(!r.is_secondary);
assert!(!r.from_env);
}
#[test]
fn merge_appends_missing_sessions_newest_first_without_double_counting() {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
fs::create_dir_all(home.join("sessions").join(WID).join(LIVE_SID)).unwrap();
let now = now_ms();
let old_day = local_midnight_ms(now, 5);
let new_day = local_midnight_ms(now, 1);
let gone_old = "session_22222222-2222-3333-4444-555555555555";
let gone_new = "session_33333333-2222-3333-4444-555555555555";
let rows = vec![
snapshot_row(LIVE_SID, new_day, "openai/gpt-x", 999),
snapshot_row(gone_old, old_day, "openai/gpt-x", 10),
snapshot_row(gone_new, new_day, "openai/gpt-x", 20),
];
let live = vec![rec(new_day + 5_000, "openai/gpt-x", (1, 1, 1, 1), 0.01)];
let merged = merge_snapshot_records(live, &rows, &home);
assert_eq!(merged.len(), 3, "live record plus the two missing sessions");
assert_eq!(merged[0].time, new_day + 5_000, "newest record first");
assert_eq!(merged[1].time, new_day);
assert_eq!(merged[2].time, old_day);
assert_eq!(merged[1].input_other, 20);
assert_eq!(merged[2].input_other, 10);
assert!(
merged.iter().all(|r| r.input_other != 999),
"a live session's stored snapshot must never be synthesized"
);
}
#[test]
fn state_times_parse_epoch_numbers_and_iso_strings() {
assert_eq!(
parse_state_time_ms(&serde_json::json!(1_700_000_000_000u64)),
Some(1_700_000_000_000)
);
assert_eq!(
parse_state_time_ms(&serde_json::json!("1700000000000")),
Some(1_700_000_000_000)
);
assert_eq!(
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05.000Z")),
Some(1_735_787_045_000)
);
assert_eq!(
parse_state_time_ms(&serde_json::json!("2025-01-02T03:04:05")),
Some(1_735_787_045_000)
);
assert_eq!(
parse_state_time_ms(&serde_json::json!("2025-01-02 03:04:05")),
Some(1_735_787_045_000)
);
assert_eq!(parse_state_time_ms(&serde_json::json!(null)), None);
assert_eq!(parse_state_time_ms(&serde_json::json!("not-a-date")), None);
}
#[test]
fn bulk_archive_flags_old_sessions_and_snapshots_their_usage() {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
// Redirect the SQLite store so the test never touches the user's DB.
std::env::set_var("KIMI_SWITCH_DB_PATH", td.path().join("test.db"));
let ws = home.join("sessions").join(WID);
let now = now_ms();
let old_ms = now - 60 * 24 * 3600 * 1000;
let old_dir = ws.join(LIVE_SID);
fs::create_dir_all(&old_dir).unwrap();
write_state(&old_dir, old_ms, false);
let wire = serde_json::json!({
"type": "usage.record",
"usageScope": "turn",
"time": old_ms,
"model": "openai/gpt-x",
"usage": {
"inputOther": 100,
"output": 50,
"inputCacheRead": 10,
"inputCacheCreation": 5,
},
});
fs::write(old_dir.join("wire.jsonl"), format!("{wire}\n")).unwrap();
let new_sid = "session_44444444-2222-3333-4444-555555555555";
let new_dir = ws.join(new_sid);
fs::create_dir_all(&new_dir).unwrap();
write_state(&new_dir, now - 3_600_000, false);
let cutoff = now - 30 * 24 * 3600 * 1000;
let res = archive_sessions_before_cmd(&home, cutoff);
assert_eq!(res.archived, 1, "only the stale session is archived");
assert_eq!(res.skipped, 1, "the recent session is reported as skipped");
assert!(res.errors.is_empty(), "{:?}", res.errors);
// Metadata archive happened in place, the recent session is untouched.
assert_eq!(read_state(&old_dir)["archived"], true);
assert!(read_state(&old_dir)["archivedAt"].as_u64().unwrap() > 0);
assert_eq!(read_state(&new_dir)["archived"], false);
// Usage snapshot landed in SQLite with the session's totals.
let rows = crate::db::list_archived_sessions().unwrap();
let row = rows.iter().find(|r| r.session_id == LIVE_SID).expect("snapshot row");
assert_eq!(row.workspace_id, WID);
assert_eq!(row.title.as_deref(), Some("archived candidate"));
assert_eq!(row.updated_at_ms, Some(old_ms));
assert_eq!(row.total_tokens, 165);
assert!(row.total_cost_usd > 0.0);
assert_eq!(row.day_stats.len(), 1);
assert_eq!(row.day_stats[0].day, day_key(old_ms));
assert_eq!(row.day_stats[0].models[0].requests, 1);
assert_eq!(row.day_stats[0].models[0].input_other, 100);
// While the files are still there, the live scan owns the usage.
let merged = records_with_archive_merge(&home, vec![]);
assert!(merged.is_empty(), "no synthesized records while the dir exists");
// Once the archived directory is deleted, stats come from the snapshot.
fs::remove_dir_all(&old_dir).unwrap();
let merged = records_with_archive_merge(&home, vec![]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].input_other, 100);
assert_eq!(merged[0].output, 50);
assert_eq!(day_key(merged[0].time), day_key(old_ms));
}
}
+146 -12
View File
@@ -10,6 +10,7 @@ use std::path::PathBuf;
use anyhow::Context; use anyhow::Context;
use indexmap::IndexMap; use indexmap::IndexMap;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::models::{Agent, Config, Model, Provider, ProviderType}; use crate::models::{Agent, Config, Model, Provider, ProviderType};
@@ -23,6 +24,13 @@ pub fn kimi_switch_data_dir() -> PathBuf {
} }
pub fn db_path() -> PathBuf { pub fn db_path() -> PathBuf {
// Tests (and power users) can point the store elsewhere; production always
// resolves to the default location under the home directory.
if let Some(p) = std::env::var_os("KIMI_SWITCH_DB_PATH") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
kimi_switch_data_dir().join("kimi-switch.db") kimi_switch_data_dir().join("kimi-switch.db")
} }
@@ -31,6 +39,10 @@ pub fn db_path() -> PathBuf {
/// configuration. Safe to call on every startup — it is a no-op once the new /// configuration. Safe to call on every startup — it is a no-op once the new
/// directory exists. /// directory exists.
fn migrate_legacy_data_dir() { fn migrate_legacy_data_dir() {
// A redirected store (tests) must never touch the real home directory.
if std::env::var_os("KIMI_SWITCH_DB_PATH").is_some() {
return;
}
let new_dir = kimi_switch_data_dir(); let new_dir = kimi_switch_data_dir();
if new_dir.exists() { if new_dir.exists() {
return; return;
@@ -69,6 +81,7 @@ pub fn init_db() -> DbResult<Connection> {
provider_type TEXT NOT NULL, provider_type TEXT NOT NULL,
base_url TEXT, base_url TEXT,
api_key TEXT, api_key TEXT,
api_key_env TEXT,
env TEXT, env TEXT,
note TEXT, note TEXT,
official_url TEXT, official_url TEXT,
@@ -106,11 +119,32 @@ pub fn init_db() -> DbResult<Connection> {
[], [],
)?; )?;
// Usage snapshots taken when sessions are bulk-archived: keep the session's
// token/cost history queryable after its directory (and wire.jsonl) is
// deleted from disk.
conn.execute(
"CREATE TABLE IF NOT EXISTS archived_sessions (
session_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
title TEXT,
archived_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER,
created_at_ms INTEGER,
total_tokens INTEGER NOT NULL DEFAULT 0,
total_cost_usd REAL NOT NULL DEFAULT 0,
day_stats TEXT NOT NULL DEFAULT '[]'
)",
[],
)?;
// Add icon columns for provider icon picker (introduced in v0.3.1). // Add icon columns for provider icon picker (introduced in v0.3.1).
// SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors. // SQLite has no ADD COLUMN IF NOT EXISTS, so ignore duplicate-column errors.
for stmt in [ for stmt in [
"ALTER TABLE providers ADD COLUMN icon TEXT", "ALTER TABLE providers ADD COLUMN icon TEXT",
"ALTER TABLE providers ADD COLUMN icon_color TEXT", "ALTER TABLE providers ADD COLUMN icon_color TEXT",
// `api_key_env` (kimi-code 2.0.0+): the name of the environment
// variable holding the credential, an alternative to `api_key`.
"ALTER TABLE providers ADD COLUMN api_key_env TEXT",
] { ] {
if let Err(e) = conn.execute(stmt, []) { if let Err(e) = conn.execute(stmt, []) {
let msg = e.to_string(); let msg = e.to_string();
@@ -132,28 +166,29 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut providers = IndexMap::new(); let mut providers = IndexMap::new();
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other "SELECT name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
FROM providers WHERE agent = ?1 ORDER BY id", FROM providers WHERE agent = ?1 ORDER BY id",
)?; )?;
let provider_rows = stmt.query_map(params![agent.as_str()], |row| { let provider_rows = stmt.query_map(params![agent.as_str()], |row| {
let provider_type: String = row.get(1)?; let provider_type: String = row.get(1)?;
let env_json: Option<String> = row.get(4)?; let env_json: Option<String> = row.get(5)?;
let raw_json: Option<String> = row.get(12)?; let raw_json: Option<String> = row.get(13)?;
Ok(Provider { Ok(Provider {
name: row.get(0)?, name: row.get(0)?,
provider_type: provider_type_for_str(&provider_type), provider_type: provider_type_for_str(&provider_type),
base_url: row.get(2)?, base_url: row.get(2)?,
api_key: row.get(3)?, api_key: row.get(3)?,
api_key_env: row.get(4)?,
env: env_json env: env_json
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(), .unwrap_or_default(),
note: row.get(5)?, note: row.get(6)?,
official_url: row.get(6)?, official_url: row.get(7)?,
managed: row.get::<_, i32>(7)? != 0, managed: row.get::<_, i32>(8)? != 0,
enabled: row.get::<_, i32>(8)? != 0, enabled: row.get::<_, i32>(9)? != 0,
active: row.get::<_, i32>(9)? != 0, active: row.get::<_, i32>(10)? != 0,
icon: row.get(10)?, icon: row.get(11)?,
icon_color: row.get(11)?, icon_color: row.get(12)?,
raw_other: raw_json raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
@@ -223,8 +258,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
{ {
let mut insert_provider = tx.prepare( let mut insert_provider = tx.prepare(
"INSERT INTO providers "INSERT INTO providers
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other) (agent, name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?; )?;
for provider in config.providers.values() { for provider in config.providers.values() {
@@ -234,6 +269,7 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
provider.provider_type.as_str(), provider.provider_type.as_str(),
provider.base_url, provider.base_url,
provider.api_key, provider.api_key,
provider.api_key_env,
serde_json::to_string(&provider.env).ok(), serde_json::to_string(&provider.env).ok(),
provider.note, provider.note,
provider.official_url, provider.official_url,
@@ -330,6 +366,104 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
Ok(()) Ok(())
} }
// ---------------------------------------------------------------------------
// Archived-session usage snapshots
// ---------------------------------------------------------------------------
/// Per-model usage totals for one calendar day of an archived session.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayModelStat {
/// Raw model key as written to wire.jsonl (`__secondary__` kept verbatim).
pub model: String,
pub requests: u64,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
}
/// One day's per-model usage, stored as JSON in `archived_sessions.day_stats`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DayStat {
/// Local calendar day, `YYYY-MM-DD`.
pub day: String,
/// Local midnight of `day`, epoch ms — used as the synthesized record time.
pub day_start_ms: u64,
pub models: Vec<DayModelStat>,
}
/// Usage snapshot of one session, taken at the moment it is archived.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArchivedSessionSnapshot {
pub session_id: String,
pub workspace_id: String,
pub title: Option<String>,
pub archived_at_ms: u64,
pub updated_at_ms: Option<u64>,
pub created_at_ms: Option<u64>,
pub total_tokens: u64,
pub total_cost_usd: f64,
pub day_stats: Vec<DayStat>,
}
/// Insert or replace one archived-session snapshot (keyed by session id, which
/// is a UUID and never reused).
pub fn upsert_archived_session(row: &ArchivedSessionSnapshot) -> DbResult<()> {
let conn = init_db()?;
let day_stats = serde_json::to_string(&row.day_stats)?;
conn.execute(
"INSERT OR REPLACE INTO archived_sessions
(session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms, total_tokens, total_cost_usd, day_stats)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
row.session_id,
row.workspace_id,
row.title,
row.archived_at_ms as i64,
row.updated_at_ms.map(|v| v as i64),
row.created_at_ms.map(|v| v as i64),
row.total_tokens as i64,
row.total_cost_usd,
day_stats,
],
)?;
Ok(())
}
/// Every stored archived-session snapshot. Unreadable `day_stats` JSON degrades
/// to an empty breakdown instead of failing the whole list.
pub fn list_archived_sessions() -> DbResult<Vec<ArchivedSessionSnapshot>> {
let conn = init_db()?;
let mut stmt = conn.prepare(
"SELECT session_id, workspace_id, title, archived_at_ms, updated_at_ms, created_at_ms,
total_tokens, total_cost_usd, day_stats
FROM archived_sessions",
)?;
let rows = stmt.query_map([], |row| {
let day_stats_json: String = row.get(8)?;
Ok(ArchivedSessionSnapshot {
session_id: row.get(0)?,
workspace_id: row.get(1)?,
title: row.get(2)?,
archived_at_ms: row.get::<_, i64>(3)? as u64,
updated_at_ms: row.get::<_, Option<i64>>(4)?.map(|v| v as u64),
created_at_ms: row.get::<_, Option<i64>>(5)?.map(|v| v as u64),
total_tokens: row.get::<_, i64>(6)? as u64,
total_cost_usd: row.get(7)?,
day_stats: serde_json::from_str(&day_stats_json).unwrap_or_default(),
})
})?;
let mut out = Vec::new();
for row in rows {
out.push(row?);
}
Ok(out)
}
fn provider_type_for_str(s: &str) -> ProviderType { fn provider_type_for_str(s: &str) -> ProviderType {
ProviderType::from_kimi_type(s) ProviderType::from_kimi_type(s)
} }
+148 -2
View File
@@ -153,6 +153,14 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string()); let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
// kimi-code 2.0.0+: `api_key_env` names the environment variable
// holding the credential; it is mutually exclusive with `api_key`.
// Captured as its own field (not left in raw_other) so export can
// enforce the exclusivity and the UI can edit it.
let api_key_env = table
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let managed = table.contains_key("oauth") let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false); || table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -181,6 +189,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
rest.remove("type"); rest.remove("type");
rest.remove("base_url"); rest.remove("base_url");
rest.remove("api_key"); rest.remove("api_key");
rest.remove("api_key_env");
rest.remove("managed"); rest.remove("managed");
rest.remove("enabled"); rest.remove("enabled");
rest.remove("env"); rest.remove("env");
@@ -200,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
provider_type, provider_type,
base_url: base_url.filter(|s| !s.is_empty()), base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()), api_key: api_key.filter(|s| !s.is_empty()),
api_key_env: api_key_env.filter(|s| !s.is_empty()),
env, env,
note: None, note: None,
official_url: None, official_url: None,
@@ -289,11 +299,30 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) { if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
pt.insert("base_url".to_string(), TomlValue::String(base_url)); pt.insert("base_url".to_string(), TomlValue::String(base_url));
} }
// `api_key_env` (kimi-code 2.0.0+) and `api_key` are mutually
// exclusive upstream: emitting both makes the CLI reject the provider
// block, so the env var name alone is written whenever the provider is
// configured that way. A legacy row that still carries the key in
// raw_other (imports before the field existed) is used as the source
// too, so the setting survives the upgrade.
let api_key_env = provider
.api_key_env
.clone()
.or_else(|| {
provider
.raw_other
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty());
// Managed (OAuth) providers persist an empty api_key line so the block // Managed (OAuth) providers persist an empty api_key line so the block
// matches the official CLI's provisioned shape (apiKey: ''). A // matches the official CLI's provisioned shape (apiKey: ''). A
// user-set api_key on a managed provider (api_key outranks OAuth in // user-set api_key on a managed provider (api_key outranks OAuth in
// the CLI's credential priority) must survive the round-trip. // the CLI's credential priority) must survive the round-trip.
if provider.managed if let Some(env_key) = api_key_env {
pt.insert("api_key_env".to_string(), TomlValue::String(env_key));
} else if provider.managed
&& provider.api_key.as_deref().map_or(true, |s| s.is_empty()) && provider.api_key.as_deref().map_or(true, |s| s.is_empty())
{ {
pt.insert("api_key".to_string(), TomlValue::String("".to_string())); pt.insert("api_key".to_string(), TomlValue::String("".to_string()));
@@ -328,10 +357,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
}); });
pt.insert("oauth".to_string(), oauth); pt.insert("oauth".to_string(), oauth);
} }
// Merge remaining raw fields (oauth and env are handled explicitly above). // Merge remaining raw fields (oauth, env and api_key_env are handled
// explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) { if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth"); extra.remove("oauth");
extra.remove("env"); extra.remove("env");
extra.remove("api_key_env");
// Strip Kimi-Switch-private field: the remembered per-provider // Strip Kimi-Switch-private field: the remembered per-provider
// default model is stored in raw_other.default_model and must NOT // default model is stored in raw_other.default_model and must NOT
// leak into the agent's config.toml. // leak into the agent's config.toml.
@@ -562,6 +593,7 @@ api_key = ""
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://fast.cdks.work".to_string()), base_url: Some("https://fast.cdks.work".to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -623,6 +655,7 @@ api_key = ""
provider_type: ProviderType::Openai, provider_type: ProviderType::Openai,
base_url: Some(base_url.to_string()), base_url: Some(base_url.to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -760,6 +793,7 @@ default_effort = "low"
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://example.com".to_string()), base_url: Some("https://example.com".to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -868,6 +902,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://a.example.com".to_string()), base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".to_string()), api_key: Some("sk-a".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -888,6 +923,7 @@ max_context_size = 1048576
provider_type: ProviderType::Openai, provider_type: ProviderType::Openai,
base_url: Some("https://b.example.com".to_string()), base_url: Some("https://b.example.com".to_string()),
api_key: Some("sk-b".to_string()), api_key: Some("sk-b".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -929,6 +965,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: None, base_url: None,
api_key: Some("sk-x".to_string()), api_key: Some("sk-x".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -974,6 +1011,7 @@ max_context_size = 1048576
provider_type: ProviderType::Kimi, provider_type: ProviderType::Kimi,
base_url: Some("https://api.kimi.com/coding/v1".to_string()), base_url: Some("https://api.kimi.com/coding/v1".to_string()),
api_key: api_key.map(String::from), api_key: api_key.map(String::from),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -1153,4 +1191,112 @@ enabled = true
"user-removed section must be dropped on export" "user-removed section must be dropped on export"
); );
} }
#[test]
fn kimi_code_import_extracts_api_key_env() {
// kimi-code 2.0.0+: `api_key_env` names the environment variable that
// holds the credential. It must become its own Provider field and stop
// living in raw_other, so export can enforce the api_key exclusivity.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
base_url = "https://proxy.example.com/v1"
api_key_env = "MY_PROXY_KEY"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let provider = config.providers.get("p1").unwrap();
assert_eq!(provider.api_key_env.as_deref(), Some("MY_PROXY_KEY"));
assert_eq!(provider.api_key, None);
assert!(
provider.raw_other.get("api_key_env").is_none(),
"api_key_env must not stay in raw_other"
);
let exported = config_to_kimi_code(&config, Some(&value));
let exported_provider = exported
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p1").unwrap()
.as_table().unwrap();
assert_eq!(
exported_provider.get("api_key_env").and_then(|v| v.as_str()),
Some("MY_PROXY_KEY")
);
assert!(
!exported_provider.contains_key("api_key"),
"api_key and api_key_env are mutually exclusive upstream"
);
}
#[test]
fn kimi_code_export_api_key_env_wins_over_stored_api_key() {
// A provider switched to env-var credentials in the UI must export
// ONLY `api_key_env`: writing both keys makes the CLI reject the block.
// A legacy raw_other copy of the key acts as the source when the field
// is empty (rows saved before the field existed).
let make_provider = |api_key: Option<&str>, api_key_env: Option<&str>, raw: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://proxy.example.com/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: api_key_env.map(String::from),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: raw,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let config = Config {
default_model: None,
providers: IndexMap::from([("p".to_string(), p)]),
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
config_to_kimi_code(&config, None)
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p").unwrap()
.as_table()
.unwrap()
.clone()
};
// Both fields set (a DB row saved before the UI cleared the key).
let pt = export(make_provider(Some("sk-stale"), Some("MY_PROXY_KEY"), Value::Null));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("MY_PROXY_KEY"));
assert!(!pt.contains_key("api_key"), "the stored key must be dropped");
// Legacy row: only raw_other carries the key.
let pt = export(make_provider(
Some("sk-stale"),
None,
serde_json::json!({"api_key_env": "LEGACY_KEY"}),
));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("LEGACY_KEY"));
assert!(!pt.contains_key("api_key"));
// No env reference → the stored key is written as before.
let pt = export(make_provider(Some("sk-keep"), None, Value::Null));
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
assert!(!pt.contains_key("api_key_env"));
}
} }
+5
View File
@@ -103,10 +103,14 @@ pub fn run() {
commands::list_provider_models, commands::list_provider_models,
commands::test_connectivity, commands::test_connectivity,
commands::query_provider_usage, commands::query_provider_usage,
commands::health_check_all,
commands::debug_log, commands::debug_log,
commands::get_app_setting, commands::get_app_setting,
commands::set_app_setting, commands::set_app_setting,
commands::check_for_update, commands::check_for_update,
commands::get_kimi_code_version,
commands::get_kimi_code_latest,
commands::get_kimi_code_version_info,
commands::download_update, commands::download_update,
commands::open_installer, commands::open_installer,
commands::open_external_url, commands::open_external_url,
@@ -121,6 +125,7 @@ pub fn run() {
dashboard::get_day_detail, dashboard::get_day_detail,
dashboard::list_sessions, dashboard::list_sessions,
dashboard::archive_session, dashboard::archive_session,
dashboard::archive_sessions_before,
dashboard::unarchive_session, dashboard::unarchive_session,
dashboard::delete_session, dashboard::delete_session,
dashboard::delete_workspace, dashboard::delete_workspace,
+13
View File
@@ -107,6 +107,12 @@ pub struct Provider {
pub provider_type: ProviderType, pub provider_type: ProviderType,
pub base_url: Option<String>, pub base_url: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
/// kimi-code 2.0.0+: name of the process environment variable holding the
/// credential, as an alternative to a stored `api_key` (the two are
/// mutually exclusive upstream — exporting both makes the CLI reject the
/// provider block). Only the variable *name* is stored, never its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default)] #[serde(default)]
pub env: IndexMap<String, String>, pub env: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +155,7 @@ impl PartialEq for Provider {
&& self.provider_type == other.provider_type && self.provider_type == other.provider_type
&& self.base_url == other.base_url && self.base_url == other.base_url
&& self.api_key == other.api_key && self.api_key == other.api_key
&& self.api_key_env == other.api_key_env
&& self.env == other.env && self.env == other.env
&& self.note == other.note && self.note == other.note
&& self.official_url == other.official_url && self.official_url == other.official_url
@@ -172,6 +179,7 @@ impl std::fmt::Debug for Provider {
.field("provider_type", &self.provider_type) .field("provider_type", &self.provider_type)
.field("base_url", &self.base_url) .field("base_url", &self.base_url)
.field("api_key", &"<redacted>") .field("api_key", &"<redacted>")
.field("api_key_env", &self.api_key_env)
.field("env", &"<redacted>") .field("env", &"<redacted>")
.field("managed", &self.managed) .field("managed", &self.managed)
.field("enabled", &self.enabled) .field("enabled", &self.enabled)
@@ -285,6 +293,11 @@ pub struct UsageConfig {
/// Per-request timeout in seconds; 0/None = default (8s). /// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>, pub timeout_seconds: Option<u64>,
/// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端
/// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在
/// 保存时把前端写下的 threshold 静默丢掉。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
} }
impl UsageConfig { impl UsageConfig {
+1
View File
@@ -659,6 +659,7 @@ fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(),
provider_type: crate::models::ProviderType::Kimi, provider_type: crate::models::ProviderType::Kimi,
base_url: None, base_url: None,
api_key: None, api_key: None,
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
+3
View File
@@ -332,6 +332,9 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
provider_type, provider_type,
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()), base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()), api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
// Pi's provider file has no env-var credential reference; the
// field stays empty so a Kimi-Code-only setting never leaks here.
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
+177 -4
View File
@@ -9,6 +9,9 @@
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100, //! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。 //! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//! //!
//! 例外:Kimi 的加力钱包(`PLAN_BOOSTER_WALLET`)不是百分比窗口而是金额行,
//! `used` / `total` / `remaining` 均为货币金额(分换算而来),`unit` 为币种。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。 //! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched}; use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
@@ -16,11 +19,17 @@ use super::usage_types::{UsageData, UsageResult};
use std::time::Duration; use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及 // 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及
// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖 // 未来新增)都只用这四个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
// 此约定做本地化映射——新增 tier id 时必须同步加映射。 // 此约定做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour"; const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
const TIER_MONTHLY_LIMIT: &str = "monthly_limit"; const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
/// Kimi quota 模型的月度代码窗口(kimi-code #3787 后 `limit_month_code`)。
const TIER_MONTH_CODE: &str = "month_code";
/// Kimi quota 响应里的加力钱包(boosterWallet)。**不是百分比 tier**:这条是
/// 金额行,前端 src/lib/usage-display.ts 的 planLabel() 负责本地化,
/// UsageFooter 按「金额行」而非配额行渲染(used/total 是钱数,不是配额比例)。
const PLAN_BOOSTER_WALLET: &str = "booster_wallet";
/// 套餐条目的统一构造:按百分比表示用量。 /// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData { fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
@@ -70,7 +79,13 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
// GET {base_url}/usages // GET {base_url}/usages
// 默认 https://api.kimi.com/coding/v1/usages // 默认 https://api.kimi.com/coding/v1/usages
// global: https://api.kimi.ai/coding/v1/usages // global: https://api.kimi.ai/coding/v1/usages
// Response: { limits: [{ detail: { limit, remaining, resetTime } }], // Response(kimi-code #3787 / 0.43.1 起服务端切换为 quota 模型):
// { usages: { limit_5h, limit_7d, limit_month_total, limit_month_code:
// { used_ratio: 0-1, reset_time?: ISO 8601 } },
// boosterWallet: { balanceCents, totalCents, monthlyChargeLimitEnabled,
// monthlyChargeLimitCents, monthlyUsedCents, currency },
// goods_version }
// 旧结构(兼容保留): { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } } // usage: { limit, remaining, resetTime } }
/// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。 /// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。
@@ -95,8 +110,9 @@ pub async fn query_kimi_coding(
Fetched::Body(body) => { Fetched::Body(body) => {
let tiers = parse_kimi_coding(&body); let tiers = parse_kimi_coding(&body);
if tiers.is_empty() { if tiers.is_empty() {
// 响应里没有可解析的套餐档位(limits/usage 缺失或字段变了)。 // 响应里没有可解析的套餐档位(quota/usages 与旧 limits/usage
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。 // 均缺失或字段变了)。把原始响应(仅用量数字,无密钥)透出,
// 方便对照接口结构修复。
let preview = serde_json::to_string(&body) let preview = serde_json::to_string(&body)
.unwrap_or_else(|_| "<unserializable body>".into()); .unwrap_or_else(|_| "<unserializable body>".into());
let trimmed: String = preview.chars().take(400).collect(); let trimmed: String = preview.chars().take(400).collect();
@@ -111,6 +127,11 @@ pub async fn query_kimi_coding(
} }
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> { fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
// 新 quota 模型(kimi-code #3787 / 0.43.1 起服务端下发):优先解析。
if let Some(tiers) = parse_kimi_quota(body) {
return tiers;
}
let mut tiers = Vec::new(); let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示) // 5 小时窗口限额(优先显示)
@@ -130,6 +151,67 @@ fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
tiers tiers
} }
/// 解析 quota 模型的 `usages` 窗口映射(`limit_5h` / `limit_7d` /
/// `limit_month_total` / `limit_month_code`)+ 加力钱包。窗口与钱包都缺失或
/// 无可解析条目时返回 None,让调用方回退旧结构。
fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
let usages = body.get("usages")?;
let mut tiers = Vec::new();
for (key, tier) in [
("limit_5h", TIER_FIVE_HOUR),
("limit_7d", TIER_WEEKLY_LIMIT),
("limit_month_total", TIER_MONTHLY_LIMIT),
("limit_month_code", TIER_MONTH_CODE),
] {
let Some(entry) = usages.get(key) else { continue };
let Some(ratio) = entry.get("used_ratio").and_then(parse_f64) else {
continue;
};
// used_ratio 规范为 0-1 比率;>1 时视为已是百分数,原样使用。
let used_percent = if ratio <= 1.0 { ratio * 100.0 } else { ratio };
let resets_at = entry.get("reset_time").and_then(extract_reset_time);
tiers.push(percent_tier(tier, used_percent, resets_at));
}
// 加力钱包排在四个窗口之后(钱包是余额,不是滚动窗口)。
if let Some(wallet) = body.get("boosterWallet").and_then(booster_wallet_tier) {
tiers.push(wallet);
}
if tiers.is_empty() { None } else { Some(tiers) }
}
/// 解析加力钱包(boosterWallet)为金额行。字段单位为「分」,统一换算成元/美元。
///
/// 只在响应含新版 `*Cents` 字段时产出条目:kimi-code #3787 之前的
/// `boosterWallet` 只有 `balance`(无币种/精度语义),产出全 0 的伪行只会误导。
fn booster_wallet_tier(wallet: &serde_json::Value) -> Option<UsageData> {
let cents = |key: &str| wallet.get(key).and_then(parse_f64);
let (balance, total, used) = (
cents("balanceCents"),
cents("totalCents"),
cents("monthlyUsedCents"),
);
if balance.is_none() && total.is_none() && used.is_none() {
return None;
}
// 币种缺失时不猜(前端 formatAmount 会退化为纯数字展示)。
let unit = wallet
.get("currency")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(UsageData {
plan_name: Some(PLAN_BOOSTER_WALLET.to_string()),
remaining: balance.map(|c| c / 100.0),
total: total.map(|c| c / 100.0),
used: used.map(|c| c / 100.0),
unit,
is_valid: Some(true),
// 钱包没有窗口重置时间。
resets_at: None,
})
}
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData { fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0); let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0); let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
@@ -459,6 +541,97 @@ mod tests {
); );
} }
#[test]
fn kimi_coding_quota_model_four_windows() {
// kimi-code #3787 / 0.43.1 起服务端下发的 quota 模型:used_ratio 为
// 0-1 比率,reset_time 为 ISO 8601 字符串。
let body = json!({
"goods_version": 3,
"usages": {
"limit_5h": { "used_ratio": 0.6, "reset_time": "2026-09-15T17:00:00Z" },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1, "reset_time": "2026-10-01T00:00:00Z" },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": { "balance": 0 }
});
let tiers = parse_kimi_coding(&body);
// 旧版 boosterWallet(只有 balance,无 *Cents)不产出钱包行。
assert_eq!(tiers.len(), 4);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
assert_eq!(tiers[0].remaining, Some(40.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(
tiers[0].resets_at.as_deref(),
Some("2026-09-15T17:00:00Z")
);
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(25.0));
assert!(tiers[1].resets_at.is_none());
assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(tiers[2].used, Some(10.0));
assert_eq!(tiers[3].plan_name.as_deref(), Some("month_code"));
assert_eq!(tiers[3].used, Some(88.0));
assert_eq!(tiers[3].remaining, Some(12.0));
}
#[test]
fn kimi_coding_quota_appends_booster_wallet_row() {
// 加力钱包是金额行(分 → 元/美元),追加在四个窗口之后。
let body = json!({
"usages": {
"limit_5h": { "used_ratio": 0.6 },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1 },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": {
"balanceCents": 1234,
"totalCents": 5000,
"monthlyChargeLimitEnabled": true,
"monthlyChargeLimitCents": 2000,
"monthlyUsedCents": 800,
"currency": "USD"
}
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 5);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
let wallet = &tiers[4];
assert_eq!(wallet.plan_name.as_deref(), Some("booster_wallet"));
assert_eq!(wallet.used, Some(8.0));
assert_eq!(wallet.total, Some(50.0));
assert_eq!(wallet.remaining, Some(12.34));
assert_eq!(wallet.unit.as_deref(), Some("USD"));
assert_eq!(wallet.is_valid, Some(true));
assert!(wallet.resets_at.is_none());
}
#[test]
fn kimi_coding_quota_ratio_above_one_is_percent() {
// 防御:>1 的 used_ratio 视为已是百分数,不再乘 100。
let body = json!({
"usages": { "limit_5h": { "used_ratio": 42.0 } }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].used, Some(42.0));
}
#[test]
fn kimi_coding_quota_missing_windows_falls_back_to_legacy() {
// usages 存在但没有任何可解析条目 → 回退旧结构解析。
let body = json!({
"usages": { "limit_5h": {} },
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[0].used, Some(10.0));
}
#[test] #[test]
fn kimi_coding_reset_time_seconds_vs_millis() { fn kimi_coding_reset_time_seconds_vs_millis() {
// 秒级时间戳自动 ×1000 // 秒级时间戳自动 ×1000
+2
View File
@@ -10,6 +10,7 @@
pub mod balance; pub mod balance;
pub mod coding_plan; pub mod coding_plan;
pub mod probe;
pub mod usage_types; pub mod usage_types;
pub use usage_types::{UsageData, UsageResult}; pub use usage_types::{UsageData, UsageResult};
@@ -336,6 +337,7 @@ mod tests {
user_id: uid.map(str::to_string), user_id: uid.map(str::to_string),
auto_query_interval_minutes: None, auto_query_interval_minutes: None,
timeout_seconds: None, timeout_seconds: None,
threshold: None,
}; };
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误) // 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None); assert_eq!(newapi_creds(None), None);
+155
View File
@@ -0,0 +1,155 @@
//! 供应商推理端点探活(「一键体检」的第二步)。
//!
//! GET `{base_url}/v1/models`,带 `Authorization: Bearer <api_key>`。
//! 2xx 且响应体是合法 JSON 即算通过(不校验内容结构,只验证端点能应答);
//! 其余状态码 / 网络错误归类成确定性错误文案。
//!
//! 错误文案与 balance.rs 一致用英文短句,前端 localizeHealthError 负责
//! 双语展示(见 src/hooks/useHealthCheck.ts)。
use std::time::Duration;
/// 探活请求超时。体检要快速出结果、不卡住 UI,比账单查询的 8s 更短。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// 由 base_url 拼出 `/v1/models` 探测地址。
///
/// 供应商配置里的 base_url 多数已经带 `/v1` 后缀(如
/// `https://api.kimi.com/coding/v1`),直接再拼 `/v1/models` 会得到
/// `/v1/v1/models`,所以先剥掉重复后缀;结尾斜杠与首尾空白一并归一。
/// 空/纯空白返回 None(调用方直接判失败,不发请求)。纯函数,便于单测。
fn models_url(base_url: &str) -> Option<String> {
let base = base_url.trim().trim_end_matches('/');
if base.is_empty() {
return None;
}
let root = base.strip_suffix("/v1").unwrap_or(base);
Some(format!("{root}/v1/models"))
}
/// 探活前置校验:返回最终请求 URL。base_url 缺失、api_key 为空都是确定性
/// 配置错误,不发请求即失败(没有 key 时必然 401,直接给出更可操作的结论)。
fn validate_probe_input(base_url: &str, api_key: &str) -> Result<String, String> {
let Some(url) = models_url(base_url) else {
return Err("missing base_url".to_string());
};
if api_key.trim().is_empty() {
return Err("no API key configured".to_string());
}
Ok(url)
}
/// 探活一个供应商的推理端点。`Ok(())` = 端点正常应答且返回合法 JSON。
pub async fn probe_provider(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<(), String> {
let url = validate_probe_input(base_url, api_key)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let resp = client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"request timed out".to_string()
} else if e.is_connect() {
"connection failed (DNS or refused)".to_string()
} else {
format!("network error: {e}")
}
})?;
let status = resp.status();
if status.is_success() {
// 先取完整响应体再解析:读体超时/中断是瞬时错误,与「返回的不是
// JSON」区分开(reqwest 的 .json() 会把两者都包成 decode)。
let raw = resp
.bytes()
.await
.map_err(|e| format!("failed to read response: {e}"))?;
return match serde_json::from_slice::<serde_json::Value>(&raw) {
Ok(_) => Ok(()),
Err(_) => Err("response is not valid JSON".to_string()),
};
}
match status.as_u16() {
401 | 403 => Err(format!("Authentication failed (HTTP {status})")),
404 => Err(format!("endpoint not found (HTTP {status})")),
code => Err(format!("API error (HTTP {code})")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn models_url_dedupes_v1_suffix() {
assert_eq!(
models_url("https://api.kimi.com/coding/v1").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
// 不带 /v1 的裸域补上 /v1
assert_eq!(
models_url("https://api.deepseek.com").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
}
#[test]
fn models_url_normalizes_slashes_and_whitespace() {
// 结尾斜杠(含多个)与首尾空白都要归一,且不得拼出 /v1/v1
assert_eq!(
models_url("https://api.kimi.com/coding/v1/").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
assert_eq!(
models_url(" https://api.deepseek.com/v1/// ").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
assert_eq!(
models_url("https://api.deepseek.com/").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
// 非 /v1 的路径后缀原样保留(只有 /v1 需要去重)
assert_eq!(
models_url("https://api.novita.ai/v3").as_deref(),
Some("https://api.novita.ai/v3/v1/models")
);
}
#[test]
fn models_url_rejects_empty_base() {
assert_eq!(models_url(""), None);
assert_eq!(models_url(" "), None);
assert_eq!(models_url("/"), None);
}
#[test]
fn probe_input_validation_rejects_empty_base_and_missing_key() {
// 两者都是确定性配置错误,不发请求即返回。
assert_eq!(
validate_probe_input(" ", "sk-x").unwrap_err(),
"missing base_url"
);
assert_eq!(
validate_probe_input("https://example.com", " ").unwrap_err(),
"no API key configured"
);
assert_eq!(
validate_probe_input("https://example.com/v1", "sk-x").unwrap(),
"https://example.com/v1/models"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.17", "version": "0.7.20",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+27
View File
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
update({ background: { ...settings.background, ...patch } }); update({ background: { ...settings.background, ...patch } });
}; };
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
update({ watch: { ...settings.watch, ...patch } });
};
const setRules = (rules: PermissionRule[]) => { const setRules = (rules: PermissionRule[]) => {
update({ permission: { rules } }); update({ permission: { rules } });
}; };
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
value={settings.loop_control?.reserved_context_size ?? 50000} value={settings.loop_control?.reserved_context_size ?? 50000}
onChange={(v) => updateLoopControl({ reserved_context_size: v })} onChange={(v) => updateLoopControl({ reserved_context_size: v })}
/> />
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
upstream default is 5; an empty field or 0 removes the key instead
of writing a literal 0 (see setAgentSettings). */}
<NumberField
label={t("compactionMaxAttempts")}
value={settings.loop_control?.compaction_max_attempts ?? 5}
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
/>
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
</Card> </Card>
<Card title={t("backgroundSettings")}> <Card title={t("backgroundSettings")}>
@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/> />
</Card> </Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("permissionRules")}> <Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env {/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false") var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+61 -1
View File
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog"; import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
import { Segmented } from "./ui/controls";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [ const KNOWN_CAPABILITIES = [
@@ -127,6 +128,19 @@ export function ProviderEdit({
// Preset this provider was created from (if any) — provides the // Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input. // "Get API Key" / referral links shown under the key input.
const preset = findPresetForProvider(provider); const preset = findPresetForProvider(provider);
/**
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
* variable instead of storing the key in config.toml; the two are mutually
* exclusive upstream, so the UI is a mode switch and each mode clears the
* other field. Derived from the provider itself (no local state) so the
* switch follows provider switches and reloads; an empty-but-present value
* ("env mode picked, name not typed yet") still reads as env mode.
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
* equivalent, so the switch and the env input are both gated on the agent
* (a Pi provider that somehow carries the key keeps its plain key input).
*/
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
useEffect(() => { useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type); const def = defaultBaseUrl(agent, provider.provider_type);
@@ -322,9 +336,49 @@ export function ProviderEdit({
{!provider.managed && ( {!provider.managed && (
<div> <div>
{apiKeyEnvSupported && (
<div className="flex items-center gap-3 mb-2">
<span className="text-sm text-content-muted">
{t("apiKeySource")}
</span>
<Segmented
options={[
{ key: "direct", label: t("apiKeySourceDirect") },
{ key: "env", label: t("apiKeySourceEnv") },
]}
value={apiKeyEnvMode ? "env" : "direct"}
onChange={(mode) => {
// Mutual exclusion: picking a source clears the
// other one so export never writes both keys.
if (mode === "env") {
onChange({
...provider,
api_key: null,
api_key_env: provider.api_key_env ?? "",
});
} else {
onChange({ ...provider, api_key_env: null });
}
}}
/>
</div>
)}
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5"> <label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span> {apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
<span className="text-red-500">*</span>
</label> </label>
{apiKeyEnvMode ? (
<input
id={apiKeyId}
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key_env ?? ""}
placeholder="KIMI_API_KEY"
onChange={(e) =>
onChange({ ...provider, api_key_env: e.target.value })
}
/>
) : (
<div className="relative"> <div className="relative">
<input <input
id={apiKeyId} id={apiKeyId}
@@ -343,6 +397,12 @@ export function ProviderEdit({
{showApiKey ? t("hide") : t("show")} {showApiKey ? t("hide") : t("show")}
</button> </button>
</div> </div>
)}
{apiKeyEnvMode && (
<p className="mt-1.5 text-xs text-content-muted">
{t("apiKeyEnvHint")}
</p>
)}
{(() => { {(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null; const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null; if (!linkUrl) return null;
+114 -4
View File
@@ -1,10 +1,26 @@
import { useState } from "react"; import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react"; import {
Pencil,
Copy,
Activity,
Loader2,
Trash2,
BarChart3,
Stethoscope,
} from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter"; import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery"; import { useUsageQuery } from "../hooks/useUsageQuery";
import {
useHealthCheck,
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type HealthResult,
} from "../hooks/useHealthCheck";
import type { Agent, Model, Provider } from "../types"; import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult { interface ConnectivityResult {
@@ -40,6 +56,12 @@ interface ProviderCardProps {
models: Record<string, Model>; models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */ /** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined; ts: TestState | undefined;
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
health: HealthResult | undefined;
/** 体检请求进行中。 */
healthRunning: boolean;
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
healthRan: boolean;
onEdit: (name: string) => void; onEdit: (name: string) => void;
onDelete: (name: string) => void; onDelete: (name: string) => void;
onDuplicate: (name: string) => void; onDuplicate: (name: string) => void;
@@ -54,6 +76,9 @@ function ProviderCard({
defaultModel, defaultModel,
models, models,
ts, ts,
health,
healthRunning,
healthRan,
onEdit, onEdit,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -70,7 +95,8 @@ function ProviderCard({
provider.name, provider.name,
provider.usageKinds, provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes, provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
); );
const providerModels = Object.values(models).filter( const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name (m) => m.provider === provider.name
@@ -80,6 +106,17 @@ function ProviderCard({
: null; : null;
const isActive = provider.active === true; const isActive = provider.active === true;
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
const healthTip = health
? [
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
health.usage ? usageLine(health.usage, t) : null,
]
.filter((s): s is string => s !== null)
.join(" · ")
: "";
return ( return (
<div <div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${ className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
@@ -102,6 +139,34 @@ function ProviderCard({
<h3 className="font-semibold text-content-primary truncate"> <h3 className="font-semibold text-content-primary truncate">
{provider.name} {provider.name}
</h3> </h3>
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
{health ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
health.verdict
)}`}
title={healthTip}
aria-label={healthTip}
/>
) : healthRunning ? (
<span
className="shrink-0 flex items-center"
title={t("healthCheckRunning")}
aria-label={t("healthCheckRunning")}
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
</span>
) : healthRan ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
"neutral"
)}`}
title={t("healthNotChecked")}
aria-label={t("healthNotChecked")}
/>
) : null}
{isActive && ( {isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30"> <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")} {t("inUse")}
@@ -273,6 +338,7 @@ export function ProviderList({
}: ProviderListProps) { }: ProviderListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({}); const [testState, setTestState] = useState<Record<string, TestState>>({});
const health = useHealthCheck(agent);
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。 // managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort( const sortedProviders = [...providers].sort(
@@ -312,8 +378,48 @@ export function ProviderList({
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2> <div className="flex items-center gap-3 min-w-0">
<h2 className="font-medium text-content-primary shrink-0">
{t("providers")}
</h2>
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
{(health.running || health.ran || health.error) && (
<span
className={`text-xs tabular-nums truncate ${
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
}`}
title={health.error ? localizeHealthError(health.error, t) : undefined}
>
{health.error
? t("healthRequestFailed", {
error: localizeHealthError(health.error, t),
})
: health.running
? t("healthCheckRunning")
: t("healthSummary", {
ok: health.okCount,
fail: health.failCount,
})}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={health.run}
disabled={health.running}
title={t("healthCheckHint")}
aria-label={t("healthCheck")}
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
>
{health.running ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Stethoscope className="w-4 h-4" />
)}
{t("healthCheck")}
</button>
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
@@ -335,6 +441,7 @@ export function ProviderList({
</svg> </svg>
</button> </button>
</div> </div>
</div>
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
{providers.length === 0 ? ( {providers.length === 0 ? (
@@ -371,6 +478,9 @@ export function ProviderList({
defaultModel={defaultModel} defaultModel={defaultModel}
models={models} models={models}
ts={testState[provider.name]} ts={testState[provider.name]}
health={health.results[provider.name]}
healthRunning={health.running}
healthRan={health.ran}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
+120
View File
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
import { useTranslation, type Language } from "../i18n"; import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme"; import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck"; import type { UpdateInfo } from "../hooks/useUpdateCheck";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string { function formatVersion(version: string): string {
const v = version.trim(); const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`; return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
} }
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -30,6 +38,11 @@ export function SettingsModal({
}: SettingsModalProps) { }: SettingsModalProps) {
const { t, lang, setLang } = useTranslation(); const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState(""); const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null); const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null); const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
@@ -158,6 +171,47 @@ export function SettingsModal({
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
}; };
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -362,6 +416,72 @@ export function SettingsModal({
</div> </div>
</section> </section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */} {/* About */}
<section> <section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted"> <h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
-4
View File
@@ -48,10 +48,6 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" }, notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" }, tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" }, subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
}; };
/** Validation-error i18n key per engine rule, for the pre-write self-check. */ /** Validation-error i18n key per engine rule, for the pre-write self-check. */
+26 -1
View File
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
)} )}
</div> </div>
{/* timeout + auto query interval */} {/* timeout + auto query interval + alert threshold */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6"> <div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label> <label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
/> />
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p> <p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div> </div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
<input
type="number"
min={0}
max={100}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.threshold ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
// JSON 里省掉,不留冗余字段。
if (raw === "") {
setCfg({ ...cfg, threshold: undefined });
return;
}
const n = parseInt(raw, 10);
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
if (Number.isNaN(n)) return;
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
</div>
</div> </div>
</> </>
)} )}
+38 -11
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display"; import {
BOOSTER_WALLET_PLAN,
formatAmount,
localizeUsageError,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps { interface UsageFooterProps {
@@ -14,6 +20,20 @@ interface UsageFooterProps {
variant?: "detail" | "compact"; variant?: "detail" | "compact";
} }
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { status, data, error, updatedAt, supported, refresh } = usage; const { status, data, error, updatedAt, supported, refresh } = usage;
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance // A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
// entries carry only remaining + unit — their plan_name is a currency / // entries carry only remaining + unit — their plan_name is a currency /
// brand label and must render as an amount, not a percentage. // brand label and must render as an amount, not a percentage.
const isPlan = const isPlan = isPlanRow(d);
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
if (d.isValid === false) { if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" }; return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
} }
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
{data.map((d, i) => { {data.map((d, i) => {
// Same plan-vs-balance rule as compactSummary: balance rows have no // Same plan-vs-balance rule as compactSummary: balance rows have no
// quota fields (total/used), so they render as an amount. // quota fields (total/used), so they render as an amount.
const isPlan = const isPlan = isPlanRow(d);
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const pct = isPlan ? percentOf(d) : null; const pct = isPlan ? percentOf(d) : null;
const color = // 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
pct == null // 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
const color = rowAlert
? "text-red-500 dark:text-red-400"
: pct == null
? "text-content-muted" ? "text-content-muted"
: pct < 70 : pct < 70
? "text-green-600 dark:text-green-400" ? "text-green-600 dark:text-green-400"
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
> >
{main} {main}
</span> </span>
{rowAlert && (
<span
className="shrink-0 text-red-500 dark:text-red-400"
title={t("usageThresholdAlert")}
aria-label={t("usageThresholdAlert")}
>
⚠
</span>
)}
{resetText && ( {resetText && (
<span className="text-content-muted shrink-0">{resetText}</span> <span className="text-content-muted shrink-0">{resetText}</span>
)} )}
+189 -2
View File
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSessions } from "../../hooks/useSessions"; import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format"; import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import type { SessionRow, WorkspaceRow } from "../../types/sessions"; import type { BulkArchiveResult, SessionRow, WorkspaceRow } from "../../types/sessions";
function fmtBytes(n: number): string { function fmtBytes(n: number): string {
const v = Number(n) || 0; const v = Number(n) || 0;
@@ -23,6 +23,44 @@ function rowKey(row: SessionRow): string {
return `${row.workspaceId}/${row.id}`; return `${row.workspaceId}/${row.id}`;
} }
type ArchiveAllMode = "month" | "half" | "week" | "custom";
type ArchiveAllState = {
mode: ArchiveAllMode;
/** YYYY-MM-DD, used when mode is "custom". */
date: string;
busy: boolean;
result: BulkArchiveResult | null;
error: string | null;
};
function localDateInput(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
/** Today minus one month — the default value of the custom-date picker. */
function defaultCustomDate(): string {
const d = new Date();
d.setMonth(d.getMonth() - 1);
return localDateInput(d);
}
/** Cutoff epoch ms for the selected option (custom dates start at local midnight). */
function cutoffFor(state: ArchiveAllState): number {
const now = new Date();
if (state.mode === "month" || state.mode === "half" || state.mode === "week") {
const d = new Date(now);
if (state.mode === "month") d.setMonth(d.getMonth() - 1);
if (state.mode === "half") d.setDate(d.getDate() - 15);
if (state.mode === "week") d.setDate(d.getDate() - 7);
return d.getTime();
}
const [y, m, day] = state.date.split("-").map(Number);
return new Date(y || now.getFullYear(), (m || 1) - 1, day || 1, 0, 0, 0, 0).getTime();
}
type ConfirmState = type ConfirmState =
| { mode: "one"; row: SessionRow } | { mode: "one"; row: SessionRow }
| { mode: "bulk"; rows: SessionRow[] } | { mode: "bulk"; rows: SessionRow[] }
@@ -52,6 +90,7 @@ export function SessionsPage() {
refresh, refresh,
archiveSession, archiveSession,
unarchiveSession, unarchiveSession,
archiveBefore,
deleteSession, deleteSession,
deleteWorkspace, deleteWorkspace,
getPreview, getPreview,
@@ -62,6 +101,7 @@ export function SessionsPage() {
const [confirm, setConfirm] = useState<ConfirmState>(null); const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false); const [confirmBusy, setConfirmBusy] = useState(false);
const [preview, setPreview] = useState<PreviewState>(null); const [preview, setPreview] = useState<PreviewState>(null);
const [archiveAll, setArchiveAll] = useState<ArchiveAllState | null>(null);
const workspaces = data?.workspaces ?? []; const workspaces = data?.workspaces ?? [];
const sessions = data?.sessions ?? []; const sessions = data?.sessions ?? [];
@@ -199,6 +239,30 @@ export function SessionsPage() {
0 0
); );
useEffect(() => {
if (!archiveAll) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setArchiveAll((s) => (s && s.busy ? s : null));
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [archiveAll]);
const runArchiveAll = async () => {
if (!archiveAll || archiveAll.busy) return;
const cutoffMs = cutoffFor(archiveAll);
setArchiveAll((s) => (s ? { ...s, busy: true, result: null, error: null } : s));
try {
const res = await archiveBefore(cutoffMs);
setArchiveAll((s) => (s ? { ...s, busy: false, result: res } : s));
setSelected(new Set());
} catch (e) {
setArchiveAll((s) =>
s ? { ...s, busy: false, error: e instanceof Error ? e.message : String(e) } : s
);
}
};
return ( return (
<div className="flex h-full flex-col overflow-hidden p-4 space-y-3"> <div className="flex h-full flex-col overflow-hidden p-4 space-y-3">
<div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"> <div className="flex shrink-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
@@ -376,6 +440,23 @@ export function SessionsPage() {
</button> </button>
</> </>
)} )}
{status !== "archived" && (
<button
type="button"
onClick={() =>
setArchiveAll({
mode: "month",
date: defaultCustomDate(),
busy: false,
result: null,
error: null,
})
}
className="px-2 py-1 text-xs rounded border border-border hover:bg-hover-2"
>
{t("sessionsArchiveAll")}
</button>
)}
<select <select
value={workspace} value={workspace}
onChange={(e) => setWorkspace(e.target.value)} onChange={(e) => setWorkspace(e.target.value)}
@@ -548,6 +629,112 @@ export function SessionsPage() {
{data?.home && <>{data.home}</>} {data?.home && <>{data.home}</>}
</div> </div>
{/* Bulk archive dialog */}
{archiveAll && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
<div className="space-y-3 p-5">
<h3 className="text-base font-semibold text-content-primary">
{t("archiveAllTitle")}
</h3>
<p className="text-sm text-content-muted">{t("archiveAllDesc")}</p>
<div className="space-y-0.5">
{(
[
["month", t("archiveOptionMonth")],
["half", t("archiveOptionHalfMonth")],
["week", t("archiveOptionWeek")],
["custom", t("archiveOptionCustom")],
] as const
).map(([mode, label]) => (
<label
key={mode}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-content-primary hover:bg-input"
>
<input
type="radio"
name="archiveAllMode"
className="h-4 w-4 accent-blue-600"
checked={archiveAll.mode === mode}
disabled={archiveAll.busy}
onChange={() =>
setArchiveAll((s) =>
s ? { ...s, mode, result: null, error: null } : s
)
}
/>
<span>{label}</span>
</label>
))}
</div>
{archiveAll.mode === "custom" && (
<input
type="date"
value={archiveAll.date}
disabled={archiveAll.busy}
onChange={(e) =>
setArchiveAll((s) => (s ? { ...s, date: e.target.value } : s))
}
className="bg-input border border-border rounded px-2 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
)}
<div className="rounded-md border border-border bg-input px-3 py-2.5 text-sm text-content-muted">
{t("archiveAllConfirm", {
date: new Date(cutoffFor(archiveAll)).toLocaleDateString(
locale === "zh" ? "zh-CN" : "en-US"
),
})}
</div>
{archiveAll.error && (
<div className="rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-sm text-red-300">
{t("archiveAllFailed")}: {archiveAll.error}
</div>
)}
{archiveAll.result && archiveAll.result.archived === 0 && (
<div className="rounded-md border border-border bg-input px-3 py-2 text-sm text-content-muted">
{t("archiveAllEmpty")}
</div>
)}
{archiveAll.result && archiveAll.result.archived > 0 && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-900/20 px-3 py-2 text-sm text-emerald-300">
{t("archiveAllDone", {
archived: archiveAll.result.archived,
skipped: archiveAll.result.skipped,
})}
</div>
)}
{archiveAll.result && archiveAll.result.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-red-500/40 bg-red-900/20 px-3 py-2 text-xs text-red-300">
{archiveAll.result.errors.slice(0, 3).map((err, i) => (
<div key={i} className="break-all">
{err}
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-border bg-footer px-5 py-3">
<button
type="button"
disabled={archiveAll.busy}
onClick={() => setArchiveAll(null)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50"
>
{archiveAll.result ? t("close") : t("cancel")}
</button>
<button
type="button"
disabled={archiveAll.busy}
onClick={runArchiveAll}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50"
>
{archiveAll.busy ? t("archiveAllRunning") : t("sessionsArchiveAll")}
</button>
</div>
</div>
</div>
)}
{/* Confirm dialog */} {/* Confirm dialog */}
{confirm && ( {confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type CheckResult,
} from "./useHealthCheck";
/**
* 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。
*/
const t = ((key: string) => `<${key}>`) as unknown as Parameters<
typeof localizeHealthError
>[1];
/** 带插值的假 t:`<key|{a}=1>`,用于断言参数透传。 */
const tWithVars = ((key: string, vars?: Record<string, string | number>) =>
vars
? `<${key}|${Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",")}>`
: `<${key}>`) as unknown as Parameters<typeof localizeHealthError>[1];
describe("localizeHealthError", () => {
it("探活错误按前缀映射到对应文案", () => {
expect(localizeHealthError("missing base_url", t)).toBe(
"<healthErrNoBaseUrl>"
);
expect(localizeHealthError("no API key configured", t)).toBe(
"<healthErrNoKey>"
);
expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe(
"<healthErrAuth>"
);
expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe(
"<healthErrNotFound>"
);
expect(localizeHealthError("request timed out", t)).toBe(
"<healthErrTimeout>"
);
expect(
localizeHealthError("connection failed (DNS or refused)", t)
).toBe("<healthErrUnreachable>");
expect(localizeHealthError("response is not valid JSON", t)).toBe(
"<healthErrBadJson>"
);
});
it("未知错误原样透出(HTTP 状态码等诊断细节)", () => {
expect(localizeHealthError("API error (HTTP 502)", t)).toBe(
"API error (HTTP 502)"
);
});
it("探活 + 账单两段拼接时逐段映射", () => {
expect(
localizeHealthError(
"endpoint not found (HTTP 404); usage query disabled in config panel",
t
)
).toBe("<healthErrNotFound>; <usageErrDisabled>");
});
it("账单链路的报错复用用量错误映射", () => {
// commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。
expect(
localizeHealthError(
"Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)",
t
)
).toBe("<healthErrAuth>; <usageInvalidKey>");
});
});
describe("probeLine", () => {
it("通过时带延迟", () => {
expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe(
"<healthProbeOk|latency=123>"
);
});
it("中性(404)说明端点不支持模型列表,而不是报失败", () => {
expect(
probeLine(
{ state: "neutral", error: "endpoint not found (HTTP 404)" },
0,
tWithVars
)
).toBe("<healthProbeUnsupported>");
});
it("失败时透出本地化后的原因", () => {
expect(
probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars)
).toBe("<healthProbeFail|error=<healthErrTimeout>>");
});
});
describe("usageLine", () => {
it("通过 / 失败 / 兜底三态", () => {
const pass: CheckResult = { state: "pass" };
expect(usageLine(pass, tWithVars)).toBe("<healthUsageOk>");
expect(
usageLine(
{ state: "fail", error: "Authentication failed (HTTP 401)" },
tWithVars
)
).toBe("<healthUsageFail|error=<healthErrAuth>>");
// 账单链路不会产生中性结论,兜底按「未判定」展示
expect(usageLine({ state: "neutral" }, tWithVars)).toBe(
"<healthNotChecked>"
);
});
});
describe("healthDotClass", () => {
it("绿=通过、红=失败、灰=未判定(中性)", () => {
expect(healthDotClass("pass")).toBe("bg-green-500");
expect(healthDotClass("fail")).toBe("bg-red-500");
expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Agent } from "../types";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError } from "../lib/usage-display";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */
export type HealthCheck = "pass" | "fail" | "neutral";
/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */
export interface CheckResult {
state: HealthCheck;
/** 失败 / 中性原因的原始文案;通过为 null。 */
error?: string | null;
}
/** Mirrors the Rust `HealthResult` (camelCase via serde). */
export interface HealthResult {
providerName: string;
/** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */
verdict: HealthCheck;
/** verdict === "pass" 的便捷布尔。 */
ok: boolean;
/** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */
error?: string | null;
/** 账单/用量结论;null/undefined = 未执行。 */
usage?: CheckResult | null;
/** 推理端点探活结论;探活是必做项,恒为 Some。 */
probe?: CheckResult | null;
/** 探活耗时(毫秒)。 */
latencyMs: number;
}
/**
* 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断
* 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。
*/
function localizeHealthPart(error: string, t: TranslateFn): string {
if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl");
if (error.startsWith("no API key configured")) return t("healthErrNoKey");
if (error.startsWith("Authentication failed")) return t("healthErrAuth");
if (error.startsWith("endpoint not found")) return t("healthErrNotFound");
if (error.startsWith("request timed out")) return t("healthErrTimeout");
if (error.startsWith("connection failed")) return t("healthErrUnreachable");
if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson");
// 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。
return localizeUsageError(error, t);
}
/**
* 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; `
* 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。
*/
export function localizeHealthError(error: string, t: TranslateFn): string {
return error
.split("; ")
.map((part) => localizeHealthPart(part, t))
.join("; ");
}
/** 探活一项的 tooltip 文案。 */
export function probeLine(
probe: CheckResult,
latencyMs: number,
t: TranslateFn
): string {
if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs });
// 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。
if (probe.state === "neutral") return t("healthProbeUnsupported");
return t("healthProbeFail", {
error: probe.error ? localizeHealthError(probe.error, t) : "",
});
}
/** 账单一项的 tooltip 文案。 */
export function usageLine(usage: CheckResult, t: TranslateFn): string {
if (usage.state === "pass") return t("healthUsageOk");
if (usage.state === "fail") {
return t("healthUsageFail", {
error: usage.error ? localizeHealthError(usage.error, t) : "",
});
}
// 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。
return t("healthNotChecked");
}
/**
* 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。
* 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。
*/
export function healthDotClass(verdict: HealthCheck): string {
switch (verdict) {
case "pass":
return "bg-green-500";
case "fail":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-500";
}
}
export interface HealthCheckState {
/** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */
running: boolean;
/** 至少跑过一轮(决定是否渲染状态点)。 */
ran: boolean;
/** provider name → 结果。 */
results: Record<string, HealthResult>;
okCount: number;
failCount: number;
/** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */
error: string | null;
run: () => void;
}
/**
* 一键体检:invoke `health_check_all`,把结果按 provider 名索引。
* 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。
*/
export function useHealthCheck(agent: Agent): HealthCheckState {
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const [results, setResults] = useState<Record<string, HealthResult>>({});
const [error, setError] = useState<string | null>(null);
// Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。
const genRef = useRef(0);
useEffect(
() => () => {
genRef.current += 1;
},
[]
);
const run = useCallback(async () => {
const gen = ++genRef.current;
setRunning(true);
setError(null);
// 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。
setResults({});
try {
const list = await invoke<HealthResult[]>("health_check_all", { agent });
if (genRef.current !== gen) return;
const map: Record<string, HealthResult> = {};
for (const r of list) map[r.providerName] = r;
setResults(map);
} catch (e) {
if (genRef.current !== gen) return;
// invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。
setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
} finally {
if (genRef.current === gen) {
setRunning(false);
setRan(true);
}
}
}, [agent]);
const values = Object.values(results);
return {
running,
ran,
results,
// 中性(灰)既不算正常也不算失败,不计入汇总。
okCount: values.filter((r) => r.verdict === "pass").length,
failCount: values.filter((r) => r.verdict === "fail").length,
error,
run: () => void run(),
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isNewerVersion } from "./useKimiCodeVersion";
describe("isNewerVersion", () => {
it("同版本不算有更新", () => {
expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false);
expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false);
});
it("按 major/minor/patch 逐段比较", () => {
expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true);
expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true);
expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true);
expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false);
expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false);
});
it("忽略 v 前缀与预发布后缀", () => {
expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true);
expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false);
});
it("缺位补 0,无法解析时不显示更新", () => {
expect(isNewerVersion("2.1", "2.0.9")).toBe(true);
expect(isNewerVersion("abc", "2.0.1")).toBe(false);
expect(isNewerVersion("2.0.2", "abc")).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */
export type KimiCodeStatus =
| "current"
| "outdated"
| "partial"
| "unknown"
| "not_installed";
export interface KimiCodeVersionInfo {
/** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */
installed: string | null;
/** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */
latest: string | null;
status: KimiCodeStatus;
}
const THROTTLE_KEY = "kimi-switch-last-kc-version-check";
// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能
// 自动拿到新版本,同时避免短时间反复触发后端调用。
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */
function versionTriple(version: string): [number, number, number] | null {
const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0];
const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10));
if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
while (nums.length < 3) nums.push(0);
return [nums[0], nums[1], nums[2]];
}
/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */
export function isNewerVersion(latest: string, installed: string): boolean {
const l = versionTriple(latest);
const i = versionTriple(installed);
if (!l || !i) return false;
for (let k = 0; k < 3; k += 1) {
if (l[k] !== i[k]) return l[k] > i[k];
}
return false;
}
/**
* 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck
* 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。
*/
export function useKimiCodeVersion() {
const [info, setInfo] = useState<KimiCodeVersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<number | null>(() => {
try {
const v = localStorage.getItem(THROTTLE_KEY);
return v ? Number(v) : null;
} catch {
return null;
}
});
const doCheck = useCallback(
async (force: boolean) => {
if (!force) {
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
}
setChecking(true);
try {
const next = await invoke<KimiCodeVersionInfo>("get_kimi_code_version_info");
setInfo(next);
const now = Date.now();
setLastChecked(now);
try {
localStorage.setItem(THROTTLE_KEY, String(now));
} catch {
// ignore
}
} catch {
// 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。
} finally {
setChecking(false);
}
},
[lastChecked]
);
// 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。
const doCheckRef = useRef(doCheck);
useEffect(() => {
doCheckRef.current = doCheck;
}, [doCheck]);
useEffect(() => {
doCheckRef.current(false);
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
const checkNow = useCallback(() => doCheck(true), [doCheck]);
return { info, checking, checkNow, lastChecked };
}
+13
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
ActionResponse, ActionResponse,
BulkArchiveResult,
PreviewResult, PreviewResult,
SessionStatusFilter, SessionStatusFilter,
SessionsResult, SessionsResult,
@@ -54,6 +55,17 @@ export function useSessions() {
[refresh] [refresh]
); );
const archiveBefore = useCallback(
async (cutoffMs: number) => {
const result = await invoke<BulkArchiveResult>("archive_sessions_before", {
cutoffMs,
});
await refresh();
return result;
},
[refresh]
);
const deleteSession = useCallback( const deleteSession = useCallback(
async (workspaceId: string, sessionId: string, sessionStatus?: string) => { async (workspaceId: string, sessionId: string, sessionStatus?: string) => {
await invoke<ActionResponse>("delete_session", { await invoke<ActionResponse>("delete_session", {
@@ -100,6 +112,7 @@ export function useSessions() {
refresh, refresh,
archiveSession, archiveSession,
unarchiveSession, unarchiveSession,
archiveBefore,
deleteSession, deleteSession,
deleteWorkspace, deleteWorkspace,
getPreview, getPreview,
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" };
}
describe("isAlertTier", () => {
it("阈值缺省或 0(关闭)时不告警", () => {
expect(isAlertTier(percentTier(100), undefined)).toBe(false);
expect(isAlertTier(percentTier(100), null)).toBe(false);
expect(isAlertTier(percentTier(100), 0)).toBe(false);
});
it("低于阈值不告警", () => {
expect(isAlertTier(percentTier(79), 80)).toBe(false);
expect(isAlertTier(percentTier(79.9), 80)).toBe(false);
});
it("等于或超过阈值告警", () => {
expect(isAlertTier(percentTier(80), 80)).toBe(true);
expect(isAlertTier(percentTier(80.5), 80)).toBe(true);
expect(isAlertTier(percentTier(100), 80)).toBe(true);
});
it("非 % unit 的行不告警(金额行的 used 是钱数)", () => {
// Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。
expect(
isAlertTier(
{ planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" },
5
)
).toBe(false);
// sub2api 的 USD 配额行同理,不参与百分比预警。
expect(
isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50)
).toBe(false);
});
it("used 缺失时不告警", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
+30 -1
View File
@@ -56,6 +56,23 @@ export interface UsageQueryState {
updatedAt: number | null; updatedAt: number | null;
refresh: (force: boolean) => void; refresh: (force: boolean) => void;
supported: boolean; supported: boolean;
/** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */
threshold?: number;
/** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */
/** 所以失败时保留的上次数据不会被误高亮。 */
alert: boolean;
}
/**
* 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的
* used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。
*/
export function isAlertTier(
d: UsageData,
threshold?: number | null
): boolean {
if (threshold == null || threshold <= 0) return false;
return d.unit === "%" && d.used != null && d.used >= threshold;
} }
export function useUsageQuery( export function useUsageQuery(
@@ -63,7 +80,8 @@ export function useUsageQuery(
providerName: string, providerName: string,
usageKinds?: string[], usageKinds?: string[],
autoIntervalMinutes?: number, autoIntervalMinutes?: number,
disabled?: boolean disabled?: boolean,
threshold?: number
): UsageQueryState { ): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0; const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider // Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -175,6 +193,15 @@ export function useUsageQuery(
return () => clearInterval(id); return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery, disabled]); }, [supported, autoIntervalMinutes, runQuery, disabled]);
// 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。
// 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。
const alertThreshold =
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
? threshold
: undefined;
const alert =
status === "success" && data.some((d) => isAlertTier(d, alertThreshold));
return { return {
status, status,
data, data,
@@ -182,5 +209,7 @@ export function useUsageQuery(
updatedAt, updatedAt,
refresh: runQuery, refresh: runQuery,
supported, supported,
threshold: alertThreshold,
alert,
}; };
} }
+63 -2
View File
@@ -43,6 +43,26 @@ export const enTranslations: Record<TranslationKey, string> = {
connectivityOk: "{name} reachable ({latency}ms)", connectivityOk: "{name} reachable ({latency}ms)",
connectivitySlow: "{name} reachable but slow ({latency}ms)", connectivitySlow: "{name} reachable but slow ({latency}ms)",
connectivityFail: "{name} unreachable: {error}", connectivityFail: "{name} unreachable: {error}",
// Health check (ProviderList toolbar button + per-row status dot)
healthCheck: "Health check",
healthCheckHint:
"Probe every enabled provider's inference endpoint and billing query in parallel",
healthCheckRunning: "Checking…",
healthSummary: "{ok} OK / {fail} failed",
healthProbeOk: "Endpoint OK ({latency}ms)",
healthProbeUnsupported: "Endpoint exposes no model list (404)",
healthProbeFail: "Endpoint failed: {error}",
healthUsageOk: "Billing OK",
healthUsageFail: "Billing failed: {error}",
healthNotChecked: "Not checked",
healthRequestFailed: "Health check request failed: {error}",
healthErrNoBaseUrl: "Missing base URL",
healthErrNoKey: "No API key configured",
healthErrAuth: "Authentication failed",
healthErrNotFound: "Endpoint not found",
healthErrTimeout: "Connection timed out",
healthErrUnreachable: "Cannot connect (DNS failure or connection refused)",
healthErrBadJson: "Response is not valid JSON",
copiedProvider: "Duplicated as {name}", copiedProvider: "Duplicated as {name}",
// Provider edit // Provider edit
@@ -78,6 +98,12 @@ export const enTranslations: Record<TranslationKey, string> = {
authField: "Auth Field", authField: "Auth Field",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "Get API Key", getApiKeyLink: "Get API Key",
apiKeySource: "Credential source",
apiKeySourceDirect: "Enter key",
apiKeySourceEnv: "Environment variable",
apiKeyEnvName: "Variable name",
apiKeyEnvHint:
"Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.",
show: "Show", show: "Show",
hide: "Hide", hide: "Hide",
requestUrl: "Request URL", requestUrl: "Request URL",
@@ -142,9 +168,16 @@ export const enTranslations: Record<TranslationKey, string> = {
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step", maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size", reservedContextSize: "Reserved context size",
compactionMaxAttempts: "Max compaction attempts",
compactionMaxAttemptsHint:
"Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).",
backgroundSettings: "Background Tasks", backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks", maxRunningTasks: "Max running tasks",
keepAliveOnExit: "Keep alive on exit", keepAliveOnExit: "Keep alive on exit",
watchSettings: "File Watching",
watchEnabled: "Enable file watching",
watchEnabledDesc:
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
permissionRules: "Permission Rules", permissionRules: "Permission Rules",
permissionDecision: "Decision", permissionDecision: "Decision",
permissionPattern: "Pattern", permissionPattern: "Pattern",
@@ -225,6 +258,19 @@ export const enTranslations: Record<TranslationKey, string> = {
roleUser: "User", roleUser: "User",
roleAssistant: "Assistant", roleAssistant: "Assistant",
roleSystem: "System", roleSystem: "System",
sessionsArchiveAll: "Archive old sessions",
archiveAllTitle: "Archive old sessions",
archiveAllDesc:
"Archives every active session whose last activity is older than the selected date. Files are kept and usage stats stay intact.",
archiveOptionMonth: "Older than 1 month",
archiveOptionHalfMonth: "Older than 15 days",
archiveOptionWeek: "Older than 1 week",
archiveOptionCustom: "Specific date",
archiveAllConfirm: "Archive every active session last active before {date}. Continue?",
archiveAllRunning: "Archiving…",
archiveAllDone: "Archived {archived} sessions, skipped {skipped}",
archiveAllFailed: "Archive failed",
archiveAllEmpty: "No sessions match",
// Heatmap // Heatmap
noData: "No data", noData: "No data",
@@ -270,6 +316,18 @@ export const enTranslations: Record<TranslationKey, string> = {
install: "Install", install: "Install",
updateCheckFailed: "Update check failed", updateCheckFailed: "Update check failed",
lastChecked: "Last checked", lastChecked: "Last checked",
// Kimi Code version tracking
kimiCodeSection: "Kimi Code version",
kimiCodeInstalled: "Installed",
kimiCodeLatest: "Latest",
kimiCodeStatusLabel: "Compatibility",
kimiCodeStatusCurrent: "Supported",
kimiCodeStatusOutdated: "Update KimiSwitch recommended",
kimiCodeStatusPartial: "Partially compatible",
kimiCodeStatusUnknown: "Unknown version",
kimiCodeStatusNotInstalled: "Not installed",
kimiCodeCheck: "Check",
kimiCodeOpenReleases: "Open releases",
appDescription: "Windows desktop LLM provider config manager", appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website", settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site", kimiEyesSite: "Kimi Eyes plugin site",
@@ -436,6 +494,11 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTierDaily: "Daily", usageTierDaily: "Daily",
usageTierWeekly: "Weekly", usageTierWeekly: "Weekly",
usageTierMonthly: "Monthly", usageTierMonthly: "Monthly",
usageTierMonthCode: "Monthly (code)",
usageTierBoosterWallet: "Booster",
usageThreshold: "Alert threshold",
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
usageThresholdAlert: "Threshold reached",
usageErrNoKey: "No API key configured", usageErrNoKey: "No API key configured",
usageErrDisabled: "Usage query is disabled in the config panel", usageErrDisabled: "Usage query is disabled in the config panel",
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again", usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
@@ -477,8 +540,6 @@ export const enTranslations: Record<TranslationKey, string> = {
flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)", flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)",
flagWaitFor: "WaitFor Tool", flagWaitFor: "WaitFor Tool",
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn", flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
flagNotifyUser: "NotifyUser tool", flagNotifyUser: "NotifyUser tool",
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)", flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
flagDefaultOn: "On by default", flagDefaultOn: "On by default",
+60 -2
View File
@@ -41,6 +41,25 @@ export const zhTranslations = {
connectivityOk: "{name} 连通正常({latency}ms)", connectivityOk: "{name} 连通正常({latency}ms)",
connectivitySlow: "{name} 连通较慢({latency}ms)", connectivitySlow: "{name} 连通较慢({latency}ms)",
connectivityFail: "{name} 无法连通:{error}", connectivityFail: "{name} 无法连通:{error}",
// 一键体检(ProviderList 工具栏按钮 + 行内状态点)
healthCheck: "一键体检",
healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询",
healthCheckRunning: "体检中…",
healthSummary: "{ok} 正常 / {fail} 失败",
healthProbeOk: "端点正常({latency}ms)",
healthProbeUnsupported: "端点不支持模型列表(404)",
healthProbeFail: "端点失败:{error}",
healthUsageOk: "账单正常",
healthUsageFail: "账单失败:{error}",
healthNotChecked: "未检测",
healthRequestFailed: "体检请求失败:{error}",
healthErrNoBaseUrl: "缺少 base_url",
healthErrNoKey: "未配置 API Key",
healthErrAuth: "鉴权失败",
healthErrNotFound: "端点不存在",
healthErrTimeout: "连接超时",
healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)",
healthErrBadJson: "响应不是合法 JSON",
copiedProvider: "已复制为 {name}", copiedProvider: "已复制为 {name}",
// Provider edit // Provider edit
@@ -76,6 +95,12 @@ export const zhTranslations = {
authField: "认证字段", authField: "认证字段",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "获取 API Key", getApiKeyLink: "获取 API Key",
apiKeySource: "凭证来源",
apiKeySourceDirect: "直接填写",
apiKeySourceEnv: "环境变量",
apiKeyEnvName: "环境变量名",
apiKeyEnvHint:
"只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。",
show: "显示", show: "显示",
hide: "隐藏", hide: "隐藏",
requestUrl: "请求地址", requestUrl: "请求地址",
@@ -140,9 +165,15 @@ export const zhTranslations = {
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数", maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小", reservedContextSize: "上下文预留大小",
compactionMaxAttempts: "压缩最大尝试次数",
compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。",
backgroundSettings: "后台任务", backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数", maxRunningTasks: "最大并发数",
keepAliveOnExit: "退出时保持运行", keepAliveOnExit: "退出时保持运行",
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
permissionRules: "权限规则", permissionRules: "权限规则",
permissionDecision: "处置", permissionDecision: "处置",
permissionPattern: "模式", permissionPattern: "模式",
@@ -220,6 +251,18 @@ export const zhTranslations = {
roleUser: "用户", roleUser: "用户",
roleAssistant: "助手", roleAssistant: "助手",
roleSystem: "系统", roleSystem: "系统",
sessionsArchiveAll: "一键归档",
archiveAllTitle: "一键归档会话",
archiveAllDesc: "将归档最后活跃时间早于所选日期的全部活跃会话;文件保留,统计不受影响。",
archiveOptionMonth: "1 个月前",
archiveOptionHalfMonth: "半个月前",
archiveOptionWeek: "1 星期前",
archiveOptionCustom: "指定日期",
archiveAllConfirm: "将归档最后活跃时间早于 {date} 的全部活跃会话。确认?",
archiveAllRunning: "正在归档…",
archiveAllDone: "已归档 {archived} 个会话,跳过 {skipped} 个",
archiveAllFailed: "归档失败",
archiveAllEmpty: "没有符合条件的会话",
// Heatmap // Heatmap
noData: "暂无数据", noData: "暂无数据",
@@ -265,6 +308,18 @@ export const zhTranslations = {
install: "安装", install: "安装",
updateCheckFailed: "检查更新失败", updateCheckFailed: "检查更新失败",
lastChecked: "上次检查", lastChecked: "上次检查",
// Kimi Code 版本跟踪
kimiCodeSection: "Kimi Code 版本",
kimiCodeInstalled: "已安装版本",
kimiCodeLatest: "最新版本",
kimiCodeStatusLabel: "适配状态",
kimiCodeStatusCurrent: "已适配",
kimiCodeStatusOutdated: "建议升级 KimiSwitch",
kimiCodeStatusPartial: "部分兼容",
kimiCodeStatusUnknown: "未知版本",
kimiCodeStatusNotInstalled: "未安装",
kimiCodeCheck: "检查",
kimiCodeOpenReleases: "打开 Releases",
appDescription: "Windows 桌面端 LLM 供应商配置管理器", appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站", settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站", kimiEyesSite: "Kimi Eyes 插件站",
@@ -431,6 +486,11 @@ export const zhTranslations = {
usageTierDaily: "每日", usageTierDaily: "每日",
usageTierWeekly: "7天", usageTierWeekly: "7天",
usageTierMonthly: "30天", usageTierMonthly: "30天",
usageTierMonthCode: "30天(代码)",
usageTierBoosterWallet: "加力钱包",
usageThreshold: "预警阈值",
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
usageThresholdAlert: "已达预警阈值",
usageErrNoKey: "未配置 API Key", usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用", usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`", usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
@@ -472,8 +532,6 @@ export const zhTranslations = {
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)", flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
flagWaitFor: "WaitFor 工具", flagWaitFor: "WaitFor 工具",
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒", flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagNotifyUser: "NotifyUser 工具", flagNotifyUser: "NotifyUser 工具",
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)", flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
flagDefaultOn: "默认开启", flagDefaultOn: "默认开启",
+96 -2
View File
@@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means // loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop
// upstream default ON; an explicit false must survive even with no rules // spread, written only when set, and 0/empty removes the key (upstream
// default 5 applies)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("loop_control.compaction_max_attempts", () => {
it("reads an existing value through the loop spread", () => {
expect(
getAgentSettings({ loop_control: { compaction_max_attempts: 7 } })
.loop_control?.compaction_max_attempts
).toBe(7);
});
it("reads as undefined when the key is absent (UI falls back to 5)", () => {
expect(
getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts
).toBeUndefined();
});
it("writes the value when the patch sets it", () => {
const next = setAgentSettings({}, {
loop_control: { compaction_max_attempts: 8 },
});
expect(loopOf(next).compaction_max_attempts).toBe(8);
});
it("drops the key for 0 and for a cleared (undefined) field", () => {
const raw = { loop_control: { compaction_max_attempts: 6 } };
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: 0 },
}))).not.toHaveProperty("compaction_max_attempts");
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: undefined },
}))).not.toHaveProperty("compaction_max_attempts");
});
it("does not materialize the upstream default on an unrelated save", () => {
// An absent key must stay absent — a plain save must not pin `5`.
const next = setAgentSettings({ thinking: { effort: "high" } }, {});
expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts");
// An existing value survives a save that does not touch it.
const kept = setAgentSettings(
{ loop_control: { compaction_max_attempts: 4 } },
{ thinking: { effort: "low" } }
);
expect(loopOf(kept).compaction_max_attempts).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
const w = (raw as { watch?: unknown })?.watch;
return w && typeof w === "object" && !Array.isArray(w)
? (w as Record<string, unknown>)
: {};
}
describe("[watch] enabled", () => {
it("reads as false when the section or key is absent (2.0.2 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(false);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
});
it("reads an explicit value", () => {
expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false);
});
it("writes the top-level [watch] section", () => {
const next = setAgentSettings({}, { watch: { enabled: true } }) as {
watch?: Record<string, unknown>;
};
expect(next.watch).toEqual({ enabled: true });
});
it("keeps the section on saves that do not touch it", () => {
const next = setAgentSettings(
{ watch: { enabled: true } },
{ thinking: { effort: "high" } }
) as { watch?: Record<string, unknown> };
expect(next.watch).toEqual({ enabled: true });
});
it("does not disturb the other managed sections", () => {
const next = setAgentSettings(
{ background: { max_running_tasks: 2 } },
{ watch: { enabled: true } }
) as { background?: Record<string, unknown> };
expect(next.background?.max_running_tasks).toBe(2);
expect(watchOf(next).enabled).toBe(true);
});
});
describe("permission.dangerous_command_guard", () => { describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => { it("reads as undefined when the key is absent (default on)", () => {
expect( expect(
+17
View File
@@ -13,6 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
background: { background: {
keep_alive_on_exit: false, keep_alive_on_exit: false,
}, },
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
// default to off, so an absent key reads as "off".
watch: {
enabled: false,
},
permission: { rules: [] }, permission: { rules: [] },
hooks: [], hooks: [],
}; };
@@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
...DEFAULT_SETTINGS.background, ...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"), ...getSection<AgentSettings["background"]>(rawOther, "background"),
}, },
watch: {
...DEFAULT_SETTINGS.watch,
...getSection<AgentSettings["watch"]>(rawOther, "watch"),
},
permission: { permission: {
rules: sectionPermission?.rules ?? [], rules: sectionPermission?.rules ?? [],
// `dangerous_command_guard` stays undefined unless the config carries // `dangerous_command_guard` stays undefined unless the config carries
@@ -111,6 +120,7 @@ export function setAgentSettings(
thinking: { ...current.thinking, ...patch.thinking }, thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control }, loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background }, background: { ...current.background, ...patch.background },
watch: { ...current.watch, ...patch.watch },
permission, permission,
hooks: patch.hooks ?? current.hooks ?? [], hooks: patch.hooks ?? current.hooks ?? [],
}; };
@@ -129,9 +139,16 @@ export function setAgentSettings(
if (!opts?.legacyV1) { if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step; delete next.loop_control?.max_retries_per_step;
} }
// `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave
// the key out" — the config then falls back to the upstream default (5)
// instead of pinning a literal 0.
if (!next.loop_control?.compaction_max_attempts) {
delete next.loop_control?.compaction_max_attempts;
}
if (next.thinking) root.thinking = next.thinking; if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control; if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background; if (next.background) root.background = next.background;
if (next.watch) root.watch = next.watch;
// Keep `[permission]` when it carries rules *or* an explicit // Keep `[permission]` when it carries rules *or* an explicit
// dangerous_command_guard — dropping the section would silently lose a // dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules // guard=false write (absent key = upstream default on). An empty rules
+2966 -511
View File
File diff suppressed because it is too large. Load diff
+2489 -397
View File
File diff suppressed because it is too large. Load diff
+7 -6
View File
@@ -461,22 +461,23 @@ describe("validateSubagentPool", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("experimental flag registry", () => { describe("experimental flag registry", () => {
it("mirrors the 0.42.0 v2 registry (6 flags)", () => { it("mirrors the 0.43.0 v2 registry (5 flags)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([ expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"wait_for", "wait_for",
"tool-select", "tool-select",
"notify_user", "notify_user",
"tower", "tower",
"subagent_fork", "subagent_fork",
"auto_session_title",
]); ]);
// Removed upstream: file_history (0.41.0, turn-level file history is // Removed upstream: file_history (0.41.0, turn-level file history is
// always on) plus secondary-model / persistence_minidb_readmodel / // always on), auto_session_title (0.43.0, graduated — AI session titles
// remote-control / search_worker (0.42.0 — promoted or moved to // are always on, #3749), plus secondary-model /
// dedicated config, the minidb read-model now lives under [database]), // persistence_minidb_readmodel / remote-control / search_worker
// so the adapter must not mirror them either. // (0.42.0 — promoted or moved to dedicated config, the minidb read-model
// now lives under [database]), so the adapter must not mirror them either.
for (const id of [ for (const id of [
"file_history", "file_history",
"auto_session_title",
"secondary-model", "secondary-model",
"persistence_minidb_readmodel", "persistence_minidb_readmodel",
"remote-control", "remote-control",
+4 -7
View File
@@ -33,10 +33,11 @@ export interface ExperimentalFlagDef {
defaultEnabled?: boolean; defaultEnabled?: boolean;
} }
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry /** Known experimental flags — mirrors the kimi-code 0.43.0 v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src): * (the per-feature flag.ts files under packages/agent-core-v2/src):
* wait_for, tool-select, notify_user, tower, subagent_fork, * wait_for, tool-select, notify_user, tower, subagent_fork.
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control * Removed in 0.43.0: auto_session_title (graduated — AI session titles are
* always on, #3749). Removed in 0.42.0: secondary-model, remote-control
* and search_worker (promoted or dropped upstream — no flag left), and * and search_worker (promoted or dropped upstream — no flag left), and
* persistence_minidb_readmodel (now controlled by the `[database]` config * persistence_minidb_readmodel (now controlled by the `[database]` config
* section). `acp-v2` was removed upstream earlier; `file_history` was * section). `acp-v2` was removed upstream earlier; `file_history` was
@@ -53,10 +54,6 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" }, { id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" }, { id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" }, { id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{
id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
]; ];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG"; export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
+1
View File
@@ -59,6 +59,7 @@ describe("planLabel", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h"); expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly"); expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly"); expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
expect(planLabel("NewAPI", t)).toBe("NewAPI"); expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥"); expect(planLabel("¥", t)).toBe("¥");
}); });
+15 -3
View File
@@ -5,15 +5,23 @@ type TranslateFn = (
vars?: Record<string, string | number> vars?: Record<string, string | number>
) => string; ) => string;
/**
* Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的
* PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与
* UsageFooter 的金额行判定共用此常量,避免字面量散落。
*/
export const BOOSTER_WALLET_PLAN = "booster_wallet";
/** /**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper * Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged. * nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
* *
* Coverage note: ALL plan-type (套餐) queries flow through * Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only * src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT — so this * from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
* mapping covers every current and future plan provider automatically. If a * TIER_MONTH_CODE — so this mapping covers every current and future plan
* new tier id is ever added there, add a case here too. * provider automatically. If a new tier id is ever added there, add a case
* here too.
*/ */
export function planLabel(name: string, t: TranslateFn): string { export function planLabel(name: string, t: TranslateFn): string {
switch (name) { switch (name) {
@@ -25,6 +33,10 @@ export function planLabel(name: string, t: TranslateFn): string {
return t("usageTierWeekly"); return t("usageTierWeekly");
case "monthly_limit": case "monthly_limit":
return t("usageTierMonthly"); return t("usageTierMonthly");
case "month_code":
return t("usageTierMonthCode");
case BOOSTER_WALLET_PLAN:
return t("usageTierBoosterWallet");
default: default:
return name; return name;
} }
+4 -1
View File
@@ -50,7 +50,10 @@ export function validateProviders(
const reasons: string[] = []; const reasons: string[] = [];
const apiKey = provider.api_key?.trim(); const apiKey = provider.api_key?.trim();
if (!apiKey && !provider.managed) { // kimi-code 2.0.0+: the credential may come from an environment variable
// instead of a stored key — a provider configured that way is complete.
const apiKeyEnv = provider.api_key_env?.trim();
if (!apiKey && !apiKeyEnv && !provider.managed) {
reasons.push(t("validationNoApiKey")); reasons.push(t("validationNoApiKey"));
} }
+21
View File
@@ -15,6 +15,12 @@ export interface Provider {
provider_type: ProviderType; provider_type: ProviderType;
base_url: string | null; base_url: string | null;
api_key: string | null; api_key: string | null;
/**
* Name of the process environment variable holding the credential
* (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the
* export writes only one of the two. Only the variable *name* is stored.
*/
api_key_env?: string | null;
env: Record<string, string>; env: Record<string, string>;
/** Optional note / remark for the provider. */ /** Optional note / remark for the provider. */
note?: string | null; note?: string | null;
@@ -90,6 +96,9 @@ export interface UsageConfig {
autoQueryIntervalMinutes?: number; autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */ /** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number; timeoutSeconds?: number;
/** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。
* 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */
threshold?: number;
} }
/** A model discovered from a provider's API endpoint. */ /** A model discovered from a provider's API endpoint. */
@@ -125,6 +134,9 @@ export interface LoopControlConfig {
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */ * KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number; max_retries_per_step?: number;
reserved_context_size?: number; reserved_context_size?: number;
/** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the
* key is not written and upstream's default (5) applies. */
compaction_max_attempts?: number;
} }
export interface BackgroundConfig { export interface BackgroundConfig {
@@ -132,6 +144,13 @@ export interface BackgroundConfig {
keep_alive_on_exit?: boolean; keep_alive_on_exit?: boolean;
} }
/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in
* 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as
* disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */
export interface WatchConfig {
enabled?: boolean;
}
export interface PermissionRule { export interface PermissionRule {
decision?: "allow" | "deny" | "ask"; decision?: "allow" | "deny" | "ask";
scope?: string; scope?: string;
@@ -150,6 +169,8 @@ export interface AgentSettings {
thinking?: ThinkingConfig; thinking?: ThinkingConfig;
loop_control?: LoopControlConfig; loop_control?: LoopControlConfig;
background?: BackgroundConfig; background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
permission?: { permission?: {
rules?: PermissionRule[]; rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is /** kimi-code `[permission]` key. Default true when the key is
+6
View File
@@ -39,6 +39,12 @@ export interface ActionResponse {
deleted?: boolean | null; deleted?: boolean | null;
} }
export interface BulkArchiveResult {
archived: number;
skipped: number;
errors: string[];
}
export interface PreviewMessage { export interface PreviewMessage {
role: string; role: string;
time: number | null; time: number | null;