feat: models.dev 价格对标 + 基元律动供应商 + 设置文案优化(v0.6.6)
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

This commit is contained in:
KimiSwitch Dev committed 2026-08-01 13:44:13 +08:00
1 parent 2464500ef5
commit fc6789a941
18 files changed
+140996 -651

No files matched your search

+180 -2
View File
@@ -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);
}
}