diff --git a/.gitignore b/.gitignore index 54593c6..75aa956 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ Snipaste_*.png # 内部设计规范与实施计划,仅供本地查阅,不入版本库 docs/superpowers/ .release* + +# 本地 models.dev 上次成功快照备份(不入库;src/lib/models-dev.json 本身即版本化兜底) +src/lib/models-dev.last-good.json diff --git a/README.md b/README.md index 1c51201..0c5c74b 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ - **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖 - **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底 - **`raw_other` 透传未知字段**:含 `[oauth]` 段,前后往返不丢字段 -- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name` +- **models.dev 快照**:来源于 `https://models.dev/api.json`,本地 JSON 缓存 → `capabilitiesFromRef` 推导 `thinking/image_in/video_in/tool_use`,`getModelRef` 推导 `max_context_size/display_name`;仪表盘成本计算以快照 `cost`($/M tokens)为准,缺失时回退内置 Kimi 价格表(`pretauri` 钩子保证打包前自动同步) - **启动版本与配置目录**:`KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` 覆盖 Kimi Code / Pi 的目录;Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置)) ## 功能详情 diff --git a/package.json b/package.json index d768b80..e5b9200 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "kimiswitch", "private": true, - "version": "0.6.5", + "version": "0.6.6", "type": "module", "scripts": { "dev": "vite", @@ -10,6 +10,7 @@ "fetch-models-dev": "node scripts/fetch-models-dev.mjs", "preview": "vite preview", "tauri": "tauri", + "pretauri": "node scripts/fetch-models-dev.mjs || echo fetch-models-dev failed, using existing snapshot", "tauri-dev": "tauri dev", "tauri-build": "tauri build" }, diff --git a/scripts/fetch-models-dev.mjs b/scripts/fetch-models-dev.mjs index 2e41f4a..eb3772e 100644 --- a/scripts/fetch-models-dev.mjs +++ b/scripts/fetch-models-dev.mjs @@ -1,57 +1,206 @@ #!/usr/bin/env node /** - * Fetch the latest model reference data from models.dev and write a compact - * snapshot to src/lib/models-dev.json. + * Fetch the latest model reference data from models.dev and write: + * 1. src/lib/models-dev.json — compact per-model snapshot (frontend) + * 2. src/lib/models-dev-full.json — provider-grouped full list incl. pricing + * 3. src/lib/models-dev.last-good.json — local backup of the last success + * + * The data source is https://models.dev/api.json (176 providers; each model + * carries `cost` = { input, output, cache_read?, cache_write? } in $/M tokens). + * Note: https://models.dev/models.json does NOT include pricing — use api.json. + * + * Offline behaviour: when models.dev is unreachable, the script keeps using + * the local snapshot (the committed src/lib/models-dev.json) so builds always + * carry the last known prices. If that file is missing, it restores from the + * last-good backup. * * Run manually (`npm run fetch-models-dev`) or automatically before builds - * (`prebuild`). Requires network access to models.dev. + * (`prebuild` / `pretauri`). Requires network access to models.dev. */ -import { writeFile, mkdir } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const SOURCE_URL = "https://models.dev/models.json"; +// ── proxy bootstrap ───────────────────────────────────────────────────────── +// Node's fetch only honors HTTP(S)_PROXY when NODE_USE_ENV_PROXY=1 is set at +// startup. On proxied networks (e.g. git http.proxy), re-exec ourselves with +// the flag so `npm run fetch-models-dev` works without manual env. +const proxyEnv = + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy; +let proxy = proxyEnv; +if (!proxy) { + try { + const g = spawnSync("git", ["config", "--get", "http.proxy"], { + encoding: "utf8", + }); + if (g.status === 0 && g.stdout.trim()) proxy = g.stdout.trim(); + } catch { + /* no git / no proxy — direct connection */ + } +} +if (proxy && process.env.NODE_USE_ENV_PROXY !== "1") { + const r = spawnSync( + process.execPath, + [fileURLToPath(import.meta.url), ...process.argv.slice(2)], + { + stdio: "inherit", + env: { + ...process.env, + NODE_USE_ENV_PROXY: "1", + HTTPS_PROXY: proxy, + HTTP_PROXY: proxy, + }, + }, + ); + process.exit(r.status ?? 1); +} + +const SOURCE_URL = "https://models.dev/api.json"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const OUTPUT = join(ROOT, "src", "lib", "models-dev.json"); +const SNAPSHOT = join(ROOT, "src", "lib", "models-dev.json"); +const FULL = join(ROOT, "src", "lib", "models-dev-full.json"); +// Local backup of the last successful snapshot. Not committed to git (see +// .gitignore); the committed models-dev.json itself is the versioned fallback. +const BACKUP = join(ROOT, "src", "lib", "models-dev.last-good.json"); -const res = await fetch(SOURCE_URL, { - headers: { Accept: "application/json" }, - signal: AbortSignal.timeout(30_000), +/** Keep only the numeric cost fields the app bills with. */ +function pickCost(cost) { + if (!cost || typeof cost !== "object") return undefined; + const out = {}; + for (const k of ["input", "output", "cache_read", "cache_write"]) { + if (typeof cost[k] === "number") out[k] = cost[k]; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +async function main() { + const res = await fetch(SOURCE_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(60_000), + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + /** @type {Record} */ + const raw = await res.json(); + + // ── snapshot: "/" → compact fields (frontend) ──────────── + // Keeps the shape callers already consume (getModelRef / capabilitiesFromRef) + // and adds `cost`. A single `last_updated` key is appended for freshness. + const snapshot = { last_updated: new Date().toISOString().slice(0, 10) }; + + // ── full list: provider → { name, models: [...] } incl. pricing ─────────── + // Human-readable complete inventory for price benchmarking / debugging. + const full = { last_updated: snapshot.last_updated, providers: {} }; + + let modelCount = 0; + for (const [providerId, provider] of Object.entries(raw)) { + if (!provider || typeof provider !== "object") continue; + const models = provider.models; + if (!models || typeof models !== "object") continue; + + const providerEntry = { + id: providerId, + name: typeof provider.name === "string" ? provider.name : providerId, + models: [], + }; + + for (const [modelId, m] of Object.entries(models)) { + if (!m || typeof m !== "object") continue; + const key = `${providerId}/${modelId}`; + + // snapshot entry + const entry = {}; + if (typeof m.name === "string") entry.name = m.name; + // context 0 means "not applicable" (image/audio models) — treat as missing + // so callers fall back to regex defaults instead of storing 0. + if (typeof m.limit?.context === "number" && m.limit.context > 0) { + entry.context = m.limit.context; + } + if (m.reasoning === true) entry.reasoning = true; + if (m.tool_call === true) entry.tool_call = true; + if (m.structured_output === true) entry.structured_output = true; + const input = m.modalities?.input; + if (Array.isArray(input)) { + if (input.includes("image")) entry.image = true; + if (input.includes("video")) entry.video = true; + } + const cost = pickCost(m.cost); + if (cost) entry.cost = cost; + snapshot[key] = entry; + + // full-list entry + providerEntry.models.push({ + id: modelId, + name: typeof m.name === "string" ? m.name : modelId, + ...(cost ? { cost } : {}), + ...(typeof m.limit?.context === "number" && m.limit.context > 0 + ? { context: m.limit.context } + : {}), + ...(typeof m.limit?.input === "number" && m.limit.input > 0 + ? { input_limit: m.limit.input } + : {}), + ...(typeof m.limit?.output === "number" && m.limit.output > 0 + ? { output_limit: m.limit.output } + : {}), + ...(m.reasoning === true ? { reasoning: true } : {}), + ...(m.tool_call === true ? { tool_call: true } : {}), + ...(m.structured_output === true ? { structured_output: true } : {}), + ...(Array.isArray(input) && input.includes("image") + ? { image: true } + : {}), + ...(Array.isArray(input) && input.includes("video") + ? { video: true } + : {}), + }); + modelCount += 1; + } + + if (providerEntry.models.length > 0) { + full.providers[providerId] = providerEntry; + } + } + + await mkdir(dirname(SNAPSHOT), { recursive: true }); + const json = JSON.stringify(snapshot, null, 2) + "\n"; + await writeFile(SNAPSHOT, json, "utf8"); + await writeFile(FULL, JSON.stringify(full, null, 2) + "\n", "utf8"); + await writeFile(BACKUP, json, "utf8"); + + console.log(`models.dev snapshot: ${modelCount} models -> ${SNAPSHOT}`); + console.log( + `models.dev full list: ${Object.keys(full.providers).length} providers -> ${FULL}`, + ); +} + +main().catch(async (err) => { + // Offline fallback: builds must keep using the last known prices. + try { + const existing = await readFile(SNAPSHOT, "utf8"); + const stamp = JSON.parse(existing).last_updated ?? "unknown"; + console.warn( + `[fetch-models-dev] models.dev unreachable (${err.message}); ` + + `using local snapshot (last synced ${stamp})`, + ); + } catch { + try { + const backup = await readFile(BACKUP, "utf8"); + await writeFile(SNAPSHOT, backup, "utf8"); + console.warn( + "[fetch-models-dev] models.dev unreachable; restored models-dev.json from last-good backup", + ); + } catch { + console.error( + "[fetch-models-dev] models.dev unreachable and no local snapshot found; " + + "run online once to generate src/lib/models-dev.json", + ); + process.exit(1); + } + } }); -if (!res.ok) { - throw new Error(`GET ${SOURCE_URL} returned HTTP ${res.status}`); -} - -/** @type {Record} */ -const raw = await res.json(); - -// Keep only the fields the app needs; the raw file carries benchmarks, -// descriptions, etc. that would bloat the bundle. -const snapshot = {}; -for (const [key, m] of Object.entries(raw)) { - if (!m || typeof m !== "object") continue; - const entry = {}; - if (typeof m.name === "string") entry.name = m.name; - // context 0 means "not applicable" (image/audio models) — treat as missing - // so callers fall back to regex defaults instead of storing 0. - if (typeof m.limit?.context === "number" && m.limit.context > 0) { - entry.context = m.limit.context; - } - if (m.reasoning === true) entry.reasoning = true; - if (m.tool_call === true) entry.tool_call = true; - if (m.structured_output === true) entry.structured_output = true; - const input = m.modalities?.input; - if (Array.isArray(input)) { - if (input.includes("image")) entry.image = true; - if (input.includes("video")) entry.video = true; - } - snapshot[key] = entry; -} - -await mkdir(dirname(OUTPUT), { recursive: true }); -await writeFile(OUTPUT, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); - -console.log( - `models-dev snapshot: ${Object.keys(snapshot).length} models -> ${OUTPUT}`, -); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9377873..4e78874 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1958,7 +1958,7 @@ dependencies = [ [[package]] name = "kimiswitch" -version = "0.6.5" +version = "0.6.6" dependencies = [ "anyhow", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 21019d7..19e7d94 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "kimiswitch" -version = "0.6.5" +version = "0.6.6" description = "Kimi Switch - model config manager" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/dashboard.rs b/src-tauri/src/dashboard.rs index 930f67a..622c5b1 100644 --- a/src-tauri/src/dashboard.rs +++ b/src-tauri/src/dashboard.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::fs; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use walkdir::WalkDir; // --------------------------------------------------------------------------- @@ -479,11 +479,118 @@ fn list_prices() -> Vec { ] } +/// 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, +} + +/// 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 "/", lowercased. +const MODELS_DEV_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json"); + +fn models_dev_cost_index() -> &'static HashMap { + static INDEX: OnceLock> = OnceLock::new(); + INDEX.get_or_init(|| { + let mut map = HashMap::new(); + let Ok(v) = serde_json::from_str::(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 + }) +} + +/// 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 = "/" + 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)) +} + fn match_price(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) { + 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(); @@ -491,14 +598,20 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) { 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). + let cache_write_price = models_dev_lookup(model) + .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) * input_price + + (cache_create as f64 / 1e6) * cache_write_price + (output as f64 / 1e6) * output_price; (cost, est) } @@ -1417,3 +1530,68 @@ pub fn get_session_preview(home_override: Option, workspace_id: 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-k2.5").copied().expect("kimi-k2.5 present"); + assert_eq!(kimi.input, 0.6); + assert_eq!(kimi.output, 3.0); + assert_eq!(kimi.cache_read, 0.1); + } + + #[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/k2.5"); + assert!(!est); + assert_eq!(id, "moonshotai/kimi-k2.5"); + } + + #[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 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 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4f612b8..e3de3b2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "Kimi Switch", - "version": "0.6.5", + "version": "0.6.6", "identifier": "com.kimiswitch.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 7ca828e..6bfdcd0 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -375,9 +375,27 @@ export function SettingsModal({
{t("attributionPorted")}{" "} - kimicode-dashboard + {t("attributionSuffix")}
+
+ {t("modelDataSourcePrefix")}{" "} + +
{t("referenceProject")}: