feat(models-dev): 高级设置新增 models.dev 在线同步 — 上下文/能力/单价即时生效 + 一键恢复内置;修复 public 快照构建漂移;确认适配 kimi-code 2.1.0 — bump to v0.7.21
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
This commit is contained in:
1 parent
26e783c2ba
commit
11286419b1
17 files changed
+282395
-267694
No files matched your search
Generated
+1
-1
@@ -1978,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.20"
|
||||
version = "0.7.21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "kimiswitch"
|
||||
version = "0.7.20"
|
||||
version = "0.7.21"
|
||||
description = "Kimi Switch - model config manager"
|
||||
authors = ["codingplan.site"]
|
||||
edition = "2021"
|
||||
|
||||
+59
-44
@@ -6,7 +6,8 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::sync::{Arc, Mutex, OnceLock, RwLock};
|
||||
use std::time::SystemTime;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -534,50 +535,62 @@ struct ModelsDevCost {
|
||||
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;
|
||||
/// models.dev price index over the effective snapshot (runtime-synced copy
|
||||
/// when present, else the compiled-in `src/lib/models-dev.json` generated by
|
||||
/// scripts/fetch-models-dev.mjs). Keyed by "<provider>/<model>", lowercased.
|
||||
/// Cached and rebuilt only when the synced file's mtime changes, so an online
|
||||
/// sync (models_dev::sync_models_dev) refreshes prices without a restart.
|
||||
fn models_dev_cost_index() -> Arc<HashMap<String, ModelsDevCost>> {
|
||||
static CACHE: OnceLock<RwLock<Option<(Option<SystemTime>, Arc<HashMap<String, ModelsDevCost>>)>>>=
|
||||
OnceLock::new();
|
||||
let cache = CACHE.get_or_init(|| RwLock::new(None));
|
||||
let stamp = fs::metadata(crate::models_dev::synced_path())
|
||||
.and_then(|m| m.modified())
|
||||
.ok();
|
||||
{
|
||||
let cached = cache.read().expect("price index lock");
|
||||
if let Some((cached_stamp, idx)) = cached.as_ref() {
|
||||
if *cached_stamp == stamp {
|
||||
return idx.clone();
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
let mut map = HashMap::new();
|
||||
let v = serde_json::from_str::<serde_json::Value>(&crate::models_dev::effective_snapshot());
|
||||
if let Ok(v) = v {
|
||||
if let Some(obj) = v.as_object() {
|
||||
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"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let idx = Arc::new(map);
|
||||
*cache.write().expect("price index lock") = Some((stamp, idx.clone()));
|
||||
idx
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Warm the 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();
|
||||
@@ -608,14 +621,15 @@ fn provider_rank(key: &str) -> usize {
|
||||
/// (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) {
|
||||
let index = models_dev_cost_index();
|
||||
if let Some(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()
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||
.iter()
|
||||
.filter(|(key, _)| {
|
||||
key.rsplit_once('/')
|
||||
@@ -695,7 +709,8 @@ fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
|
||||
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()
|
||||
let index = models_dev_cost_index();
|
||||
let mut matches: Vec<(&String, &ModelsDevCost)> = index
|
||||
.iter()
|
||||
.filter(|(key, cost)| {
|
||||
(cost.input > 0.0 || cost.output > 0.0)
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod dashboard;
|
||||
pub mod db;
|
||||
pub mod kimi_code_io;
|
||||
pub mod models;
|
||||
pub mod models_dev;
|
||||
pub mod oauth;
|
||||
pub mod pi_io;
|
||||
pub mod plugins;
|
||||
@@ -119,6 +120,10 @@ pub fn run() {
|
||||
commands::kimi_oauth_start,
|
||||
commands::kimi_oauth_poll,
|
||||
commands::get_experimental_env_status,
|
||||
models_dev::sync_models_dev,
|
||||
models_dev::get_models_dev_status,
|
||||
models_dev::get_models_dev_snapshot,
|
||||
models_dev::reset_models_dev,
|
||||
dashboard::get_paths,
|
||||
dashboard::get_prices,
|
||||
dashboard::get_summary,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//! models.dev reference-data sync.
|
||||
//!
|
||||
//! The app carries a compiled-in models.dev snapshot (generated by
|
||||
//! scripts/fetch-models-dev.mjs before every build). This module lets the
|
||||
//! user refresh it at runtime from https://models.dev/api.json without
|
||||
//! rebuilding: the synced copy lives at `~/.kimi-switch/models-dev.json` and
|
||||
//! takes precedence whenever it exists and parses. The dashboard price index
|
||||
//! (see dashboard::models_dev_cost_index) re-checks that file's mtime so new
|
||||
//! prices take effect without an app restart.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::db::kimi_switch_data_dir;
|
||||
|
||||
/// Compiled-in snapshot — the same file the dashboard price index used to
|
||||
/// embed directly. Always available; the runtime-synced copy (if any) wins.
|
||||
const BUILTIN_SNAPSHOT: &str = include_str!("../../src/lib/models-dev.json");
|
||||
|
||||
const SOURCE_URL: &str = "https://models.dev/api.json";
|
||||
const SYNCED_FILE: &str = "models-dev.json";
|
||||
|
||||
/// Where the runtime-synced snapshot lives. The dashboard stats this path's
|
||||
/// mtime to decide when to rebuild its price index.
|
||||
pub fn synced_path() -> PathBuf {
|
||||
kimi_switch_data_dir().join(SYNCED_FILE)
|
||||
}
|
||||
|
||||
/// Read + validate the synced copy. Anything unparsable or missing
|
||||
/// `last_updated` is treated as absent so the built-in snapshot takes over.
|
||||
fn read_synced() -> Option<String> {
|
||||
let raw = fs::read_to_string(synced_path()).ok()?;
|
||||
let v: Value = serde_json::from_str(&raw).ok()?;
|
||||
if v.get("last_updated").and_then(|x| x.as_str()).is_some() {
|
||||
Some(raw)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The snapshot the app should use right now: the synced copy when present
|
||||
/// and valid, otherwise the compiled-in one.
|
||||
pub fn effective_snapshot() -> String {
|
||||
read_synced().unwrap_or_else(|| BUILTIN_SNAPSHOT.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelsDevStatus {
|
||||
/// "synced" = runtime online copy; "builtin" = compiled at build time.
|
||||
pub source: &'static str,
|
||||
pub last_updated: String,
|
||||
pub model_count: u64,
|
||||
pub provider_count: u64,
|
||||
}
|
||||
|
||||
/// Count models / providers and read the `last_updated` stamp from a
|
||||
/// snapshot string ("<provider>/<model>" keys, plus the `last_updated` meta).
|
||||
fn status_of(source: &'static str, raw: &str) -> ModelsDevStatus {
|
||||
let v: Value = serde_json::from_str(raw).unwrap_or(Value::Null);
|
||||
let mut model_count = 0u64;
|
||||
let mut providers = HashSet::new();
|
||||
if let Some(obj) = v.as_object() {
|
||||
for (k, entry) in obj {
|
||||
if k == "last_updated" || !entry.is_object() {
|
||||
continue;
|
||||
}
|
||||
model_count += 1;
|
||||
if let Some((p, _)) = k.split_once('/') {
|
||||
providers.insert(p.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
ModelsDevStatus {
|
||||
source,
|
||||
last_updated: v
|
||||
.get("last_updated")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
model_count,
|
||||
provider_count: providers.len() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
fn status(source: &'static str) -> ModelsDevStatus {
|
||||
match source {
|
||||
"synced" => {
|
||||
let raw = read_synced().unwrap_or_default();
|
||||
status_of("synced", &raw)
|
||||
}
|
||||
_ => status_of("builtin", BUILTIN_SNAPSHOT),
|
||||
}
|
||||
}
|
||||
|
||||
/// Numbers serialize as integers when they are whole (matches the mjs
|
||||
/// script's JSON output, e.g. context 1000000 not 1000000.0).
|
||||
fn num(v: f64) -> Value {
|
||||
if v.fract() == 0.0 && v.abs() < 9e15 {
|
||||
Value::from(v as i64)
|
||||
} else {
|
||||
Value::from(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform models.dev api.json into the compact snapshot shape produced by
|
||||
/// scripts/fetch-models-dev.mjs (the canonical field-picking logic lives
|
||||
/// there; this is the runtime port). Pure — unit-tested below.
|
||||
fn build_snapshot(raw: &Value) -> Result<Value, String> {
|
||||
let providers = raw.as_object().ok_or("api.json is not a JSON object")?;
|
||||
let mut snapshot = serde_json::Map::new();
|
||||
snapshot.insert(
|
||||
"last_updated".into(),
|
||||
Value::String(Utc::now().format("%Y-%m-%d").to_string()),
|
||||
);
|
||||
|
||||
for (provider_id, provider) in providers {
|
||||
let Some(models) = provider.get("models").and_then(|m| m.as_object()) else {
|
||||
continue;
|
||||
};
|
||||
for (model_id, m) in models {
|
||||
let mut entry = serde_json::Map::new();
|
||||
if let Some(name) = m.get("name").and_then(|x| x.as_str()) {
|
||||
entry.insert("name".into(), Value::String(name.to_string()));
|
||||
}
|
||||
// Context 0 means "not applicable" (image/audio models) — treat as
|
||||
// missing so callers fall back to defaults.
|
||||
if let Some(ctx) = m
|
||||
.pointer("/limit/context")
|
||||
.and_then(|x| x.as_f64())
|
||||
.filter(|c| *c > 0.0)
|
||||
{
|
||||
entry.insert("context".into(), num(ctx));
|
||||
}
|
||||
for flag in ["reasoning", "tool_call", "structured_output"] {
|
||||
if m.get(flag).and_then(|x| x.as_bool()) == Some(true) {
|
||||
entry.insert(flag.into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
if let Some(input) = m.pointer("/modalities/input").and_then(|x| x.as_array()) {
|
||||
let has = |s: &str| input.iter().any(|v| v.as_str() == Some(s));
|
||||
if has("image") {
|
||||
entry.insert("image".into(), Value::Bool(true));
|
||||
}
|
||||
if has("video") {
|
||||
entry.insert("video".into(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
if let Some(cost) = m.get("cost").and_then(|c| c.as_object()) {
|
||||
let mut c = serde_json::Map::new();
|
||||
for k in ["input", "output", "cache_read", "cache_write"] {
|
||||
if let Some(n) = cost.get(k).and_then(|x| x.as_f64()) {
|
||||
c.insert(k.into(), num(n));
|
||||
}
|
||||
}
|
||||
if !c.is_empty() {
|
||||
entry.insert("cost".into(), Value::Object(c));
|
||||
}
|
||||
}
|
||||
snapshot.insert(format!("{provider_id}/{model_id}"), Value::Object(entry));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(snapshot))
|
||||
}
|
||||
|
||||
/// reqwest is built without the default system-proxy feature, so proxies must
|
||||
/// be attached explicitly. Mirror fetch-models-dev.mjs: env vars first, then
|
||||
/// git's http.proxy.
|
||||
fn proxy_url() -> Option<String> {
|
||||
let env = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
|
||||
.iter()
|
||||
.find_map(|k| std::env::var(k).ok().filter(|p| !p.trim().is_empty()));
|
||||
env.or_else(git_http_proxy)
|
||||
}
|
||||
|
||||
fn git_http_proxy() -> Option<String> {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["config", "--get", "http.proxy"])
|
||||
.output()
|
||||
.ok()?;
|
||||
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
(!s.is_empty()).then_some(s)
|
||||
}
|
||||
|
||||
fn http_client() -> Result<reqwest::Client, String> {
|
||||
let mut b = reqwest::Client::builder()
|
||||
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(Duration::from_secs(60));
|
||||
if let Some(p) = proxy_url() {
|
||||
b = b.proxy(reqwest::Proxy::all(&p).map_err(|e| format!("invalid proxy '{p}': {e}"))?);
|
||||
}
|
||||
b.build().map_err(|e| format!("failed to build HTTP client: {e}"))
|
||||
}
|
||||
|
||||
/// Download api.json, transform it and atomically replace the synced copy.
|
||||
/// On any failure the previous snapshot (synced or built-in) stays untouched.
|
||||
async fn sync_from_remote() -> Result<ModelsDevStatus, String> {
|
||||
let client = http_client()?;
|
||||
let raw: Value = client
|
||||
.get(SOURCE_URL)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("request failed: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("models.dev returned {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse api.json: {e}"))?;
|
||||
|
||||
let snapshot = build_snapshot(&raw)?;
|
||||
let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())? + "\n";
|
||||
|
||||
let path = synced_path();
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir).map_err(|e| format!("failed to create {}: {e}", dir.display()))?;
|
||||
}
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
fs::write(&tmp, &json).map_err(|e| format!("failed to write {}: {e}", tmp.display()))?;
|
||||
// Windows rename replaces an existing destination (MOVEFILE_REPLACE_EXISTING).
|
||||
fs::rename(&tmp, &path).map_err(|e| {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
format!("failed to replace {}: {e}", path.display())
|
||||
})?;
|
||||
Ok(status_of("synced", &json))
|
||||
}
|
||||
|
||||
/// Refresh the models.dev snapshot from the network.
|
||||
#[tauri::command]
|
||||
pub async fn sync_models_dev() -> Result<ModelsDevStatus, String> {
|
||||
sync_from_remote().await
|
||||
}
|
||||
|
||||
/// Where the current reference data comes from (for the settings card).
|
||||
#[tauri::command]
|
||||
pub fn get_models_dev_status() -> Result<ModelsDevStatus, String> {
|
||||
Ok(if read_synced().is_some() {
|
||||
status("synced")
|
||||
} else {
|
||||
status("builtin")
|
||||
})
|
||||
}
|
||||
|
||||
/// The synced snapshot's raw JSON, or None when the frontend should keep
|
||||
/// using the bundled static asset.
|
||||
#[tauri::command]
|
||||
pub fn get_models_dev_snapshot() -> Result<Option<String>, String> {
|
||||
Ok(read_synced())
|
||||
}
|
||||
|
||||
/// Drop the synced copy and fall back to the compiled-in snapshot.
|
||||
#[tauri::command]
|
||||
pub fn reset_models_dev() -> Result<ModelsDevStatus, String> {
|
||||
match fs::remove_file(synced_path()) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("failed to remove synced snapshot: {e}")),
|
||||
}
|
||||
Ok(status("builtin"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn build_snapshot_picks_reference_fields() {
|
||||
let raw = json!({
|
||||
"moonshotai": {
|
||||
"models": {
|
||||
"kimi-k3": {
|
||||
"name": "Kimi K3",
|
||||
"limit": { "context": 262144, "output": 16384 },
|
||||
"reasoning": true,
|
||||
"tool_call": true,
|
||||
"structured_output": true,
|
||||
"modalities": { "input": ["text", "image"], "output": ["text"] },
|
||||
"cost": { "input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_write": 0.0 }
|
||||
},
|
||||
"kimi-image": {
|
||||
"name": "Kimi Image",
|
||||
"limit": { "context": 0 },
|
||||
"modalities": { "input": ["text", "image", "video"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"zhipuai": {
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"name": "GLM-5.3",
|
||||
"reasoning": false,
|
||||
"cost": { "input": 0.6, "output": 2.2 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let snap = build_snapshot(&raw).unwrap();
|
||||
let obj = snap.as_object().unwrap();
|
||||
assert!(obj.contains_key("last_updated"));
|
||||
|
||||
let k3 = &obj["moonshotai/kimi-k3"];
|
||||
assert_eq!(k3["name"], "Kimi K3");
|
||||
assert_eq!(k3["context"], 262144);
|
||||
assert_eq!(k3["reasoning"], true);
|
||||
assert_eq!(k3["tool_call"], true);
|
||||
assert_eq!(k3["structured_output"], true);
|
||||
assert_eq!(k3["image"], true);
|
||||
assert!(k3.get("video").is_none());
|
||||
assert_eq!(k3["cost"]["input"], 3.0);
|
||||
assert_eq!(k3["cost"]["cache_read"], 0.3);
|
||||
assert_eq!(k3["cost"]["cache_write"], 0.0);
|
||||
|
||||
// Context 0 → dropped; video modality → flagged.
|
||||
let img = &obj["moonshotai/kimi-image"];
|
||||
assert!(img.get("context").is_none());
|
||||
assert_eq!(img["video"], true);
|
||||
assert!(img.get("cost").is_none());
|
||||
|
||||
// Explicit false is omitted (same as the mjs script).
|
||||
let glm = &obj["zhipuai/glm-5.3"];
|
||||
assert!(glm.get("reasoning").is_none());
|
||||
assert_eq!(glm["cost"]["input"], 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_counts_models_and_providers() {
|
||||
let raw = r#"{
|
||||
"last_updated": "2026-09-21",
|
||||
"moonshotai/kimi-k3": { "cost": { "input": 3.0 } },
|
||||
"moonshotai/kimi-k2": { "name": "K2" },
|
||||
"zhipuai/glm-5.3": {}
|
||||
}"#;
|
||||
let st = status_of("builtin", raw);
|
||||
assert_eq!(st.last_updated, "2026-09-21");
|
||||
assert_eq!(st.model_count, 3);
|
||||
assert_eq!(st.provider_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn num_serializes_whole_numbers_as_integers() {
|
||||
assert_eq!(num(262144.0), json!(262144));
|
||||
assert_eq!(num(0.3), json!(0.3));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.7.20",
|
||||
"version": "0.7.21",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user