feat: models.dev 价格对标 + 基元律动供应商 + 设置文案优化(v0.6.6)
This commit is contained in:
1 parent
2464500ef5
commit
fc6789a941
18 files changed
+140996
-651
No files matched your search
Generated
+1
-1
@@ -1958,7 +1958,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kimiswitch"
|
||||
version = "0.6.5"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
||||
@@ -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"
|
||||
|
||||
+180
-2
@@ -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<PriceRow> {
|
||||
]
|
||||
}
|
||||
|
||||
/// 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
|
||||
})
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
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<String>, 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "Kimi Switch",
|
||||
"version": "0.6.5",
|
||||
"version": "0.6.6",
|
||||
"identifier": "com.kimiswitch.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
Reference in new issue
Block a user