Files
KimiCodeSwitch/src-tauri/src/dashboard.rs
T
KimiSwitch Dev 35fd2e7b0e feat(dashboard): 用量统计新增「昨天」范围 + 跨供应商「模型用量统计」选项卡
- 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤
- RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并,
  子代理记录归入 __secondary__ 单独成行,按 Token 降序
- 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图,
  悬停显示请求数/Token/缓存命中/费用
- 概览卡网格扩展为 5 张;i18n 中英同步
- 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3
2026-09-09 13:51:43 +08:00

2435 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::{DateTime, Datelike, Local, TimeZone};
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use walkdir::WalkDir;
// ---------------------------------------------------------------------------
// Types – match the Node API JSON shape exactly
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PathsResult {
pub current: String,
pub valid: bool,
pub candidates: Vec<PathCandidate>,
pub env: EnvInfo,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PathCandidate {
pub path: String,
pub valid: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvInfo {
#[serde(rename = "KIMI_CODE_HOME")]
pub kimi_code_home: Option<String>,
#[serde(rename = "KIMI_MODEL_NAME")]
pub kimi_model_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PriceRow {
pub id: String,
pub cache_hit: f64,
pub input: f64,
pub output: f64,
pub context: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PricesResult {
pub prices: Vec<PriceRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SummaryResult {
pub home: String,
pub valid: bool,
pub scanned_at: u64,
pub meta: ScanMeta,
pub model_map: ModelMapInfo,
pub range: String,
pub stats: RangeStats,
pub heatmap: HeatmapData,
pub all_models: Vec<AllModelRow>,
pub all_model_count: usize,
pub range_totals: HashMap<String, TotalsRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ScanMeta {
pub files_scanned: usize,
pub lines_seen: usize,
pub record_count: usize,
pub home: String,
pub sessions_root: String,
pub errors: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelMapInfo {
pub default_model: Option<String>,
pub env_model: Option<EnvModelInfo>,
pub alias_count: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EnvModelInfo {
pub name: String,
pub provider: Option<String>,
pub model: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RangeStats {
pub range: String,
pub totals: TotalsRow,
pub daily: Vec<DailyRow>,
pub models: Vec<ModelRow>,
/// Model totals keyed by bare model name (provider prefix stripped), so the
/// same model served by multiple providers merges into one row.
pub models_by_name: Vec<ModelRow>,
pub recent: Vec<RecentRow>,
pub recent_total: usize,
pub recent_limit: usize,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TotalsRow {
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct DailyRow {
pub date: String,
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
/// Per-model structured breakdown (tokens / requests / cost / cacheHitRate).
pub by_model: HashMap<String, TotalsRow>,
/// Per-provider token breakdown for stacked-bar rendering
pub by_provider: HashMap<String, u64>,
/// Per-provider, per-model nested token breakdown (provider → model → tokens),
/// used by the provider-tab drill-down detail.
pub by_provider_model: HashMap<String, HashMap<String, u64>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ModelRow {
pub model: String,
pub model_display: String,
pub model_resolved: String,
pub price_id: String,
pub cost_estimated: bool,
/// True when any aggregated record came from a subagent request (Kimi Code
/// `__secondary__` marker, resolved to the configured secondary model).
pub is_secondary: bool,
pub requests: usize,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub total_tokens: u64,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecentRow {
pub time: u64,
pub model: String,
pub model_display: String,
pub model_resolved: String,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub total_tokens: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub price_id: String,
pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AllModelRow {
pub model: String,
pub model_display: String,
pub requests: usize,
pub total_tokens: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub cache_hit_rate: f64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeatmapData {
pub weeks: usize,
pub start: String,
pub end: String,
pub max_tokens: u64,
pub cells: Vec<HeatmapCell>,
pub month_labels: Vec<MonthLabel>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct HeatmapCell {
pub date: String,
pub dow: usize,
pub week_index: usize,
pub requests: usize,
pub total_tokens: u64,
pub cost_usd: f64,
pub cache_hit_rate: f64,
pub level: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MonthLabel {
pub week_index: usize,
pub label: String,
}
// Sessions types
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionsResult {
pub home: String,
pub archive_root: String,
pub workspaces: Vec<WorkspaceRow>,
pub sessions: Vec<SessionRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceRow {
pub id: String,
pub name: String,
pub root: Option<String>,
pub created_at: Option<String>,
pub last_opened_at: Option<String>,
pub active_count: usize,
pub archived_count: usize,
pub empty: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct SessionRow {
pub id: String,
pub workspace_id: String,
pub status: String,
pub title: Option<String>,
pub work_dir: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub bytes: u64,
pub files: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ActionResponse {
pub ok: bool,
pub workspace_id: String,
pub session_id: String,
pub status: Option<String>,
pub path: Option<String>,
pub deleted: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreviewResult {
pub workspace_id: String,
pub session_id: String,
pub status: String,
pub title: Option<String>,
pub work_dir: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
pub message_count: usize,
pub truncated: bool,
pub messages: Vec<PreviewMessage>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct PreviewMessage {
pub role: String,
pub time: Option<u64>,
pub text: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct WorkspaceDeleteBody {
pub workspace_id: String,
pub confirm: Option<bool>,
pub force: Option<bool>,
}
// ---------------------------------------------------------------------------
// App state
// ---------------------------------------------------------------------------
pub struct AppState {
pub scan_cache: Mutex<ScanCache>,
}
#[derive(Clone)]
pub struct ScanCache {
pub home: String,
pub scanned_at: u64,
pub records: Vec<UsageRecord>,
pub meta: ScanMeta,
pub model_map: ModelMapInfo,
}
#[derive(Debug, Clone)]
pub struct UsageRecord {
pub time: u64,
pub model: String,
pub input_other: u64,
pub output: u64,
pub input_cache_read: u64,
pub input_cache_creation: u64,
pub cost_usd: f64,
pub cost_estimated: bool,
pub price_id: String,
pub model_resolved: String,
pub model_display: String,
pub provider: Option<String>,
pub from_env: bool,
/// True when the record is a subagent request bound to the configured
/// secondary model (Kimi Code `__secondary__` marker).
pub is_secondary: bool,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Resolve the Kimi Code home directory: explicit override, then
/// `KIMI_CODE_HOME`, then the default `~/.kimi-code`. Shared with the plugin
/// marketplace module (`crate::plugins`).
pub(crate) fn resolve_kimi_home(override_path: Option<String>) -> PathBuf {
if let Some(p) = override_path {
if !p.trim().is_empty() {
return PathBuf::from(p);
}
}
if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") {
if !env_home.trim().is_empty() {
return PathBuf::from(env_home);
}
}
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".kimi-code")
}
fn is_kimi_home(dir: &Path) -> bool {
if !dir.exists() || !dir.is_dir() {
return false;
}
dir.join("config.toml").exists() || dir.join("sessions").exists()
}
/// Build a legacy alias→provider map by merging the current config.toml with every
/// `config.toml.bak.*` snapshot (home root + backups/). Historical usage records may
/// reference model aliases that no longer exist in the current config (old bare or
/// `-N`-suffixed aliases); their provider is recovered from these snapshots.
fn build_alias_provider_map(home: &Path) -> HashMap<String, String> {
let mut map: HashMap<String, String> = HashMap::new();
let mut candidates: Vec<PathBuf> = Vec::new();
let current = home.join("config.toml");
if current.is_file() {
candidates.push(current);
}
for dir in [home.to_path_buf(), home.join("backups")] {
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with("config.toml.bak.") {
candidates.push(e.path());
}
}
}
}
for path in candidates {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let parsed: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => continue,
};
if let Some(models) = parsed.get("models").and_then(|m| m.as_table()) {
for (alias, m) in models {
if let Some(p) = m.get("provider").and_then(|p| p.as_str()) {
// Current config is parsed first and wins on conflict.
map.entry(alias.clone()).or_insert_with(|| p.to_string());
}
}
}
}
map
}
/// Read `[secondary_model] model` from the Kimi Code config.toml — the alias
/// subagents bind to when the experimental secondary-model feature is on.
/// Kimi Code emits usage records with the internal marker `__secondary__` for
/// those requests; the marker is resolved to this alias so prices and display
/// line up with the actual model.
fn read_secondary_model_alias(home: &Path) -> Option<String> {
let path = home.join("config.toml");
if !path.is_file() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let parsed: toml::Value = toml::from_str(&content).ok()?;
parsed
.get("secondary_model")
.and_then(|s| s.get("model"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Resolve the provider for a usage record's raw model key: prefer the `provider/`
/// prefix, then fall back to the legacy alias→provider map (config + backups).
fn resolve_provider(model_raw: &str, map: &HashMap<String, String>) -> Option<String> {
let p = model_raw
.rsplit_once('/')
.map(|(prov, _)| prov.to_string())
.or_else(|| map.get(model_raw).cloned());
p.map(|x| normalize_provider(&x))
}
/// Normalize historical provider-name variants to the canonical name.
fn normalize_provider(p: &str) -> String {
match p {
"CodingPlanSite" => "CodingPlan.site".to_string(),
other => other.to_string(),
}
}
fn sessions_root(home: &Path) -> PathBuf {
home.join("sessions")
}
fn workspace_re() -> Regex {
Regex::new(r"^wd_[A-Za-z0-9._-]+$").unwrap()
}
fn session_re() -> Regex {
Regex::new(r"^session_[0-9a-fA-F-]{8,}$").unwrap()
}
fn safe_read_dir(path: &Path) -> Vec<fs::DirEntry> {
let mut entries = Vec::new();
if let Ok(rd) = fs::read_dir(path) {
for e in rd.flatten() {
entries.push(e);
}
}
entries
}
fn file_size_approx(path: &Path) -> (u64, usize) {
let mut total_bytes = 0u64;
let mut total_files = 0usize;
for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
if entry.file_type().is_file() {
if let Ok(meta) = entry.metadata() {
total_bytes += meta.len();
total_files += 1;
}
}
}
(total_bytes, total_files)
}
fn day_key(ts_ms: u64) -> String {
let secs = if ts_ms > 1e12 as u64 { ts_ms / 1000 } else { ts_ms };
// Bucket by LOCAL calendar date (not UTC) so records around midnight stay on the correct day.
let local = Local
.timestamp_opt(secs as i64, 0)
.single()
.unwrap_or_else(|| DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().with_timezone(&Local));
format!("{:04}-{:02}-{:02}", local.year(), local.month(), local.day())
}
// ---------------------------------------------------------------------------
// Pricing
// ---------------------------------------------------------------------------
fn list_prices() -> Vec<PriceRow> {
vec![
PriceRow { id: "kimi-k3".into(), cache_hit: 0.30, input: 3.00, output: 15.00, context: 1_048_576 },
PriceRow { id: "kimi-k2.7-code".into(), cache_hit: 0.19, input: 0.95, output: 4.00, context: 262_144 },
PriceRow { id: "kimi-k2.6".into(), cache_hit: 0.16, input: 0.95, output: 4.00, context: 262_144 },
PriceRow { id: "kimi-k2.5".into(), cache_hit: 0.10, input: 0.60, output: 3.00, context: 262_144 },
PriceRow { id: "kimi-k2-turbo".into(), cache_hit: 0.15, input: 1.15, output: 8.00, context: 262_144 },
PriceRow { id: "kimi-k2".into(), cache_hit: 0.15, input: 0.60, output: 2.50, context: 262_144 },
]
}
/// Per-model price from the models.dev snapshot (all values in $/M tokens).
#[derive(Debug, Clone, Copy)]
struct ModelsDevCost {
input: f64,
output: f64,
cache_read: f64,
/// None = models.dev has no cache_write field → caller falls back to input.
cache_write: Option<f64>,
}
/// Compiled-in models.dev snapshot (`src/lib/models-dev.json`, generated by
/// scripts/fetch-models-dev.mjs before every build via the `pretauri` hook).
/// Keyed by "<provider>/<model>", lowercased.
const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
fn models_dev_cost_index() -> &'static HashMap<String, ModelsDevCost> {
static INDEX: OnceLock<HashMap<String, ModelsDevCost>> = OnceLock::new();
INDEX.get_or_init(|| {
let mut map = HashMap::new();
let Ok(v) = serde_json::from_str::<serde_json::Value>(MODELS_DEV_SNAPSHOT) else {
return map;
};
let Some(obj) = v.as_object() else {
return map;
};
for (key, entry) in obj {
// Skip the "last_updated" metadata key and entries without cost.
if !entry.is_object() || entry.get("cost").is_none() {
continue;
}
let Some(cost) = entry.get("cost").and_then(|c| c.as_object()) else {
continue;
};
let num = |k: &str| cost.get(k).and_then(|x| x.as_f64());
let (Some(input), Some(output)) = (num("input"), num("output")) else {
continue;
};
map.insert(
key.to_ascii_lowercase(),
ModelsDevCost {
input,
output,
cache_read: num("cache_read").unwrap_or(0.0),
cache_write: num("cache_write"),
},
);
}
map
})
}
/// Warm the compiled-in models.dev price index off the first dashboard open:
/// parsing the ~1.3 MB snapshot costs tens of milliseconds, so build it on a
/// background thread at startup instead of lazily on the first get_summary.
pub fn warm_price_index() {
std::thread::spawn(|| {
let _ = models_dev_cost_index();
});
}
/// Official providers take precedence over resellers when the same model id
/// ships under multiple providers and no provider prefix disambiguates.
const OFFICIAL_PROVIDERS: &[&str] = &[
"openai", "anthropic", "google", "deepseek", "moonshotai", "zhipuai",
"minimax", "x-ai", "meta", "mistral", "qwen", "doubao", "volcengine",
"baidu", "tencent", "nvidia",
];
fn provider_rank(key: &str) -> usize {
// key = "<provider>/<model>"
let provider = key.split('/').next().unwrap_or("");
OFFICIAL_PROVIDERS
.iter()
.position(|p| *p == provider)
.unwrap_or(usize::MAX)
}
/// Look up a model's price in the models.dev snapshot. Resolution order:
/// 1. exact key match (e.g. "moonshotai/kimi-k2.5" passed verbatim),
/// 2. suffix match on the bare model id, preferring an exact provider prefix,
/// then an official provider, then lexicographically smallest key
/// (deterministic tie-break). Returns None when nothing matches.
fn models_dev_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
let lower = model_name.to_ascii_lowercase();
if let Some(cost) = models_dev_cost_index().get(&lower) {
return Some((lower, *cost));
}
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
// Match on the model part after the last '/', not the full key: aliases
// strip the family prefix (record "k2.5" vs models.dev "kimi-k2.5").
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
.iter()
.filter(|(key, _)| {
key.rsplit_once('/')
.map(|(_, model)| model.ends_with(&bare_l))
.unwrap_or(false)
})
.collect();
if matches.is_empty() {
return None;
}
matches.sort_by(|(a, _), (b, _)| {
provider_rank(a)
.cmp(&provider_rank(b))
.then_with(|| a.cmp(b))
});
let (key, cost) = matches[0];
Some((key.clone(), *cost))
}
/// Strip a trailing derived-variant suffix ("-highspeed", "-free") from a
/// model id: plan endpoints expose e.g. `glm-5.2-highspeed`, which bills at
/// the base model's price.
fn strip_variant_suffix(model_name: &str) -> Option<String> {
for suffix in ["-highspeed", "-free"] {
if let Some(base) = model_name.strip_suffix(suffix) {
if !base.is_empty() {
return Some(base.to_string());
}
}
}
None
}
/// Strip a trailing context-size suffix ("-256k", "-128k", "-1m") from a model
/// id: context variants like "k3-256k" bill at the base model's ("k3") price.
/// Returns the stripped id (provider prefix preserved), or None when there is
/// no such suffix.
fn strip_context_suffix(model_name: &str) -> Option<String> {
let (prefix, bare) = match model_name.rsplit_once('/') {
Some((p, b)) => (Some(p), b),
None => (None, model_name),
};
let (stem, suffix) = bare.rsplit_once('-')?;
let digits = suffix.strip_suffix(|c| matches!(c, 'k' | 'K' | 'm' | 'M'))?;
if stem.is_empty() || digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some(match prefix {
Some(p) => format!("{p}/{stem}"),
None => stem.to_string(),
})
}
/// Memoized price resolution. `match_price` is called once per usage record
/// during the scan, and the models.dev suffix scan is O(5910) per miss — with
/// thousands of records sharing a handful of model names, memoizing the result
/// turns the whole scan from O(records × 5910) into O(unique_models × 5910).
static MATCH_PRICE_MEMO: OnceLock<Mutex<HashMap<String, (String, f64, f64, f64, bool)>>> =
OnceLock::new();
/// Resolve a model's price, caching per-model results (hits and misses).
fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
let key = model_name.to_ascii_lowercase();
let memo = MATCH_PRICE_MEMO.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(hit) = memo.lock().unwrap().get(&key) {
return hit.clone();
}
let result = match_price_inner(model_name);
memo.lock().unwrap().insert(key, result.clone());
result
}
/// Suffix-match a model against the models.dev index, skipping zero-priced
/// (subscription-plan) listings so a paid official entry wins. Used as the
/// fallback when the exact hit resolves to a zero-cost plan entry such as
/// `zhipuai-coding-plan/glm-5.2` or `kimi-for-coding/kimi-for-coding`.
fn priced_suffix_lookup(model_name: &str) -> Option<(String, ModelsDevCost)> {
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
let mut matches: Vec<(&String, &ModelsDevCost)> = models_dev_cost_index()
.iter()
.filter(|(key, cost)| {
(cost.input > 0.0 || cost.output > 0.0)
&& key
.rsplit_once('/')
.map(|(_, model)| model.ends_with(&bare_l))
.unwrap_or(false)
})
.collect();
if matches.is_empty() {
return None;
}
matches.sort_by(|(a, _), (b, _)| {
provider_rank(a)
.cmp(&provider_rank(b))
.then_with(|| a.cmp(b))
});
let (key, cost) = matches[0];
Some((key.clone(), *cost))
}
/// Subscription-plan models without any priced counterpart in the snapshot
/// (e.g. the "Kimi For Coding" plan endpoint has no per-token price) bill at
/// the flagship model of the same family for cost estimation.
const PLAN_MODEL_FALLBACK: &[(&str, &str)] = &[
("kimi-for-coding", "moonshotai/kimi-k3"),
];
fn match_price_inner(model_name: &str) -> (String, f64, f64, f64, bool) {
let bare = match model_name.rsplit_once('/') {
Some((_, b)) => b,
None => model_name,
};
// 1) models.dev snapshot first (authoritative, all providers).
if let Some((id, c)) = models_dev_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
// Zero-priced listing (subscription-plan entries such as
// zhipuai-coding-plan/glm-5.2 or kimi-for-coding/kimi-for-coding):
// bill at a priced equivalent — the full id first, then derived
// variants (-highspeed / -free), then the context-size base, then the
// curated flagship fallback.
for base in [
Some(model_name.to_string()),
strip_variant_suffix(model_name),
strip_context_suffix(model_name),
]
.into_iter()
.flatten()
{
if let Some((bid, bc)) = priced_suffix_lookup(&base) {
if bc.input > 0.0 || bc.output > 0.0 {
return (bid, bc.cache_read, bc.input, bc.output, false);
}
}
}
let bare = model_name.rsplit_once('/').map(|(_, b)| b).unwrap_or(model_name);
let bare_l = bare.to_ascii_lowercase();
for (plan, flagship) in PLAN_MODEL_FALLBACK {
if bare_l.contains(plan) {
if let Some((fid, fc)) = models_dev_lookup(flagship) {
return (fid, fc.cache_read, fc.input, fc.output, false);
}
}
}
return (id, c.cache_read, c.input, c.output, false);
}
// 1b) no direct match: retry with the context-size suffix stripped
// ("k3-256k" → "k3") before falling back to legacy tables.
if let Some(base) = strip_context_suffix(model_name) {
if let Some((id, c)) = models_dev_lookup(&base) {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 1c) still no exact match: fall back to a cross-provider bare-name
// suffix lookup in the models.dev snapshot. Custom-gateway aliases (e.g.
// "CodingPlan.site/deepseek-v4-flash", not catalogued in models.dev)
// then price against the official listing of the same model id.
if let Some((id, c)) = priced_suffix_lookup(model_name) {
if c.input > 0.0 || c.output > 0.0 {
return (id, c.cache_read, c.input, c.output, false);
}
}
// 2) legacy Kimi table (kept as a fallback for names not in models.dev).
let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() {
let id_l = p.id.to_ascii_lowercase();
if bare_l == id_l || bare_l.contains(&id_l) || id_l.contains(&bare_l) {
return (p.id.clone(), p.cache_hit, p.input, p.output, false);
}
}
// 3) last-resort estimate.
("kimi-k2.6".into(), 0.16, 0.95, 4.00, true)
}
fn cost_for_usage(input_other: u64, output: u64, cache_read: u64, cache_create: u64, model: &str) -> (f64, bool) {
let (price_id, cache_hit, input_price, output_price, est) = match_price(model);
// models.dev reports a separate cache_write price; fall back to input when
// absent (models without a cache_write field bill cache creation at input).
// Resolve via the matched price id so context-suffix fallbacks (k3-256k →
// k3) also pick up the base model's cache_write price.
let cache_write_price = models_dev_lookup(&price_id)
.and_then(|(_, c)| c.cache_write)
.unwrap_or(input_price);
let cost = (input_other as f64 / 1e6) * input_price
+ (cache_read as f64 / 1e6) * cache_hit
+ (cache_create as f64 / 1e6) * cache_write_price
+ (output as f64 / 1e6) * output_price;
(cost, est)
}
// ---------------------------------------------------------------------------
// Scanner
// ---------------------------------------------------------------------------
/// Settings key caching the last configured secondary-model alias. Historical
/// `__secondary__` usage records carry no model identity, so billing follows
/// the currently configured secondary model; this cache keeps a stable basis
/// even after the secondary config is removed or changed.
const SECONDARY_MODEL_CACHE_KEY: &str = "dashboard.secondary_model_cache";
fn scan_usage(home: &Path) -> (Vec<UsageRecord>, ScanMeta) {
let root = sessions_root(home);
let alias2prov = build_alias_provider_map(home);
// Resolve the `__secondary__` marker (subagent requests bound to the
// configured secondary model) to the real model alias once per scan.
// Prefer the current config; fall back to the persisted cache so records
// keep a stable billing basis after the secondary config is removed or
// changed. When the config still declares a secondary model, refresh the
// cache with it.
let current_secondary = read_secondary_model_alias(home);
let cached_secondary = crate::db::get_setting_pub(SECONDARY_MODEL_CACHE_KEY)
.ok()
.flatten()
.filter(|s| !s.is_empty());
let secondary_alias = current_secondary.clone().or(cached_secondary);
if let Some(alias) = &current_secondary {
let _ = crate::db::set_setting_pub(SECONDARY_MODEL_CACHE_KEY, alias);
}
let mut records = Vec::new();
let mut files_scanned = 0;
let mut lines_seen = 0;
let errors = Vec::new();
if !root.exists() {
return (records, ScanMeta {
files_scanned: 0, lines_seen: 0, record_count: 0,
home: home.to_string_lossy().to_string(),
sessions_root: root.to_string_lossy().to_string(),
errors: vec!["sessions directory not found".into()],
});
}
for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) {
if entry.file_name() != "wire.jsonl" { continue; }
if !entry.file_type().is_file() { continue; }
// Skip blob/task directories
let p = entry.path();
if p.to_string_lossy().contains("blobs") || p.to_string_lossy().contains("tasks") {
continue;
}
files_scanned += 1;
let content = match fs::read_to_string(entry.path()) {
Ok(c) => c,
Err(_) => continue,
};
for line in content.lines() {
lines_seen += 1;
if !line.contains("\"usage.record\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if obj.get("type").and_then(|v| v.as_str()) != Some("usage.record") { continue; }
let scope = obj.get("usageScope").and_then(|v| v.as_str()).unwrap_or("turn");
if scope != "turn" { continue; }
let model_raw = obj.get("model").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
// Kimi Code writes the internal marker `__secondary__` as the model
// key for usage records emitted by subagents bound to the
// configured secondary model. The record stays keyed on that
// stable marker so historical records keep their semantics if the
// secondary config changes later; cost billing follows the
// currently configured secondary model, and the record is flagged
// is_secondary so the UI shows it as its own "subagent model"
// entry.
let is_secondary = model_raw == "__secondary__";
let from_env = model_raw == "__kimi_env_model__";
let price_model = if is_secondary {
secondary_alias.clone().unwrap_or_else(|| model_raw.clone())
} else {
model_raw.clone()
};
let time = obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0);
let usage = &obj["usage"];
let input_other = usage.get("inputOther").and_then(|v| v.as_u64()).unwrap_or(0);
let output = usage.get("output").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_read = usage.get("inputCacheRead").and_then(|v| v.as_u64()).unwrap_or(0);
let cache_create = usage.get("inputCacheCreation").and_then(|v| v.as_u64()).unwrap_or(0);
// Resolve model display
let bare = model_raw.rsplit_once('/').map(|x| x.1).unwrap_or(&model_raw);
let (cost, est) = cost_for_usage(input_other, output, cache_read, cache_create, &price_model);
// Subagent records are inherently estimates: the `__secondary__`
// marker carries no model identity, so the cost is flagged as
// estimated even when the current secondary config resolves to a
// priced listing.
let est = est || is_secondary;
let (pid, _ch, _ip, _op, _) = match_price(&price_model);
records.push(UsageRecord {
time,
model: model_raw.clone(),
model_resolved: bare.to_string(),
model_display: model_raw.clone(),
provider: resolve_provider(&price_model, &alias2prov),
from_env,
is_secondary,
input_other,
output,
input_cache_read: cache_read,
input_cache_creation: cache_create,
cost_usd: cost,
cost_estimated: est,
price_id: pid,
});
}
}
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(),
})
}
// ---------------------------------------------------------------------------
// Scan cache
// ---------------------------------------------------------------------------
/// Short-TTL cache for the expensive `scan_usage` walk (reads every
/// wire.jsonl under the sessions root and prices every record). Tab switches
/// call get_summary repeatedly; re-walking the whole tree on every switch is
/// the main dashboard lag. Keyed by home, expires after 8s; `refresh=true`
/// bypasses it.
struct SummaryCacheEntry {
home: String,
scanned_at: std::time::Instant,
records: Vec<UsageRecord>,
meta: ScanMeta,
}
static SUMMARY_CACHE: Mutex<Option<SummaryCacheEntry>> = Mutex::new(None);
const SUMMARY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(8);
fn scan_usage_cached(home: &Path, refresh: bool) -> (Vec<UsageRecord>, ScanMeta) {
let home_s = home.to_string_lossy().to_string();
let mut cache = SUMMARY_CACHE.lock().unwrap();
let hit = cache
.as_ref()
.is_some_and(|e| !refresh && e.home == home_s && e.scanned_at.elapsed() < SUMMARY_CACHE_TTL);
if hit {
let e = cache.as_ref().unwrap();
return (e.records.clone(), e.meta.clone());
}
let (records, meta) = scan_usage(home);
*cache = Some(SummaryCacheEntry {
home: home_s,
scanned_at: std::time::Instant::now(),
records: records.clone(),
meta: meta.clone(),
});
(records, meta)
}
// ---------------------------------------------------------------------------
// Aggregate
// ---------------------------------------------------------------------------
fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
let filtered: Vec<&UsageRecord> = filter_by_range(records, range, now_ms);
let range_label = range.to_string();
if filtered.is_empty() {
return RangeStats {
range: range_label,
totals: TotalsRow::default(),
daily: vec![],
models: vec![],
models_by_name: vec![],
recent: vec![],
recent_total: 0,
recent_limit: 500,
};
}
let mut totals = TotalsRow::default();
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
let mut by_name: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
for r in &filtered {
totals.add(r);
let dk = day_key(r.time);
let (day_totals, day_models, day_prov_models) = by_day.entry(dk.clone()).or_default();
day_totals.add(r);
day_models.entry(r.model.clone()).or_default().add(r);
let provider_key = r.provider.clone().unwrap_or_else(|| "unknown".to_string());
*day_prov_models
.entry(provider_key)
.or_default()
.entry(r.model.clone())
.or_insert(0) += r.input_other + r.output + r.input_cache_read + r.input_cache_creation;
let mk = r.model.clone();
let entry = by_model.entry(mk.clone()).or_insert_with(|| {
let m = ModelRow {
model: mk.clone(),
model_display: r.model_display.clone(),
model_resolved: r.model_resolved.clone(),
price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
};
(m, TotalsRow::default())
});
entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
// Provider-agnostic bucket: secondary (subagent) records keep the
// stable "__secondary__" marker; everything else keys on the bare
// model name so the same model across providers merges into one row.
let nk = if r.is_secondary { "__secondary__".to_string() } else { r.model_resolved.clone() };
let name_entry = by_name.entry(nk.clone()).or_insert_with(|| {
let m = ModelRow {
model: nk.clone(),
model_display: nk.clone(),
model_resolved: nk.clone(),
price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
};
(m, TotalsRow::default())
});
name_entry.1.add(r);
name_entry.0.cost_estimated = name_entry.0.cost_estimated || r.cost_estimated;
name_entry.0.is_secondary = name_entry.0.is_secondary || r.is_secondary;
}
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
totals.cache_hit_rate = if total_input > 0 { totals.input_cache_read as f64 / total_input as f64 } else { 0.0 };
let mut daily: Vec<DailyRow> = by_day.into_iter()
.map(|(date, (t, by_model, by_provider_model))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
// Per-model cache_hit_rate (not cumulative; computed from the
// model's own input token split).
let by_model_finalized: HashMap<String, TotalsRow> = by_model
.into_iter()
.map(|(k, mut mt)| {
let mi = mt.input_other + mt.input_cache_read + mt.input_cache_creation;
mt.cache_hit_rate = if mi > 0 { mt.input_cache_read as f64 / mi as f64 } else { 0.0 };
(k, mt)
})
.collect();
DailyRow {
date, requests: t.requests,
input_other: t.input_other, output: t.output,
input_cache_read: t.input_cache_read, input_cache_creation: t.input_cache_creation,
cost_usd: t.cost_usd, total_tokens: t.total_tokens, cache_hit_rate: ch,
by_model: by_model_finalized,
by_provider: by_provider_model
.iter()
.map(|(p, m)| (p.clone(), m.values().sum::<u64>()))
.collect(),
by_provider_model,
}
})
.collect();
daily.sort_by(|a, b| a.date.cmp(&b.date));
let mut models: Vec<ModelRow> = by_model.into_iter().map(|(_, (mut m, t))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
m.requests = t.requests;
m.input_other = t.input_other; m.output = t.output;
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
m
}).collect();
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
let mut models_by_name: Vec<ModelRow> = by_name.into_iter().map(|(_, (mut m, t))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
m.requests = t.requests;
m.input_other = t.input_other; m.output = t.output;
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
m
}).collect();
models_by_name.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
let recent_total = filtered.len();
let recent_limit = 500;
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
time: r.time, model: r.model.clone(), model_display: r.model_display.clone(),
model_resolved: r.model_resolved.clone(),
input_other: r.input_other, output: r.output,
input_cache_read: r.input_cache_read, input_cache_creation: r.input_cache_creation,
total_tokens: r.input_other + r.output + r.input_cache_read + r.input_cache_creation,
cost_usd: r.cost_usd, cost_estimated: r.cost_estimated,
price_id: r.price_id.clone(), from_env: r.from_env,
is_secondary: r.is_secondary,
}).collect();
RangeStats {
range: range_label,
totals,
daily,
models,
models_by_name,
recent,
recent_total,
recent_limit,
}
}
impl TotalsRow {
fn default() -> Self {
TotalsRow {
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
}
}
fn add(&mut self, r: &UsageRecord) {
self.requests += 1;
self.input_other += r.input_other;
self.output += r.output;
self.input_cache_read += r.input_cache_read;
self.input_cache_creation += r.input_cache_creation;
self.cost_usd += r.cost_usd;
self.total_tokens = self.input_other + self.output + self.input_cache_read + self.input_cache_creation;
}
}
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
if range == "all" { return records.iter().collect(); }
let start = range_start(range, now_ms);
match range_end(range, now_ms) {
Some(end) => records.iter().filter(|r| r.time >= start && r.time < end).collect(),
None => records.iter().filter(|r| r.time >= start).collect(),
}
}
/// Upper bound (exclusive) for bounded ranges. Only "yesterday" has one —
/// it must not bleed into today.
fn range_end(range: &str, now_ms: u64) -> Option<u64> {
match range {
"yesterday" => Some(local_midnight_ms(now_ms, 0)),
_ => None,
}
}
/// LOCAL midnight `days_ago` days before today, in epoch ms. Not UTC midnight:
/// without local-midnight anchoring, users east of UTC see day boundaries at
/// the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
fn local_midnight_ms(now_ms: u64, days_ago: i64) -> u64 {
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let date = now_local.date_naive() - chrono::Duration::days(days_ago);
let day_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
Local
.from_local_datetime(&day_start)
.single()
.map(|dt| dt.timestamp() as u64 * 1000)
.unwrap_or_else(|| day_start.and_utc().timestamp() as u64 * 1000)
}
fn range_start(range: &str, now_ms: u64) -> u64 {
match range {
"today" => local_midnight_ms(now_ms, 0),
"yesterday" => local_midnight_ms(now_ms, 1),
"7d" => now_ms - 7 * 24 * 3600 * 1000,
"30d" => now_ms - 30 * 24 * 3600 * 1000,
_ => now_ms - 30 * 24 * 3600 * 1000,
}
}
fn build_heatmap(records: &[UsageRecord], now_ms: u64) -> HeatmapData {
let weeks = 53;
// Use LOCAL today for the heatmap's right edge so the current day's cell lights up correctly.
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let end_date = now_local.date_naive();
let start_date = end_date - chrono::Duration::days(weeks * 7 - 1);
let start_date = start_date - chrono::Duration::days(start_date.weekday().num_days_from_sunday() as i64);
let mut by_day: HashMap<String, TotalsRow> = HashMap::new();
for r in records {
let dk = day_key(r.time);
let t = by_day.entry(dk).or_default();
t.add(r);
}
let mut cursor = start_date;
let mut cells = Vec::new();
let mut max_tokens = 0u64;
while cursor <= end_date {
let key = format!("{:04}-{:02}-{:02}", cursor.year(), cursor.month(), cursor.day());
let t = by_day.get(&key).cloned().unwrap_or_else(TotalsRow::default);
let tok = t.total_tokens;
if tok > max_tokens { max_tokens = tok; }
let dow = cursor.weekday().num_days_from_sunday() as usize;
let week_idx = ((cursor - start_date).num_days() / 7) as usize;
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
cells.push(HeatmapCell {
date: key, dow, week_index: week_idx,
requests: t.requests, total_tokens: tok, cost_usd: t.cost_usd,
cache_hit_rate: ch, level: 0,
});
cursor += chrono::Duration::days(1);
if cells.len() > (weeks * 7 + 7) as usize { break; }
}
for c in &mut cells {
c.level = if max_tokens == 0 || c.total_tokens == 0 {
0
} else {
let r = c.total_tokens as f64 / max_tokens as f64;
if r > 0.75 { 4 } else if r > 0.5 { 3 } else if r > 0.25 { 2 } else { 1 }
};
}
let mut month_labels = Vec::new();
let mut last_month = String::new();
for c in &cells {
let m = c.date[..7].to_string();
if m != last_month {
month_labels.push(MonthLabel { week_index: c.week_index, label: c.date[5..7].to_string() });
last_month = m;
}
}
HeatmapData {
weeks: weeks as usize,
start: start_date.format("%Y-%m-%d").to_string(),
end: end_date.format("%Y-%m-%d").to_string(),
max_tokens,
cells,
month_labels,
}
}
// ---------------------------------------------------------------------------
// Sessions
// ---------------------------------------------------------------------------
fn list_workspace_dirs(home: &Path) -> Vec<String> {
let root = sessions_root(home);
if !root.exists() { return vec![]; }
let mut out = Vec::new();
let re = workspace_re();
for entry in safe_read_dir(&root) {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string();
if name == ".kcd-archive" { continue; }
if !re.is_match(&name) { continue; }
out.push(name);
}
out.sort();
out
}
fn read_state_safe(session_dir: &Path) -> (Option<String>, Option<String>, Option<String>, Option<String>) {
let sp = session_dir.join("state.json");
if !sp.exists() { return (None, None, None, None); }
if let Ok(content) = fs::read_to_string(&sp) {
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) {
let title = obj.get("title").and_then(|v| v.as_str().map(|s| s.to_string()));
let work_dir = obj.get("workDir").and_then(|v| v.as_str().map(|s| s.to_string()));
let created_at = obj.get("createdAt").and_then(|v| v.as_str().map(|s| s.to_string()));
let updated_at = obj.get("updatedAt").and_then(|v| v.as_str().map(|s| s.to_string()));
return (title, work_dir, created_at, updated_at);
}
}
(None, None, None, None)
}
/// Whether a session is archived per its `state.json` metadata: the
/// kimi-code v2 engine writes `archived: true` (plus an `archivedAt` epoch-ms
/// timestamp) when archiving and clears both on restore. Unparseable or
/// missing state.json counts as not archived (and won't panic).
fn read_state_archived(session_dir: &Path) -> bool {
let sp = session_dir.join("state.json");
let Ok(content) = fs::read_to_string(&sp) else { return false };
let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
obj.get("archived").and_then(|v| v.as_bool()).unwrap_or(false)
}
/// Set or clear the archived metadata on a session's `state.json`, preserving
/// every other field. Mirrors upstream kimi-code v2 `SessionMetadata.setArchived`:
/// archive writes `{ archived: true, archivedAt: Date.now() }` (epoch ms, same
/// `Date.now()` format as the CLI), restore writes `{ archived: false }` and
/// drops the `archivedAt` key; `updatedAt` is untouched in both cases.
/// The write is atomic (tmp file + rename), same pattern as the plugin store.
/// Missing or malformed state.json returns a descriptive error, never a panic.
fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), String> {
let sp = session_dir.join("state.json");
let content = fs::read_to_string(&sp).map_err(|e| format!("read state.json: {e}"))?;
let mut obj: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("parse state.json: {e}"))?;
let obj = obj
.as_object_mut()
.ok_or_else(|| "state.json is not a JSON object".to_string())?;
obj.insert("archived".into(), serde_json::Value::Bool(archived));
if archived {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
obj.insert("archivedAt".into(), serde_json::Value::Number(now_ms.into()));
} else {
obj.remove("archivedAt");
}
let out = serde_json::to_string(&obj).map_err(|e| format!("serialize state.json: {e}"))?;
let tmp = sp.with_file_name("state.json.tmp");
fs::write(&tmp, out).map_err(|e| format!("write state.json: {e}"))?;
fs::rename(&tmp, &sp).map_err(|e| format!("commit state.json: {e}"))?;
Ok(())
}
fn humanize_workspace(id: &str) -> String {
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
if let Some(caps) = re.captures(id) {
caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_else(|| id.to_string())
} else {
id.to_string()
}
}
/// List session rows under one directory. `in_archive_dir` marks `.kcd-archive`
/// (legacy physical archive); a session is additionally archived when its own
/// `state.json` carries `archived: true` — so CLI-v2 metadata-archived sessions
/// that still live at the active root are correctly classified.
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, in_archive_dir: bool) -> Vec<SessionRow> {
let mut sessions = Vec::new();
if !dir.exists() { return sessions; }
let re = session_re();
for entry in safe_read_dir(dir) {
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string();
if !re.is_match(&name) { continue; }
let archived = in_archive_dir || read_state_archived(&entry.path());
let status = if archived { "archived" } else { "active" };
let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path());
let (bytes, files) = file_size_approx(&entry.path());
let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_millis() as u64)).flatten();
sessions.push(SessionRow {
id: name, workspace_id: workspace_id.to_string(), status: status.to_string(),
title, work_dir, created_at,
updated_at: updated_at.or_else(|| mtime.map(|m| {
let secs = if m > 1e12 as u64 { m / 1000 } else { m };
let dt = DateTime::from_timestamp(secs as i64, 0).unwrap_or_default().naive_utc();
dt.to_string()
})),
bytes, files,
});
}
sessions
}
fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option<String>) -> SessionsResult {
let root = sessions_root(home);
let archive_root = root.join(".kcd-archive");
let mut ws_ids: Vec<String> = list_workspace_dirs(home);
// Also include archive-only workspaces
if archive_root.exists() {
for entry in safe_read_dir(&archive_root) {
let name = entry.file_name().to_string_lossy().to_string();
if workspace_re().is_match(&name) && !ws_ids.contains(&name) {
ws_ids.push(name);
}
}
}
ws_ids.sort();
// Load workspaces.json
let wp = home.join("workspaces.json");
let mut ws_meta: HashMap<String, serde_json::Value> = HashMap::new();
if let Ok(content) = fs::read_to_string(&wp) {
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ws) = obj.get("workspaces").and_then(|v| v.as_object()) {
for (k, v) in ws {
ws_meta.insert(k.clone(), v.clone());
}
}
}
}
let mut workspaces = Vec::new();
let mut all_sessions = Vec::new();
for wid in &ws_ids {
if let Some(ref filter) = workspace_filter {
if wid != filter { continue; }
}
let meta = ws_meta.get(wid);
let name = meta.and_then(|m| m.get("name").and_then(|v| v.as_str()))
.map(|s| s.to_string()).unwrap_or_else(|| humanize_workspace(wid));
let root_path = meta.and_then(|m| m.get("root").and_then(|v| v.as_str()).map(|s| s.to_string()));
let active_dir = root.join(wid);
let arch_dir = archive_root.join(wid);
// Merge both sources, deduping by session id (ids are UUIDs and never
// reused; a stale legacy copy under .kcd-archive loses to the entry at
// the active root). Status comes from each row, so metadata-archived
// sessions surface in the archived filter and the archive-only counts.
let mut by_id: HashMap<String, SessionRow> = HashMap::new();
for row in list_sessions_in_dir(&active_dir, wid, false) {
by_id.insert(row.id.clone(), row);
}
for row in list_sessions_in_dir(&arch_dir, wid, true) {
by_id.entry(row.id.clone()).or_insert(row);
}
let all: Vec<SessionRow> = by_id.into_values().collect();
let active_count = all.iter().filter(|s| s.status == "active").count();
let archived_count = all.iter().filter(|s| s.status == "archived").count();
let listed: Vec<SessionRow> = match status {
"active" => all.iter().filter(|s| s.status == "active").cloned().collect(),
"archived" => all.iter().filter(|s| s.status == "archived").cloned().collect(),
_ => all,
};
let empty = active_count == 0 && archived_count == 0;
workspaces.push(WorkspaceRow {
id: wid.clone(), name, root: root_path,
created_at: None, last_opened_at: None,
active_count, archived_count, empty,
});
all_sessions.extend(listed);
}
all_sessions.sort_by(|a, b| {
let ta = a.updated_at.as_deref().or(a.created_at.as_deref()).unwrap_or("").to_string();
let tb = b.updated_at.as_deref().or(b.created_at.as_deref()).unwrap_or("").to_string();
tb.cmp(&ta)
});
SessionsResult {
home: home.to_string_lossy().to_string(),
archive_root: ".kcd-archive".into(),
workspaces,
sessions: all_sessions,
}
}
fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result<PathBuf, String> {
if !workspace_re().is_match(workspace_id) {
return Err("invalid workspace id".into());
}
if !session_re().is_match(session_id) {
return Err("invalid session id".into());
}
let root = sessions_root(home).canonicalize().unwrap_or_else(|_| sessions_root(home));
let candidate = root.join(workspace_id).join(session_id).canonicalize().unwrap_or_else(|_| root.join(workspace_id).join(session_id));
if !candidate.starts_with(&root) {
return Err("path escape blocked".into());
}
Ok(candidate)
}
fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let dir = assert_safe_path(home, workspace_id, session_id)?;
if !dir.exists() { return Err("session not found".into()); }
// Metadata archive, matching upstream kimi-code v2: write `archived: true`
// + `archivedAt` into state.json, leave the session directory in place, and
// do NOT touch session_index.jsonl — archived state is derived from
// state.json, and the CLI reconciles its own index mirror. A session
// without a readable state.json gets a clear error rather than a panic.
let sp = dir.join("state.json");
if !sp.is_file() {
return Err(format!("session {session_id} has no state.json; cannot archive"));
}
set_session_archived_meta(&dir, true)?;
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("archived".into()),
path: Some(dir.to_string_lossy().to_string()), deleted: None,
})
}
fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let dest = assert_safe_path(home, workspace_id, session_id)?;
// 1) Legacy recovery: sessions physically moved to .kcd-archive by older
// KimiSwitch versions are moved back, then their metadata flag is
// cleared per upstream restore semantics (archived:false + archivedAt
// dropped). A missing state.json is fine here — there is no flag.
let archived_dir = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
if archived_dir.exists() {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
}
fs::rename(&archived_dir, &dest).or_else(|_| {
// cross-device fallback
fs_extra::dir::copy(&archived_dir, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&archived_dir).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
// The move already restored the session; a corrupt state.json must not
// turn a successful restore into an error — the session simply lists
// as active with its flag intact.
if dest.join("state.json").is_file() {
let _ = set_session_archived_meta(&dest, false);
}
return Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
});
}
// 2) Metadata-archived session: still at the active root, just clear the flag.
if !dest.exists() { return Err("archived session not found".into()); }
set_session_archived_meta(&dest, false)?;
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
})
}
fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> {
let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
// Metadata-archived sessions still live at the active root, so a
// status_hint of "archived" with no legacy .kcd-archive copy falls through
// to the default arm and removes the in-place directory.
let target = match status_hint {
Some("archived") if archived.exists() => archived,
Some("active") if active.exists() => active,
_ => {
if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); }
}
};
fs::remove_dir_all(&target).map_err(|e| format!("delete: {}", e))?;
scrub_session_index(home, session_id);
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: None,
path: Some(target.to_string_lossy().to_string()), deleted: Some(true),
})
}
fn scrub_session_index(home: &Path, session_id: &str) {
let ip = home.join("session_index.jsonl");
if !ip.exists() { return; }
if let Ok(content) = fs::read_to_string(&ip) {
let lines: Vec<&str> = content.lines().filter(|l| {
let sid = format!("\"sessionId\":\"{}\"", session_id);
let sid2 = format!("\"sessionId\": \"{}\"", session_id);
let path_match = format!("/{}\"", session_id);
!l.contains(&sid) && !l.contains(&sid2) && !l.contains(&path_match)
}).collect();
if lines.len() < content.lines().count() {
let _ = fs::write(&ip, lines.join("\n") + "\n");
}
}
}
fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force: bool) -> Result<ActionResponse, String> {
if !workspace_re().is_match(workspace_id) {
return Err("invalid workspace id".into());
}
let root = sessions_root(home);
let active_dir = root.join(workspace_id);
let arch_dir = root.join(".kcd-archive").join(workspace_id);
if active_dir.exists() {
let active_list = list_sessions_in_dir(&active_dir, workspace_id, false);
if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
}
if arch_dir.exists() {
let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, true);
if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
}
if active_dir.exists() { fs::remove_dir_all(&active_dir).ok(); }
if arch_dir.exists() { fs::remove_dir_all(&arch_dir).ok(); }
// Update workspaces.json
let wp = home.join("workspaces.json");
if let Ok(content) = fs::read_to_string(&wp) {
if let Ok(mut obj) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ws) = obj.get_mut("workspaces").and_then(|v| v.as_object_mut()) {
ws.remove(workspace_id);
}
let deleted = obj.get_mut("deleted_workspace_ids")
.and_then(|v| v.as_array_mut());
if let Some(arr) = deleted {
if !arr.iter().any(|v| v.as_str() == Some(workspace_id)) {
arr.push(serde_json::Value::String(workspace_id.to_string()));
}
}
let _ = fs::write(&wp, serde_json::to_string_pretty(&obj).unwrap() + "\n");
}
}
Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: String::new(), status: None, path: None, deleted: Some(true),
})
}
// ---------------------------------------------------------------------------
// Preview
// ---------------------------------------------------------------------------
fn clip_text(s: &str, max: usize) -> String {
// Only walk the first `max` bytes — earlier version filtered the whole string,
// which was fine for short text but blew up when previewing big turns.
let (end, trunc) = if s.len() <= max {
(s.len(), false)
} else {
let mut e = max;
while e < s.len() && !s.is_char_boundary(e) { e += 1; }
(e, true)
};
let head = &s[..end];
let cleaned: String = head.chars().filter(|&c| c != '\0').collect();
if trunc { format!("{}…", cleaned) } else { cleaned }
}
fn extract_text_parts(content: &serde_json::Value) -> String {
if let Some(s) = content.as_str() { return s.to_string(); }
if let Some(arr) = content.as_array() {
let mut parts = Vec::new();
for p in arr {
if let Some(obj) = p.as_object() {
if let Some(t) = obj.get("text").and_then(|v| v.as_str()) {
parts.push(t.to_string());
}
}
}
return parts.join("\n");
}
String::new()
}
fn push_user_msg(msgs: &mut Vec<PreviewMessage>, role: &str, text: &str, time: u64) {
let norm: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if norm.is_empty() { return; }
if let Some(last) = msgs.last() {
if last.role == role {
let last_norm: String = last.text.split_whitespace().collect::<Vec<_>>().join(" ");
if last_norm == norm { return; }
}
}
let text = if looks_like_secret(text) { "[redacted: possible secret content]".into() } else { clip_text(text, 2500) };
msgs.push(PreviewMessage { role: role.into(), time: Some(time), text });
}
fn flush_assistant_msg(msgs: &mut Vec<PreviewMessage>, bucket: &Option<(Vec<String>, u64)>) {
if let Some((texts, _)) = bucket {
let combined = texts.join("");
if !combined.trim().is_empty() {
let text = if looks_like_secret(&combined) { "[redacted: possible secret content]".into() } else { clip_text(&combined, 2500) };
if let Some(last) = msgs.last() {
if last.role == "assistant" {
let last_norm: String = last.text.split_whitespace().collect::<Vec<_>>().join(" ");
let this_norm: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
if last_norm == this_norm { return; }
}
}
msgs.push(PreviewMessage { role: "assistant".into(), time: None, text });
}
}
}
fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<PreviewResult, String> {
let root = sessions_root(home);
let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = root.join(".kcd-archive").join(workspace_id).join(session_id);
let session_dir = match status_hint {
Some("archived") if archived.exists() => archived,
// Metadata-archived sessions stay at the active root (kimi-code v2
// writes state.json.archived, no physical move) — read them in place.
Some("archived") if active.exists() => active,
Some("active") if active.exists() => active,
_ => if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); }
};
let (title, work_dir, created_at, updated_at) = read_state_safe(&session_dir);
let api_dir = session_dir.join("agents").join("main");
let mut wire_path = api_dir.join("wire.jsonl");
if !wire_path.exists() {
wire_path = session_dir.join("wire.jsonl");
}
if !wire_path.exists() {
// try first agent wire
let agents_dir = session_dir.join("agents");
if agents_dir.exists() {
for entry in safe_read_dir(&agents_dir) {
let w = entry.path().join("wire.jsonl");
if w.exists() { wire_path = w; break; }
}
}
}
let mut messages: Vec<PreviewMessage> = Vec::new();
let mut truncated = false;
let max_msgs = 80;
let _max_chars = 2500usize;
// Hard byte cap: refuse to stream more than this from a single wire.jsonl.
// Typical heavy sessions are 5–30 MB; anything larger risks OOM in the WebView.
const MAX_WIRE_BYTES: usize = 20 * 1024 * 1024; // 20 MB
if wire_path.exists() {
let file = match fs::File::open(&wire_path) {
Ok(f) => f,
Err(e) => return Err(format!("open wire: {e}")),
};
let reader = BufReader::new(file);
let mut bytes_read = 0usize;
let mut line_count = 0usize;
let mut current_assistant: Option<(Vec<String>, u64)> = None;
let mut current_step_key: Option<String> = None;
for line_res in reader.lines() {
let line = match line_res {
Ok(l) => l,
Err(_) => break,
};
bytes_read += line.len() + 1; // +1 for the stripped newline
line_count += 1;
if bytes_read > MAX_WIRE_BYTES { truncated = true; break; }
if messages.len() >= max_msgs { truncated = true; break; }
if !line.contains("\"context.append_message\"") && !line.contains("\"turn.steer\"")
&& !line.contains("\"turn.prompt\"") && !line.contains("\"content.part\"")
&& !line.contains("\"step.end\"") { continue; }
let obj: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v, Err(_) => continue,
};
let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
if typ == "context.append_message" {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = None;
current_step_key = None;
let msg = &obj["message"];
let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("unknown");
if role == "tool" { continue; }
let raw = extract_text_parts(&msg["content"]);
if raw.trim().is_empty() { continue; }
push_user_msg(&mut messages, role, &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0));
continue;
}
if typ == "turn.steer" || typ == "turn.prompt" {
let input = &obj["input"];
let raw = if let Some(s) = input.as_str() { s.to_string() }
else if let Some(arr) = input.as_array() {
arr.iter().filter_map(|x| x.get("text").and_then(|v| v.as_str())).collect::<Vec<_>>().join("\n")
} else { String::new() };
if raw.trim().is_empty() { continue; }
push_user_msg(&mut messages, "user", &raw, obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0));
continue;
}
if typ == "context.append_loop_event" {
let ev = &obj["event"];
let ev_type = ev.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ev_type == "content.part" {
let part = &ev["part"];
if part.get("type").and_then(|v| v.as_str()) == Some("text") {
let text = part.get("text").and_then(|v| v.as_str()).unwrap_or("");
if !text.is_empty() {
let step_key = ev.get("stepUuid").and_then(|v| v.as_str())
.map(|s| s.to_string()).unwrap_or_else(|| format!("{}:{}", ev["turnId"], ev["step"]));
// flush if step changed
if current_step_key.as_deref() != Some(&step_key) {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)));
current_step_key = Some(step_key.clone());
} else if current_assistant.is_none() {
current_assistant = Some((Vec::new(), obj.get("time").and_then(|v| v.as_u64()).unwrap_or(0)));
}
current_assistant.as_mut().unwrap().0.push(text.to_string());
}
}
continue;
}
if ev_type == "step.end" {
flush_assistant_msg(&mut messages, &current_assistant);
current_assistant = None;
current_step_key = None;
}
}
}
flush_assistant_msg(&mut messages, &current_assistant);
if line_count > 8000 { truncated = true; }
if messages.len() >= max_msgs { truncated = true; }
if bytes_read > MAX_WIRE_BYTES { truncated = true; }
}
Ok(PreviewResult {
workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(),
status: if session_dir.to_string_lossy().contains(".kcd-archive") || read_state_archived(&session_dir) {
"archived".into()
} else {
"active".into()
},
title, work_dir, created_at, updated_at,
message_count: messages.len(),
truncated,
messages,
})
}
fn looks_like_secret(s: &str) -> bool {
let re = regex::Regex::new(r"(?i)api[_-]?key|sk-[a-zA-Z0-9]{12,}|BEGIN (RSA |OPENSSH )?PRIVATE KEY").unwrap();
re.is_match(s)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
#[tauri::command]
pub fn get_paths() -> PathsResult {
let home = resolve_kimi_home(None);
let valid = is_kimi_home(&home);
let mut candidates = Vec::new();
if let Ok(env_home) = std::env::var("KIMI_CODE_HOME") {
let p = PathBuf::from(&env_home);
candidates.push(PathCandidate { path: env_home, valid: is_kimi_home(&p) });
}
if let Some(hd) = dirs::home_dir() {
let p = hd.join(".kimi-code");
candidates.push(PathCandidate { path: p.to_string_lossy().to_string(), valid: is_kimi_home(&p) });
if let Ok(up) = std::env::var("USERPROFILE") {
let p2 = PathBuf::from(&up).join(".kimi-code");
if !candidates.iter().any(|c| c.path == p2.to_string_lossy()) {
candidates.push(PathCandidate { path: p2.to_string_lossy().to_string(), valid: is_kimi_home(&p2) });
}
}
}
PathsResult {
current: home.to_string_lossy().to_string(),
valid,
candidates,
env: EnvInfo {
kimi_code_home: std::env::var("KIMI_CODE_HOME").ok(),
kimi_model_name: std::env::var("KIMI_MODEL_NAME").ok(),
},
}
}
#[tauri::command]
pub fn get_prices() -> PricesResult {
PricesResult { prices: list_prices() }
}
#[tauri::command]
pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh: Option<bool>) -> SummaryResult {
let t0 = std::time::Instant::now();
let refresh = refresh.unwrap_or(false);
let home = resolve_kimi_home(home_override);
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let r = range.unwrap_or_else(|| "30d".into());
let (records, meta) = scan_usage_cached(&home, refresh);
let t1 = std::time::Instant::now();
let stats = aggregate(&records, &r, now_ms);
let all_stats = aggregate(&records, "all", now_ms);
let heatmap = build_heatmap(&records, now_ms);
let all_models: Vec<AllModelRow> = all_stats.models.into_iter().map(|m| {
AllModelRow {
model: m.model, model_display: m.model_display, requests: m.requests,
total_tokens: m.total_tokens, cost_usd: m.cost_usd, cost_estimated: m.cost_estimated,
cache_hit_rate: m.cache_hit_rate,
}
}).collect();
let all_model_count = all_models.len();
let mut range_totals = HashMap::new();
for r_k in ["today", "yesterday", "7d", "30d", "all"] {
let s = aggregate(&records, r_k, now_ms);
range_totals.insert(r_k.to_string(), s.totals);
}
let default_model = None;
let env_model = std::env::var("KIMI_MODEL_NAME").ok().map(|name| EnvModelInfo {
name, provider: std::env::var("KIMI_MODEL_PROVIDER").ok(), model: std::env::var("KIMI_MODEL_ID").ok(),
});
let t2 = std::time::Instant::now();
let scan_ms = t1.duration_since(t0).as_secs_f64() * 1000.0;
let aggregate_ms = t2.duration_since(t1).as_secs_f64() * 1000.0;
eprintln!(
"[dashboard] get_summary range={} records={} scan={:.0}ms aggregate={:.0}ms total={:.0}ms",
r, records.len(), scan_ms, aggregate_ms, t2.duration_since(t0).as_secs_f64() * 1000.0
);
SummaryResult {
home: home.to_string_lossy().to_string(),
valid: is_kimi_home(&home),
scanned_at: now_ms,
meta,
model_map: ModelMapInfo { default_model, env_model, alias_count: 0 },
range: r,
stats,
heatmap,
all_models,
all_model_count,
range_totals,
}
}
/// Aggregate a single calendar day into a `DailyRow` — lazy detail for the
/// heatmap double-click modal. Returns `None` when the date has no records.
fn build_day_detail(date: &str, records: &[UsageRecord]) -> Option<DailyRow> {
let mut t = TotalsRow::default();
let mut by_model: HashMap<String, TotalsRow> = HashMap::new();
let mut by_provider_model: HashMap<String, HashMap<String, u64>> = HashMap::new();
let mut found = false;
for r in records {
if day_key(r.time) != date {
continue;
}
found = true;
t.add(r);
by_model.entry(r.model.clone()).or_default().add(r);
let p = r.provider.clone().unwrap_or_else(|| "unknown".to_string());
*by_provider_model
.entry(p)
.or_default()
.entry(r.model.clone())
.or_insert(0) += r.input_other + r.output + r.input_cache_read + r.input_cache_creation;
}
if !found {
return None;
}
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
let ch = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
let by_model: HashMap<String, TotalsRow> = by_model
.into_iter()
.map(|(k, mut mt)| {
let mi = mt.input_other + mt.input_cache_read + mt.input_cache_creation;
mt.cache_hit_rate = if mi > 0 { mt.input_cache_read as f64 / mi as f64 } else { 0.0 };
(k, mt)
})
.collect();
Some(DailyRow {
date: date.to_string(),
requests: t.requests,
input_other: t.input_other,
output: t.output,
input_cache_read: t.input_cache_read,
input_cache_creation: t.input_cache_creation,
cost_usd: t.cost_usd,
total_tokens: t.total_tokens,
cache_hit_rate: ch,
by_model,
by_provider: by_provider_model
.iter()
.map(|(p, m)| (p.clone(), m.values().sum::<u64>()))
.collect(),
by_provider_model,
})
}
/// Lazy per-day detail for the heatmap double-click modal (fetched on demand,
/// so `get_summary` stays lean). Returns null when the date has no records.
#[tauri::command]
pub fn get_day_detail(home_override: Option<String>, date: String) -> Option<DailyRow> {
let home = resolve_kimi_home(home_override);
let (records, _) = scan_usage_cached(&home, false);
build_day_detail(&date, &records)
}
#[tauri::command]
pub fn list_sessions(home_override: Option<String>, status: Option<String>, workspace: Option<String>) -> SessionsResult {
let home = resolve_kimi_home(home_override);
list_sessions_cmd(&home, &status.unwrap_or_else(|| "active".into()), workspace)
}
#[tauri::command]
pub fn archive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
archive_session_cmd(&home, &workspace_id, &session_id)
}
#[tauri::command]
pub fn unarchive_session(home_override: Option<String>, workspace_id: String, session_id: String) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
unarchive_session_cmd(&home, &workspace_id, &session_id)
}
#[tauri::command]
pub fn delete_session(home_override: Option<String>, workspace_id: String, session_id: String, status: Option<String>) -> Result<ActionResponse, String> {
let home = resolve_kimi_home(home_override);
delete_session_cmd(&home, &workspace_id, &session_id, status.as_deref())
}
#[tauri::command]
pub fn delete_workspace(home_override: Option<String>, workspace_id: String, confirm: bool, force: Option<bool>) -> Result<ActionResponse, String> {
if !confirm { return Err("confirm_required: Pass confirm:true to delete an empty workspace".into()); }
let home = resolve_kimi_home(home_override);
delete_workspace_cmd(&home, &workspace_id, confirm, force.unwrap_or(false))
}
#[tauri::command]
pub fn get_session_preview(home_override: Option<String>, workspace_id: String, session_id: String, status: Option<String>) -> Result<PreviewResult, String> {
let home = resolve_kimi_home(home_override);
get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref())
}
#[cfg(test)]
mod pricing_tests {
use super::*;
#[test]
fn models_dev_snapshot_has_pricing() {
let idx = models_dev_cost_index();
// The compiled-in snapshot must carry real models.dev prices.
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present");
assert_eq!(kimi.input, 3.0);
assert_eq!(kimi.output, 15.0);
assert_eq!(kimi.cache_read, 0.3);
}
#[test]
fn match_price_prefers_models_dev() {
// Bare ids and prefixed ids both resolve via the suffix index.
let (id, ch, input, output, est) = match_price("glm-4.6");
assert!(!est);
assert_eq!(id, "zhipuai/glm-4.6");
assert_eq!(input, 0.6);
assert_eq!(output, 2.2);
let (id, _, _, _, est) = match_price("kimi/k3");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
}
#[test]
fn match_price_falls_back_to_legacy_table() {
// kimi-k3 resolves via models.dev (moonshotai); a made-up id hits the
// last-resort estimate.
let (id, _, _, _, est) = match_price("kimi-k3");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
let (_, _, _, _, est) = match_price("totally-unknown-model");
assert!(est, "unknown models must be flagged as estimated");
}
#[test]
fn custom_gateway_alias_bills_at_official_listing() {
// Custom-gateway aliases not catalogued in models.dev (e.g. the user's
// aggregated gateway "CodingPlan.site/deepseek-v4-flash") must resolve
// via the cross-provider bare-name lookup to a priced official entry
// instead of falling into the last-resort estimate.
let (id, _, input, output, est) = match_price("CodingPlan.site/deepseek-v4-flash");
assert!(!est, "resolved via models.dev, not estimated");
assert_eq!(id, "deepseek/deepseek-v4-flash");
assert_eq!(input, 0.14);
assert_eq!(output, 0.28);
}
#[test]
fn ambiguous_ids_prefer_official_provider() {
// glm-4.6 ships under resellers too (e.g. 302ai); the official zhipuai
// entry must win.
let (id, _, input, _, _) = match_price("glm-4.6");
assert_eq!(id, "zhipuai/glm-4.6");
assert_eq!(input, 0.6);
}
#[test]
fn context_suffix_variant_bills_at_base_model() {
// kimi-code/k3-256k only exists as a zero-priced subscription entry
// (kimi-for-coding/k3-256k); it must bill at the k3 list price.
let (id, ch, input, output, est) = match_price("kimi-code/k3-256k");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
assert_eq!(input, 3.0);
assert_eq!(output, 15.0);
assert_eq!(ch, 0.3);
// The strip helper leaves normal ids alone.
assert_eq!(strip_context_suffix("kimi-code/k3-256k").as_deref(), Some("kimi-code/k3"));
assert_eq!(strip_context_suffix("glm-4.6"), None);
assert_eq!(strip_context_suffix("kimi-k2-thinking"), None);
}
#[test]
fn subscription_plan_models_bill_at_priced_equivalent() {
// zhipuai-coding-plan/glm-5.2 is a zero-cost plan entry; the priced
// official zhipuai/glm-5.2 must win.
let (id, ch, input, output, est) = match_price("zhipuai-coding-plan/glm-5.2");
assert!(!est);
assert_eq!(id, "zhipuai/glm-5.2");
assert_eq!(input, 1.4);
assert_eq!(output, 4.4);
assert_eq!(ch, 0.26);
// Kimi For Coding has no priced counterpart at all → curated flagship.
let (id, _, input, _, est) = match_price("kimi-code/kimi-for-coding");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k3");
assert_eq!(input, 3.0);
// -highspeed variants strip back to the priced base model.
let (id, _, input, _, est) = match_price("zhipuai-coding-plan/glm-5.2-highspeed");
assert!(!est);
assert_eq!(id, "zhipuai/glm-5.2");
assert_eq!(input, 1.4);
assert_eq!(
strip_variant_suffix("glm-5.2-highspeed").as_deref(),
Some("glm-5.2")
);
assert_eq!(strip_variant_suffix("glm-5.2"), None);
}
#[test]
fn cache_write_price_preferred_when_present() {
// glm-4.6 has cache_write: 0 → cache creation billed at 0, not input.
let (_id, ch, input, _output, _est) = match_price("glm-4.6");
let cw = models_dev_lookup("glm-4.6").and_then(|(_, c)| c.cache_write);
assert_eq!(cw, Some(0.0));
// 1M input tokens + 1M cache-creation tokens, no output: cache_write 0
// → total is just the input price.
let cost = cost_for_usage(1_000_000, 0, 0, 1_000_000, "glm-4.6");
assert_eq!(cost.0, input);
assert_eq!(cost.1, false);
assert_eq!(ch, 0.11);
}
}
#[cfg(test)]
mod session_tests {
use super::*;
const WID: &str = "wd_proj_a1b2c3d4e5f6";
const SID: &str = "session_11111111-2222-3333-4444-555555555555";
fn setup_home() -> (tempfile::TempDir, PathBuf) {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
fs::create_dir_all(home.join("sessions").join(WID)).unwrap();
(td, home)
}
fn active_dir(home: &Path) -> PathBuf {
home.join("sessions").join(WID)
}
fn legacy_arch_dir(home: &Path) -> PathBuf {
home.join("sessions").join(".kcd-archive").join(WID)
}
fn write_state(session_dir: &Path, archived: bool, archived_at: Option<u64>) {
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/tmp/work",
"createdAt": 1_700_000_000_000u64,
"updatedAt": 1_700_000_000_000u64,
"archived": archived,
"title": "test session",
});
let o = obj.as_object_mut().unwrap();
match archived_at {
Some(at) => { o.insert("archivedAt".into(), serde_json::json!(at)); }
None => { o.remove("archivedAt"); }
}
fs::write(session_dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
}
fn read_state_json(session_dir: &Path) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(session_dir.join("state.json")).unwrap()).unwrap()
}
#[test]
fn metadata_archive_roundtrip() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, false, None);
// Fresh session lists as active.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
// Archive is metadata-only: dir stays put, flag + epoch-ms archivedAt written.
let ar = archive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ar.status.as_deref(), Some("archived"));
assert!(dir.exists(), "metadata archive must not move the session dir");
assert!(!legacy_arch_dir(&home).join(SID).exists(), "nothing moves into .kcd-archive");
let st = read_state_json(&dir);
assert_eq!(st["archived"], true);
let archived_at = st["archivedAt"].as_u64().expect("archivedAt as epoch ms");
assert!(archived_at > 0, "archivedAt is a Date.now()-style epoch-ms number");
// List reflects the new status.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Unarchive clears the flag in place (archived:false, archivedAt dropped).
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(dir.exists(), "unarchive must not move the dir either");
let st = read_state_json(&dir);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none(), "archivedAt key removed on restore");
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn metadata_archive_preserves_other_state_fields() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/repo",
"createdAt": 1,
"updatedAt": 2,
"archived": false,
"title": "keep me",
"custom": { "goal": "x" },
"agents": { "main": { "type": "main" } },
});
obj.as_object_mut().unwrap().insert("someFutureField".into(), serde_json::json!([1, 2, 3]));
fs::write(dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
archive_session_cmd(&home, WID, SID).unwrap();
unarchive_session_cmd(&home, WID, SID).unwrap();
let st = read_state_json(&dir);
assert_eq!(st["title"], "keep me");
assert_eq!(st["custom"]["goal"], "x");
assert_eq!(st["agents"]["main"]["type"], "main");
assert_eq!(st["someFutureField"][0], 1);
assert_eq!(st["updatedAt"], 2, "updatedAt untouched, like upstream touchUpdatedAt:false");
}
#[test]
fn cli_archived_session_in_active_root_lists_as_archived() {
// A session already carrying archived:true in state.json while still
// living at the active root (written by kimi-code CLI v2 itself) must
// surface in the archived filter / counts.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
fs::write(dir.join("wire.jsonl"), "").unwrap();
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Preview resolves it in place with status "archived".
let pv = get_session_preview_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(pv.status, "archived");
}
#[test]
fn legacy_kcd_archive_dir_still_listed_and_restored() {
let (_td, home) = setup_home();
let arch_dir = legacy_arch_dir(&home).join(SID);
fs::create_dir_all(&arch_dir).unwrap();
write_state(&arch_dir, false, None);
// Legacy physical archive still shows as archived.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
// Unarchive moves it back and clears the flag.
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(!arch_dir.exists(), "legacy .kcd-archive copy moved back");
let restored = active_dir(&home).join(SID);
assert!(restored.exists());
let st = read_state_json(&restored);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none());
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn corrupt_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("state.json"), "{ this is not json").unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("state.json"), "clear error mentioning state.json, got: {err}");
assert!(dir.exists(), "failed archive leaves the session dir in place");
assert_eq!(fs::read_to_string(dir.join("state.json")).unwrap(), "{ this is not json");
}
#[test]
fn missing_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("no state.json"), "clear error, got: {err}");
assert!(dir.exists());
}
#[test]
fn delete_session_covers_metadata_archived() {
// Status hint "archived" without a legacy .kcd-archive copy falls back
// to removing the metadata-archived in-place directory.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
let del = delete_session_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(del.deleted, Some(true));
assert!(!dir.exists(), "metadata-archived session deleted in place");
assert!(!legacy_arch_dir(&home).join(SID).exists());
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
}
}
#[cfg(test)]
mod range_and_model_totals_tests {
use super::*;
fn rec(time: u64, model: &str, resolved: &str, secondary: bool) -> UsageRecord {
UsageRecord {
time,
model: model.to_string(),
input_other: 100,
output: 50,
input_cache_read: 50,
input_cache_creation: 0,
cost_usd: 0.01,
cost_estimated: false,
price_id: String::new(),
model_resolved: resolved.to_string(),
model_display: model.to_string(),
provider: None,
from_env: false,
is_secondary: secondary,
}
}
#[test]
fn yesterday_range_is_bounded_to_yesterday() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let today_start = local_midnight_ms(now, 0);
let yesterday_start = local_midnight_ms(now, 1);
assert!(yesterday_start < today_start, "yesterday midnight precedes today's");
assert_eq!(range_end("yesterday", now), Some(today_start));
assert_eq!(range_end("today", now), None);
assert_eq!(range_end("7d", now), None);
let records = vec![
rec(yesterday_start + 1, "a/m1", "m1", false), // yesterday → in
rec(today_start + 1, "a/m1", "m1", false), // today → out
rec(yesterday_start.saturating_sub(1), "a/m1", "m1", false), // before → out
];
let stats = aggregate(&records, "yesterday", now);
assert_eq!(stats.totals.requests, 1, "only yesterday's record counts");
}
#[test]
fn models_by_name_merges_providers_and_keeps_secondary() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let records = vec![
rec(now, "openai/gpt-x", "gpt-x", false),
rec(now, "azure/gpt-x", "gpt-x", false),
rec(now, "__secondary__", "__secondary__", true),
];
let stats = aggregate(&records, "all", now);
// by_model keeps provider-qualified rows separate
assert_eq!(stats.models.len(), 3);
// by_name merges the two gpt-x rows, subagent stays its own row
assert_eq!(stats.models_by_name.len(), 2);
let gptx = stats.models_by_name.iter().find(|m| m.model == "gpt-x").expect("merged gpt-x row");
assert_eq!(gptx.requests, 2);
assert_eq!(gptx.total_tokens, 2 * 200);
let sub = stats.models_by_name.iter().find(|m| m.model == "__secondary__").expect("secondary row");
assert!(sub.is_secondary);
// sorted by total_tokens desc — every record has equal tokens here, so
// just assert the set is complete and totals add up.
let total: u64 = stats.models_by_name.iter().map(|m| m.total_tokens).sum();
assert_eq!(total, 3 * 200);
}
}