Compare commits

..
3 Commits
Author SHA1 Message Date
KimiSwitch Dev ce282524db feat: Kimi 设备授权登录(应用内置 kimi login)+ 外链打开统一走 Rust 命令(v0.6.7)
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
2026-08-01 14:49:14 +08:00
KimiSwitch Dev fc6789a941 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
2026-08-01 13:44:13 +08:00
KimiSwitch Dev 2464500ef5 feat: 优化更新检查逻辑 + 修复按量余额显示(v0.6.5)
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
2026-08-01 10:51:54 +08:00
27 changed files with 141658 additions and 691 deletions

No files matched your search

+3
View File
@@ -14,3 +14,6 @@ Snipaste_*.png
# 内部设计规范与实施计划,仅供本地查阅,不入版本库 # 内部设计规范与实施计划,仅供本地查阅,不入版本库
docs/superpowers/ docs/superpowers/
.release* .release*
# 本地 models.dev 上次成功快照备份(不入库;src/lib/models-dev.json 本身即版本化兜底)
src/lib/models-dev.last-good.json
+1 -1
View File
@@ -179,7 +179,7 @@
- **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖 - **`config.toml` 是 Kimi Code 的权威来源**:所有供应商和模型始终全量保留,`default_model` 决定哪个生效(与 CLI 原生 `/provider` 行为一致)。切换时只改 `default_model`,新增的供应商会被自动提升到列表最前,不会被覆盖
- **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底 - **SQLite 只存 Kimi Switch 专有元数据**:备注、官网、每个 Agent 记住的默认模型(`settings` 表)。主题 / 语言 / 上次更新检查时间存在前端 `localStorage`(WebView2),不在 `~/.kimi-switch` 下。`config.toml` 不完整时 SQLite 兜底
- **`raw_other` 透传未知字段**:含 `[oauth]` 段,前后往返不丢字段 - **`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`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置)) - **启动版本与配置目录**:`KIMI_CODE_HOME` / `PI_CODING_AGENT_DIR` 覆盖 Kimi Code / Pi 的目录;Kimi Switch 自己的数据目录固定为 `~/.kimi-switch`,暂无环境变量覆盖(详见 [数据存储位置](#数据存储位置))
## 功能详情 ## 功能详情
+7
View File
@@ -11,6 +11,13 @@
- GitHub Actions Release workflow 已触发(三平台构建 → Release draft 自动上传 msi/dmg/AppImage/deb/rpm) - GitHub Actions Release workflow 已触发(三平台构建 → Release draft 自动上传 msi/dmg/AppImage/deb/rpm)
- 本地 0.6.4 安装包:`src-tauri/target/release/bundle/{nsis,msi}/` - 本地 0.6.4 安装包:`src-tauri/target/release/bundle/{nsis,msi}/`
**已提交并发布 v0.6.5**:
- `feat: 优化更新检查逻辑 + 修复按量余额显示(v0.6.5)`
- 检查更新优先私有库(git.codingplan.site),8s 超时自动回退 GitHub
- 设置-关于:合并移植声明为一句、新增 cc-switch 链接(openUrl + window.open 兜底)、移除仪表盘/会话页脚重复声明
- 修复按量余额不显示:UsageFooter isPlan 判定增加配额字段(total/used)条件——余额条目(DeepSeek/StepFun/SiliconFlow/Novita/Kimi 开放平台)现在正确显示 `💰 ¥x.xx`,套餐类(有 total/used)不受影响。实测 DeepSeek 官方接口返回 200 + balance_infos
- 两仓库 main + Dev_20260801 + tag v0.6.5 已同步
**历史提交**:`12dcb17` — docs: 账单查询适配调研 + OpenCode Zen 预设补推荐链接 **历史提交**:`12dcb17` — docs: 账单查询适配调研 + OpenCode Zen 预设补推荐链接
**工作区未提交改动**——用户硬约束:**实现完成不提交,等用户逐批测试确认后才提交**。 **工作区未提交改动**——用户硬约束:**实现完成不提交,等用户逐批测试确认后才提交**。
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.6.4", "version": "0.6.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,6 +10,7 @@
"fetch-models-dev": "node scripts/fetch-models-dev.mjs", "fetch-models-dev": "node scripts/fetch-models-dev.mjs",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
"pretauri": "node scripts/fetch-models-dev.mjs || echo fetch-models-dev failed, using existing snapshot",
"tauri-dev": "tauri dev", "tauri-dev": "tauri dev",
"tauri-build": "tauri build" "tauri-build": "tauri build"
}, },
+175 -26
View File
@@ -1,36 +1,121 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Fetch the latest model reference data from models.dev and write a compact * Fetch the latest model reference data from models.dev and write:
* snapshot to src/lib/models-dev.json. * 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 * 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 { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const SOURCE_URL = "https://models.dev/models.json"; // ── proxy bootstrap ─────────────────────────────────────────────────────────
const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); // Node's fetch only honors HTTP(S)_PROXY when NODE_USE_ENV_PROXY=1 is set at
const OUTPUT = join(ROOT, "src", "lib", "models-dev.json"); // 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 res = await fetch(SOURCE_URL, { const proxyEnv =
headers: { Accept: "application/json" }, process.env.HTTPS_PROXY ||
signal: AbortSignal.timeout(30_000), process.env.https_proxy ||
}); process.env.HTTP_PROXY ||
if (!res.ok) { process.env.http_proxy;
throw new Error(`GET ${SOURCE_URL} returned HTTP ${res.status}`); 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);
} }
/** @type {Record<string, any>} */ const SOURCE_URL = "https://models.dev/api.json";
const raw = await res.json(); const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
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");
// Keep only the fields the app needs; the raw file carries benchmarks, /** Keep only the numeric cost fields the app bills with. */
// descriptions, etc. that would bloat the bundle. function pickCost(cost) {
const snapshot = {}; if (!cost || typeof cost !== "object") return undefined;
for (const [key, m] of Object.entries(raw)) { 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<string, any>} */
const raw = await res.json();
// ── snapshot: "<provider>/<model>" → 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; if (!m || typeof m !== "object") continue;
const key = `${providerId}/${modelId}`;
// snapshot entry
const entry = {}; const entry = {};
if (typeof m.name === "string") entry.name = m.name; if (typeof m.name === "string") entry.name = m.name;
// context 0 means "not applicable" (image/audio models) — treat as missing // context 0 means "not applicable" (image/audio models) — treat as missing
@@ -46,12 +131,76 @@ for (const [key, m] of Object.entries(raw)) {
if (input.includes("image")) entry.image = true; if (input.includes("image")) entry.image = true;
if (input.includes("video")) entry.video = true; if (input.includes("video")) entry.video = true;
} }
const cost = pickCost(m.cost);
if (cost) entry.cost = cost;
snapshot[key] = entry; 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}`,
);
} }
await mkdir(dirname(OUTPUT), { recursive: true }); main().catch(async (err) => {
await writeFile(OUTPUT, JSON.stringify(snapshot, null, 2) + "\n", "utf8"); // Offline fallback: builds must keep using the last known prices.
try {
console.log( const existing = await readFile(SNAPSHOT, "utf8");
`models-dev snapshot: ${Object.keys(snapshot).length} models -> ${OUTPUT}`, 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);
}
}
});
+1 -1
View File
@@ -1958,7 +1958,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.6.4" version = "0.6.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.6.4" version = "0.6.7"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"
+62 -19
View File
@@ -801,25 +801,55 @@ fn version_lt(current: &str, latest: &str) -> bool {
#[tauri::command] #[tauri::command]
pub async fn check_for_update() -> Result<UpdateInfo, String> { pub async fn check_for_update() -> Result<UpdateInfo, String> {
let current = env!("CARGO_PKG_VERSION").to_string(); let current = env!("CARGO_PKG_VERSION").to_string();
// GitHub Releases API for billowliu2/KimiSwitch. Historical versions // Prefer the private Gitea repo (primary release channel, all versions
// (v0.5.x and earlier) live on git.codingplan.site and are not exposed // since v0.1.0). Fall back to GitHub Releases when it is unreachable
// here — only v0.6.0+ is published via GitHub. // (e.g. off the LAN/VPN) — v0.6.0+ is mirrored there.
let url = "https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1"; let sources = [
"https://git.codingplan.site/api/v1/repos/admin/KimiCodeSwitch/releases?limit=1",
"https://api.github.com/repos/billowliu2/KimiSwitch/releases?per_page=1",
];
// GitHub returns 403 without a User-Agent header. Bundle the app version // Both hosts require a User-Agent header (GitHub returns 403 without one).
// so the source is identifiable in any rate-limit / abuse reports. // Bundle the app version so the source is identifiable in any rate-limit
// / abuse reports. A short timeout keeps the fallback responsive when the
// private server is unreachable.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION"))) .user_agent(concat!("KimiSwitch/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(8))
.build() .build()
.map_err(|e| format!("client build failed: {e}"))?; .map_err(|e| format!("client build failed: {e}"))?;
let resp = client let mut errors: Vec<String> = Vec::new();
for url in sources {
match fetch_latest_release(&client, url).await {
Ok((latest, release_url, download_url)) => {
let update_available = version_lt(&current, &latest);
return Ok(UpdateInfo {
current,
latest,
update_available,
release_url,
download_url,
});
}
Err(e) => errors.push(format!("{url}: {e}")),
}
}
Err(format!("all update sources failed: {}", errors.join("; ")))
}
/// GET a releases API (Gitea or GitHub, same array shape) and extract the
/// latest tag, release page URL and the installer asset for the current OS.
async fn fetch_latest_release(
client: &reqwest::Client,
url: &str,
) -> Result<(String, String, Option<String>), String> {
let releases: serde_json::Value = client
.get(url) .get(url)
.send() .send()
.await .await
.map_err(|e| format!("request failed: {e}"))?; .map_err(|e| format!("request failed: {e}"))?
let releases: serde_json::Value = resp
.json() .json()
.await .await
.map_err(|e| format!("parse failed: {e}"))?; .map_err(|e| format!("parse failed: {e}"))?;
@@ -846,15 +876,7 @@ pub async fn check_for_update() -> Result<UpdateInfo, String> {
.and_then(|a| a.as_array()) .and_then(|a| a.as_array())
.and_then(|a| pick_asset_for_current_os(a)); .and_then(|a| pick_asset_for_current_os(a));
let update_available = version_lt(&current, &latest); Ok((latest, release_url, download_url))
Ok(UpdateInfo {
current,
latest,
update_available,
release_url,
download_url,
})
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1035,3 +1057,24 @@ pub fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), Strin
.open_url(url, None::<&str>) .open_url(url, None::<&str>)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
// ---------------------------------------------------------------------------
// Kimi OAuth device-code sign-in (in-app replacement for `kimi login`)
// ---------------------------------------------------------------------------
/// Step 1: ask auth.kimi.com for a user_code + verification URI.
#[tauri::command]
pub async fn kimi_oauth_start() -> Result<crate::oauth::DeviceAuthorization, String> {
crate::oauth::start_device_authorization().await
}
/// Step 2: poll the token endpoint until the user approves. On success the
/// tokens are written to the CLI credentials file (`kimi login` no longer
/// needed). Errors are transient (network); deterministic outcomes
/// (pending / success / expired / denied / timeout) come back in the enum.
#[tauri::command]
pub async fn kimi_oauth_poll(
device_code: String,
interval: i64,
) -> Result<crate::oauth::DevicePollStatus, String> {
crate::oauth::poll_device_token(&device_code, interval).await
}
+180 -2
View File
@@ -5,7 +5,7 @@ use std::collections::HashMap;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Mutex; use std::sync::{Mutex, OnceLock};
use walkdir::WalkDir; 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) { fn match_price(model_name: &str) -> (String, f64, f64, f64, bool) {
let bare = match model_name.rsplit_once('/') { let bare = match model_name.rsplit_once('/') {
Some((_, b)) => b, Some((_, b)) => b,
None => model_name, 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(); let bare_l = bare.to_ascii_lowercase();
for p in &list_prices() { for p in &list_prices() {
let id_l = p.id.to_ascii_lowercase(); 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); 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) ("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) { 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); 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 let cost = (input_other as f64 / 1e6) * input_price
+ (cache_read as f64 / 1e6) * cache_hit + (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; + (output as f64 / 1e6) * output_price;
(cost, est) (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); let home = resolve_kimi_home(home_override);
get_session_preview_cmd(&home, &workspace_id, &session_id, status.as_deref()) 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);
}
}
+2
View File
@@ -106,6 +106,8 @@ pub fn run() {
commands::download_update, commands::download_update,
commands::open_installer, commands::open_installer,
commands::open_external_url, commands::open_external_url,
commands::kimi_oauth_start,
commands::kimi_oauth_poll,
dashboard::get_paths, dashboard::get_paths,
dashboard::get_prices, dashboard::get_prices,
dashboard::get_summary, dashboard::get_summary,
+240 -1
View File
@@ -21,8 +21,18 @@
//! - right before writing back, the file is re-read once more — if the CLI //! - right before writing back, the file is re-read once more — if the CLI
//! refreshed meanwhile its newer tokens win (refresh tokens rotate on use, //! refreshed meanwhile its newer tokens win (refresh tokens rotate on use,
//! so clobbering the CLI's write would break its next refresh). //! so clobbering the CLI's write would break its next refresh).
//!
//! v3 adds the in-app sign-in flow: a Device Code Flow (RFC 8628) identical
//! to the official `kimi` CLI, so users can authorize Kimi from the app
//! instead of running `kimi login` in a terminal. Flow:
//! 1. `start_device_authorization()` → POST /api/oauth/device_authorization,
//! returns user_code + device_code + verification_uri;
//! 2. user opens the verification URI in a browser and approves;
//! 3. `poll_device_token()` polls POST /api/oauth/token with the device_code
//! grant until the tokens arrive, then writes them to the same
//! `~/.kimi-code/credentials/kimi-code.json` file the CLI uses.
use serde::Deserialize; use serde::{Deserialize, Serialize};
use crate::kimi_code_io::kimi_code_config_dir; use crate::kimi_code_io::kimi_code_config_dir;
@@ -32,11 +42,17 @@ const EXPIRY_LEEWAY_SECS: i64 = 30;
/// OAuth token endpoint (confirmed in the official kimi.exe binary). /// OAuth token endpoint (confirmed in the official kimi.exe binary).
const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token"; const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token";
/// Device authorization endpoint (RFC 8628).
const DEVICE_AUTHORIZATION_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/device_authorization";
/// Public OAuth client id used by the official CLI (from kimi.exe). /// Public OAuth client id used by the official CLI (from kimi.exe).
const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
/// Refresh request timeout; refresh is rare, a bit more headroom than the 8s /// Refresh request timeout; refresh is rare, a bit more headroom than the 8s
/// query default is fine. /// query default is fine.
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Device grant type (RFC 8628).
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
/// Total polling budget for the device flow, matching the official CLI.
const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct OAuthCredentials { pub struct OAuthCredentials {
@@ -227,6 +243,200 @@ async fn refresh_credentials(creds: &OAuthCredentials) -> Result<String, String>
Ok(token.access_token) Ok(token.access_token)
} }
// ---------------------------------------------------------------------------
// Device Code Flow (RFC 8628) — in-app sign-in, mirrors the official CLI
// ---------------------------------------------------------------------------
/// Response of POST /api/oauth/device_authorization.
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeviceAuthorization {
pub user_code: String,
pub device_code: String,
pub verification_uri: Option<String>,
pub verification_uri_complete: Option<String>,
pub expires_in: Option<i64>,
pub interval: Option<i64>,
}
/// Poll outcome, serialized back to the frontend so it can drive the dialog.
#[derive(Debug, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum DevicePollStatus {
/// Keep polling with the returned interval (seconds).
Pending { interval: i64 },
/// Server asked to slow down; keep polling with interval + 5.
SlowDown { interval: i64 },
/// Tokens obtained and written to the credentials file.
Success,
/// Device code expired; the user must start over.
Expired,
/// User denied the authorization.
AccessDenied,
/// Polling budget exhausted.
Timeout,
}
/// `~/.kimi-code/device_id` — reused by the CLI so the app looks like the
/// same device. Created on first use.
fn device_id_path() -> std::path::PathBuf {
kimi_code_config_dir().join("device_id")
}
fn load_or_create_device_id() -> Option<String> {
let path = device_id_path();
if let Ok(content) = std::fs::read_to_string(&path) {
let id = content.trim().to_string();
if !id.is_empty() {
return Some(id);
}
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let id = format!("kimi-switch-{nanos:x}-{}", std::process::id());
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
// Best-effort write; a missing file just means the header is omitted.
let _ = std::fs::write(&path, &id);
Some(id)
}
/// Device identity headers matching the official CLI (`X-Msh-*`).
fn identity_headers() -> Vec<(&'static str, String)> {
let hostname = std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "kimi-switch".to_string());
let os_version = std::env::var("OS").unwrap_or_else(|_| std::env::consts::OS.to_string());
let mut headers = vec![
("X-Msh-Platform", "kimi_code_cli".to_string()),
("X-Msh-Version", env!("CARGO_PKG_VERSION").to_string()),
("X-Msh-Device-Name", hostname.clone()),
(
"X-Msh-Device-Model",
format!("{} {}", std::env::consts::OS, hostname),
),
("X-Msh-Os-Version", os_version),
];
if let Some(device_id) = load_or_create_device_id() {
headers.push(("X-Msh-Device-Id", device_id));
}
headers
}
/// Step 1 of the device flow: ask the server for a user_code + device_code.
/// No user interaction required here; the frontend shows the code + URL.
pub async fn start_device_authorization() -> Result<DeviceAuthorization, String> {
let client = reqwest::Client::builder()
.timeout(REFRESH_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let mut req = client
.post(DEVICE_AUTHORIZATION_ENDPOINT)
.form(&[("client_id", CLIENT_ID)]);
for (name, value) in identity_headers() {
req = req.header(name, value);
}
let resp = req
.send()
.await
.map_err(|e| format!("Device authorization request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let body: String = body.chars().take(200).collect();
return Err(format!("Device authorization failed (HTTP {status}): {body}"));
}
resp.json::<DeviceAuthorization>()
.await
.map_err(|e| format!("Failed to parse device authorization response: {e}"))
}
/// Step 2 of the device flow: poll the token endpoint until the user
/// approves (or the flow fails). On success the tokens are merged into the
/// CLI credentials file, making `kimi login` unnecessary.
pub async fn poll_device_token(
device_code: &str,
initial_interval: i64,
) -> Result<DevicePollStatus, String> {
let client = reqwest::Client::builder()
.timeout(REFRESH_TIMEOUT)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
let deadline = std::time::Instant::now() + POLL_TIMEOUT;
let mut interval = initial_interval.max(1);
loop {
let mut req = client
.post(TOKEN_ENDPOINT)
.form(&[
("grant_type", DEVICE_GRANT_TYPE),
("client_id", CLIENT_ID),
("device_code", device_code),
]);
for (name, value) in identity_headers() {
req = req.header(name, value);
}
let resp = match req.send().await {
Ok(r) => r,
Err(e) => return Err(format!("Token polling request failed: {e}")),
};
if resp.status().is_success() {
let token: TokenResponse = match resp.json().await {
Ok(t) => t,
Err(e) => return Err(format!("Failed to parse token response: {e}")),
};
persist_device_token(&token)?;
return Ok(DevicePollStatus::Success);
}
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let error: Option<String> = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from));
match error.as_deref() {
Some("authorization_pending") => {}
Some("slow_down") => interval += 5,
Some("expired_token") => return Ok(DevicePollStatus::Expired),
Some("access_denied") => return Ok(DevicePollStatus::AccessDenied),
Some(other) => {
let body: String = body.chars().take(200).collect();
return Err(format!("Token polling error ({other}): {body}"));
}
None => {
let body: String = body.chars().take(200).collect();
return Err(format!("Token polling failed (HTTP {status}): {body}"));
}
}
if std::time::Instant::now() + std::time::Duration::from_secs(interval as u64) >= deadline {
return Ok(DevicePollStatus::Timeout);
}
tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await;
}
}
/// Write a freshly obtained token set into the CLI credentials file,
/// preserving unrelated fields and using the same snake_case shape.
fn persist_device_token(token: &TokenResponse) -> Result<(), String> {
let path = credentials_path();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let current = std::fs::read_to_string(&path).unwrap_or_default();
let merged = merge_token_response(&current, token);
std::fs::write(&path, merged)
.map_err(|e| format!("Failed to write Kimi credentials: {e}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -330,4 +540,33 @@ mod tests {
let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
assert_eq!(v["refresh_token"], "old-refresh"); assert_eq!(v["refresh_token"], "old-refresh");
} }
#[test]
fn device_authorization_serializes_snake_case() {
let auth = DeviceAuthorization {
user_code: "ABC-DEF".to_string(),
device_code: "dev-1".to_string(),
verification_uri: Some("https://auth.kimi.com/device".to_string()),
verification_uri_complete: None,
expires_in: Some(1800),
interval: Some(5),
};
let v = serde_json::to_value(&auth).unwrap();
assert_eq!(v["user_code"], "ABC-DEF");
assert_eq!(v["device_code"], "dev-1");
assert_eq!(v["verification_uri_complete"], serde_json::Value::Null);
assert_eq!(v["interval"], 5);
assert!(v.get("userCode").is_none(), "must be snake_case for the frontend");
}
#[test]
fn device_poll_status_serializes_snake_case() {
// Internally tagged: {"status":"pending","interval":5} — easy for the
// frontend to switch on.
let pending = serde_json::to_value(DevicePollStatus::Pending { interval: 5 }).unwrap();
assert_eq!(pending["status"], "pending");
assert_eq!(pending["interval"], 5);
let success = serde_json::to_value(DevicePollStatus::Success).unwrap();
assert_eq!(success["status"], "success");
}
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.6.4", "version": "0.6.7",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+248
View File
@@ -0,0 +1,248 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n";
interface DeviceAuthorization {
user_code: string;
device_code: string;
verification_uri: string | null;
verification_uri_complete: string | null;
expires_in: number | null;
interval: number | null;
}
type PollStatus =
| { status: "pending"; interval: number }
| { status: "slow_down"; interval: number }
| { status: "success" }
| { status: "expired" }
| { status: "access_denied" }
| { status: "timeout" };
interface KimiOAuthDialogProps {
open: boolean;
onClose: () => void;
}
/** Kimi device-code sign-in dialog (in-app `kimi login`). */
export function KimiOAuthDialog({ open, onClose }: KimiOAuthDialogProps) {
const { t } = useTranslation();
const [auth, setAuth] = useState<DeviceAuthorization | null>(null);
const [polling, setPolling] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const activeRef = useRef(false);
const intervalRef = useRef(5);
// Start a fresh device authorization when the dialog opens.
useEffect(() => {
if (!open) return;
activeRef.current = true;
setAuth(null);
setPolling(false);
setDone(false);
setError(null);
setCopied(false);
intervalRef.current = 5;
invoke<DeviceAuthorization>("kimi_oauth_start")
.then((a) => {
if (!activeRef.current) return;
setAuth(a);
if (a.interval && a.interval > 0) intervalRef.current = a.interval;
setPolling(true);
})
.catch((e) => {
if (!activeRef.current) return;
setError(e instanceof Error ? e.message : String(e));
});
return () => {
activeRef.current = false;
};
}, [open]);
// Poll the token endpoint while the user authorizes in the browser.
useEffect(() => {
if (!open || !polling || !auth) return;
let cancelled = false;
const tick = async () => {
if (cancelled || !activeRef.current) return;
try {
const res = await invoke<PollStatus>("kimi_oauth_poll", {
deviceCode: auth.device_code,
interval: intervalRef.current,
});
if (cancelled) return;
switch (res.status) {
case "pending":
case "slow_down":
if (res.interval > 0) intervalRef.current = res.interval;
break;
case "success":
setPolling(false);
setDone(true);
setTimeout(() => {
if (activeRef.current) onClose();
}, 1600);
return;
case "expired":
case "access_denied":
case "timeout":
setPolling(false);
setError(
res.status === "expired"
? t("kimiOAuthExpired")
: res.status === "access_denied"
? t("kimiOAuthDenied")
: t("kimiOAuthTimeout")
);
return;
}
} catch {
if (!cancelled) setError(t("kimiOAuthNetworkError"));
}
// Schedule the next poll (also on network errors — keep retrying).
setTimeout(() => {
if (!cancelled && activeRef.current && polling) void tick();
}, intervalRef.current * 1000);
};
void tick();
return () => {
cancelled = true;
};
// `polling` and `auth` gate the loop; restart polling explicitly via
// startPolling when retrying.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, polling, auth]);
const restart = () => {
setAuth(null);
setPolling(false);
setDone(false);
setError(null);
intervalRef.current = 5;
invoke<DeviceAuthorization>("kimi_oauth_start")
.then((a) => {
if (!activeRef.current) return;
setAuth(a);
if (a.interval && a.interval > 0) intervalRef.current = a.interval;
setPolling(true);
})
.catch((e) => {
if (!activeRef.current) return;
setError(e instanceof Error ? e.message : String(e));
});
};
const copyCode = () => {
if (!auth) return;
navigator.clipboard.writeText(auth.user_code).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
// Open the authorization page via the Rust-side opener command (bypasses the
// JS plugin scope, same reliable path the provider website links use);
// fall back to a new tab when even that fails.
const openAuthPage = () => {
if (!auth) return;
const url = auth.verification_uri_complete || auth.verification_uri;
if (!url) return;
invoke("open_external_url", { url }).catch(() => {
const w = window.open(url, "_blank");
if (!w) console.error(`failed to open ${url}`);
});
};
if (!open) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-panel shadow-xl">
<div className="flex items-center justify-between border-b border-border px-5 py-4">
<h3 className="font-medium text-content-primary">{t("kimiOAuthLogin")}</h3>
<button
type="button"
onClick={onClose}
className="text-content-muted hover:text-content-primary text-lg leading-none"
aria-label={t("kimiOAuthCancel")}
>
×
</button>
</div>
<div className="space-y-4 p-5">
{done && (
<div className="rounded-lg bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 text-sm px-3 py-2">
✓ {t("kimiOAuthSuccess")}
</div>
)}
{error && !done && (
<div className="rounded-lg bg-red-100 dark:bg-red-900/30 text-red-500 dark:text-red-400 text-sm px-3 py-2">
{error}
</div>
)}
{auth && !done && (
<>
{/* user code + copy */}
<div className="flex items-center justify-center gap-3">
<code className="text-2xl font-bold tracking-widest tabular-nums text-content-primary select-all">
{auth.user_code}
</code>
<button
type="button"
onClick={copyCode}
className="px-2.5 py-1 text-xs rounded border border-border hover:bg-hover-2 text-content-muted transition-colors"
>
{copied ? t("kimiOAuthCodeCopied") : t("kimiOAuthCopyCode")}
</button>
</div>
<button
type="button"
onClick={openAuthPage}
className="w-full px-3 py-2 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
>
{t("kimiOAuthOpenPage")}
</button>
<p className="text-sm text-content-muted text-center">
{t("kimiOAuthWaiting")}
</p>
</>
)}
{!auth && !done && (
<div className="h-3.5 w-2/3 rounded bg-hover-2 animate-pulse mx-auto" />
)}
<div className="flex items-center justify-end gap-2 pt-1">
{error && !done && (
<button
type="button"
onClick={restart}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiOAuthRetry")}
</button>
)}
<button
type="button"
onClick={onClose}
disabled={polling}
className="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-500 transition-colors disabled:opacity-50"
>
{t("kimiOAuthCancel")}
</button>
</div>
</div>
</div>
</div>,
document.body
);
}
+18
View File
@@ -8,6 +8,7 @@ import { getDefaultMaxContextSize } from "../lib/model-defaults";
import { capabilitiesFromRef, getModelRef } from "../lib/models-dev"; import { capabilitiesFromRef, getModelRef } from "../lib/models-dev";
import { getIconMetadata } from "../icons/extracted/metadata"; import { getIconMetadata } from "../icons/extracted/metadata";
import { AgentSettingsPanel } from "./AgentSettingsPanel"; import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
@@ -117,6 +118,7 @@ export function ProviderEdit({
const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic"); const [activeTab, setActiveTab] = useState<"basic" | "models" | "json">("basic");
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [showIconPicker, setShowIconPicker] = useState(false); const [showIconPicker, setShowIconPicker] = useState(false);
const [kimiOAuthOpen, setKimiOAuthOpen] = useState(false);
// Preset this provider was created from (if any) — provides the // Preset this provider was created from (if any) — provides the
// "Get API Key" / referral links shown under the key input. // "Get API Key" / referral links shown under the key input.
@@ -270,6 +272,17 @@ export function ProviderEdit({
{t("managedProvider")} {t("managedProvider")}
</label> </label>
</div> </div>
{provider.managed && (
<div className="col-span-1 md:col-span-2">
<button
type="button"
onClick={() => setKimiOAuthOpen(true)}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors"
>
{t("kimiOAuthLogin")}
</button>
</div>
)}
</div> </div>
</section> </section>
@@ -439,6 +452,11 @@ export function ProviderEdit({
</div>, </div>,
document.body document.body
)} )}
<KimiOAuthDialog
open={kimiOAuthOpen}
onClose={() => setKimiOAuthOpen(false)}
/>
</div> </div>
); );
} }
+83 -10
View File
@@ -2,7 +2,6 @@ import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useTranslation, type Language } from "../i18n"; import { useTranslation, type Language } from "../i18n";
import { useTheme, type Theme } from "../hooks/useTheme"; import { useTheme, type Theme } from "../hooks/useTheme";
import type { UpdateInfo } from "../hooks/useUpdateCheck"; import type { UpdateInfo } from "../hooks/useUpdateCheck";
@@ -110,6 +109,15 @@ export function SettingsModal({
invoke("open_installer", { path: downloadedPath }).catch(() => {}); invoke("open_installer", { path: downloadedPath }).catch(() => {});
}; };
// Open an external link via the Rust-side opener command (bypasses the JS
// plugin scope); fall back to a new tab when even that fails.
const openExternal = (url: string) => {
invoke("open_external_url", { url }).catch(() => {
const w = window.open(url, "_blank");
if (!w) console.error(`failed to open ${url}`);
});
};
if (!open) return null; if (!open) return null;
const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [ const themeOptions: { value: Theme; icon: ReactNode; label: string }[] = [
@@ -253,26 +261,66 @@ export function SettingsModal({
type="button" type="button"
onClick={onCheckUpdate} onClick={onCheckUpdate}
disabled={checking} disabled={checking}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors" className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 disabled:opacity-50 transition-colors flex items-center gap-1.5"
> >
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{checking ? t("checking") : t("checkUpdate")} {checking ? t("checking") : t("checkUpdate")}
</button> </button>
{updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && ( {updateInfo?.updateAvailable && downloadProgress === null && !downloadedPath && (
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-500 transition-colors flex items-center gap-1.5"
> >
{t("downloadUpdate")} ↓ <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
{t("downloadUpdate")}
</button> </button>
)} )}
{downloadedPath && ( {downloadedPath && (
<button <button
type="button" type="button"
onClick={handleInstall} onClick={handleInstall}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors" className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 transition-colors flex items-center gap-1.5"
> >
{t("install")} ⏎ <svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polygon points="6 3 20 12 6 21 6 3" />
</svg>
{t("install")}
</button> </button>
)} )}
</div> </div>
@@ -323,18 +371,43 @@ export function SettingsModal({
<div className="font-medium text-content-primary">Kimi Switch</div> <div className="font-medium text-content-primary">Kimi Switch</div>
<div>{t("appDescription")}</div> <div>{t("appDescription")}</div>
<div className="text-xs text-content-muted">v{appVersion} · MIT License</div> <div className="text-xs text-content-muted">v{appVersion} · MIT License</div>
<div className="pt-2"> <div className="pt-2 space-y-1">
<div>
{t("attributionPorted")}{" "}
<button
type="button"
onClick={() =>
openExternal("https://github.com/JochenYang/kimicode-dashboard")
}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer font-semibold"
>
kimicode-dashboard
</button>
<span className="text-content-muted"> {t("attributionSuffix")}</span>
</div>
<div className="pt-1">
{t("modelDataSourcePrefix")}{" "}
<button
type="button"
onClick={() => openExternal("https://models.dev/")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
models.dev
</button>
</div>
<div className="pt-1">
<span className="text-content-muted">{t("referenceProject")}: </span> <span className="text-content-muted">{t("referenceProject")}: </span>
<button <button
type="button" type="button"
onClick={() => onClick={() =>
openUrl("https://github.com/JochenYang/kimicode-dashboard") openExternal("https://github.com/farion1231/cc-switch")
} }
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer" className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
> >
kimicode-dashboard cc-switch
</button> </button>
<span className="text-content-muted"> (MIT, © JochenYang)</span> <span className="text-content-muted"> (MIT, © Jason Young)</span>
</div>
</div> </div>
</div> </div>
</section> </section>
+13 -2
View File
@@ -245,7 +245,13 @@ export function UsageFooter({
const compactSummary = (): { icon: string; text: string; color: string } | null => { const compactSummary = (): { icon: string; text: string; color: string } | null => {
const d = data[0]; const d = data[0];
if (!d) return null; if (!d) return null;
const isPlan = !!d.planName && d.planName !== "NewAPI"; // A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance
// entries carry only remaining + unit — their plan_name is a currency /
// brand label and must render as an amount, not a percentage.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
if (d.isValid === false) { if (d.isValid === false) {
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" }; return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
} }
@@ -360,7 +366,12 @@ export function UsageFooter({
}`} }`}
> >
{data.map((d, i) => { {data.map((d, i) => {
const isPlan = !!d.planName; // Same plan-vs-balance rule as compactSummary: balance rows have no
// quota fields (total/used), so they render as an amount.
const isPlan =
!!d.planName &&
d.planName !== "NewAPI" &&
(d.total != null || d.used != null);
const pct = isPlan ? percentOf(d) : null; const pct = isPlan ? percentOf(d) : null;
const color = const color =
pct == null pct == null
+1 -13
View File
@@ -1,5 +1,4 @@
import { useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useDashboard, type DashboardRange } from "../../hooks/useDashboard"; import { useDashboard, type DashboardRange } from "../../hooks/useDashboard";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format"; import { fmtInt, fmtPct, fmtTime, fmtTokens, fmtUsd } from "../../lib/dashboard-format";
@@ -420,23 +419,12 @@ export function DashboardPage() {
)} )}
</Card> </Card>
{/* Footer — meta + attribution at the end of the content flow */} {/* Footer — meta at the end of the content flow */}
<div className="text-xs text-content-muted pb-2"> <div className="text-xs text-content-muted pb-2">
<div> <div>
{t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "} {t("footerScanned", { n: data.meta.filesScanned, m: fmtInt(data.meta.recordCount) })} ·{" "}
{data.meta.home} {data.meta.home}
</div> </div>
<div className="mt-0.5">
{t("dashboardAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div>
</div> </div>
</div> </div>
); );
-11
View File
@@ -1,5 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useSessions } from "../../hooks/useSessions"; import { useSessions } from "../../hooks/useSessions";
import { fmtInt, fmtTime } from "../../lib/dashboard-format"; import { fmtInt, fmtTime } from "../../lib/dashboard-format";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
@@ -547,16 +546,6 @@ export function SessionsPage() {
{/* Attribution */} {/* Attribution */}
<div className="text-xs text-content-muted pb-2"> <div className="text-xs text-content-muted pb-2">
{data?.home && <>{data.home}</>} {data?.home && <>{data.home}</>}
<br />
{t("sessionsAttribution")}{" "}
<button
type="button"
onClick={() => openUrl("https://github.com/JochenYang/kimicode-dashboard")}
className="text-blue-500 hover:text-blue-400 underline bg-transparent p-0 border-0 cursor-pointer"
>
kimicode-dashboard
</button>{" "}
{t("attributionSuffix")}
</div> </div>
{/* Confirm dialog */} {/* Confirm dialog */}
+29
View File
@@ -367,6 +367,35 @@ export const providerPresets: ProviderPreset[] = [
usageKinds: ["balance:openrouter"], usageKinds: ["balance:openrouter"],
billingMode: "pay_as_you_go", billingMode: "pay_as_you_go",
}, },
{
id: "tokenrhythm",
name: "TokenRhythm",
nameKey: "presetNameTokenRhythm",
websiteUrl: "https://tokenrhythm.studio",
// Referral link for creating an API key on the console.
apiKeyUrl: "https://tokenrhythm.studio/i/rf_tr_v0QXuBTzyl3-bJOS2uFFPg6x",
category: "aggregator",
providerType: "openai",
baseUrl: "https://tokenrhythm.studio/v1",
icon: "tokenrhythm",
models: [
{ model: "deepseek-v4-flash" },
{ model: "deepseek-v4-pro" },
{ model: "glm-5" },
{ model: "glm-5.1" },
{ model: "glm-5.2" },
{ model: "kimi-k2.5" },
{ model: "kimi-k2.6" },
{ model: "kimi-k2.7-code" },
{ model: "minimax-m2.5" },
{ model: "minimax-m2.7" },
{ model: "mimo-v2.5-pro" },
{ model: "qwen3.7-max" },
],
// No public billing endpoint: the API key only authorizes the OpenAI /
// Anthropic / Embeddings proxy. Console usage requires a login session.
billingMode: "pay_as_you_go",
},
{ {
id: "openai", id: "openai",
name: "OpenAI", name: "OpenAI",
+15 -2
View File
@@ -56,6 +56,18 @@ export const enTranslations: Record<TranslationKey, string> = {
notePlaceholder: "e.g. company account", notePlaceholder: "e.g. company account",
officialUrl: "Official URL", officialUrl: "Official URL",
managedProvider: "Managed provider (no credentials needed)", managedProvider: "Managed provider (no credentials needed)",
kimiOAuthLogin: "Sign in with Kimi",
kimiOAuthOpenPage: "Open authorization page",
kimiOAuthCopyCode: "Copy code",
kimiOAuthCodeCopied: "Copied",
kimiOAuthWaiting: "Enter the code on the page that opens, waiting for authorization…",
kimiOAuthSuccess: "Authorized — credentials written for Kimi login",
kimiOAuthExpired: "Code expired, please start over",
kimiOAuthDenied: "Authorization denied",
kimiOAuthTimeout: "Authorization timed out, please start over",
kimiOAuthRetry: "Restart",
kimiOAuthCancel: "Cancel",
kimiOAuthNetworkError: "Network error, retrying…",
apiSettings: "API Settings", apiSettings: "API Settings",
apiFormat: "API Format", apiFormat: "API Format",
authField: "Auth Field", authField: "Auth Field",
@@ -318,9 +330,9 @@ export const enTranslations: Record<TranslationKey, string> = {
// Dashboard — footer / attribution // Dashboard — footer / attribution
footerScanned: "Scanned {n} files · {m} records", footerScanned: "Scanned {n} files · {m} records",
dashboardAttribution: "Dashboard ported from", attributionPorted: "Dashboard & session manager ported from",
sessionsAttribution: "Session manager ported from",
attributionSuffix: "(MIT, © JochenYang)", attributionSuffix: "(MIT, © JochenYang)",
modelDataSourcePrefix: "Model & pricing data referenced from",
// Sessions — preview // Sessions — preview
previewCollapseChars: "Collapse · {n} chars", previewCollapseChars: "Collapse · {n} chars",
@@ -354,6 +366,7 @@ export const enTranslations: Record<TranslationKey, string> = {
presetNameSiliconflow: "SiliconFlow", presetNameSiliconflow: "SiliconFlow",
presetNameNovita: "Novita AI", presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter", presetNameOpenrouter: "OpenRouter",
presetNameTokenRhythm: "TokenRhythm",
presetNameOpenai: "OpenAI", presetNameOpenai: "OpenAI",
presetNameGoogleGenai: "Google AI Studio", presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "Volcengine Ark", presetNameVolcengine: "Volcengine Ark",
+16 -3
View File
@@ -54,6 +54,18 @@ export const zhTranslations = {
notePlaceholder: "例如:公司专用账号", notePlaceholder: "例如:公司专用账号",
officialUrl: "官网链接", officialUrl: "官网链接",
managedProvider: "托管供应商(无需凭证)", managedProvider: "托管供应商(无需凭证)",
kimiOAuthLogin: "Kimi 授权登录",
kimiOAuthOpenPage: "打开授权页",
kimiOAuthCopyCode: "复制授权码",
kimiOAuthCodeCopied: "已复制",
kimiOAuthWaiting: "请在打开的页面中输入授权码,等待授权中…",
kimiOAuthSuccess: "授权成功,已写入 Kimi 登录凭据",
kimiOAuthExpired: "授权码已过期,请重新开始",
kimiOAuthDenied: "授权被拒绝",
kimiOAuthTimeout: "授权超时,请重新开始",
kimiOAuthRetry: "重新开始",
kimiOAuthCancel: "取消",
kimiOAuthNetworkError: "网络错误,正在重试…",
apiSettings: "API 设置", apiSettings: "API 设置",
apiFormat: "API 格式", apiFormat: "API 格式",
authField: "认证字段", authField: "认证字段",
@@ -313,9 +325,9 @@ export const zhTranslations = {
// Dashboard — footer / attribution // Dashboard — footer / attribution
footerScanned: "扫描 {n} 个文件 · {m} 条记录", footerScanned: "扫描 {n} 个文件 · {m} 条记录",
dashboardAttribution: "仪表盘功能基于", attributionPorted: "仪表盘与会话管理功能基于",
sessionsAttribution: "会话管理功能基于", attributionSuffix: "(MIT,© JochenYang)",
attributionSuffix: "(MIT,© JochenYang)移植", modelDataSourcePrefix: "新增模型与价格参考来自于",
// Sessions — preview // Sessions — preview
previewCollapseChars: "收起 · {n} 字符", previewCollapseChars: "收起 · {n} 字符",
@@ -349,6 +361,7 @@ export const zhTranslations = {
presetNameSiliconflow: "硅基流动", presetNameSiliconflow: "硅基流动",
presetNameNovita: "Novita AI", presetNameNovita: "Novita AI",
presetNameOpenrouter: "OpenRouter", presetNameOpenrouter: "OpenRouter",
presetNameTokenRhythm: "基元律动",
presetNameOpenai: "OpenAI 官方", presetNameOpenai: "OpenAI 官方",
presetNameGoogleGenai: "Google AI Studio", presetNameGoogleGenai: "Google AI Studio",
presetNameVolcengine: "火山方舟", presetNameVolcengine: "火山方舟",
+2
View File
@@ -23,6 +23,7 @@ import _relaxcode from "./relaxcode.png";
import _runapi from "./runapi.jpg"; import _runapi from "./runapi.jpg";
import _shengsuanyun from "./shengsuanyun.svg?url"; import _shengsuanyun from "./shengsuanyun.svg?url";
import _subrouter from "./subrouter.svg?url"; import _subrouter from "./subrouter.svg?url";
import _tokenrhythm from "./tokenrhythm.svg?url";
import _sudocode from "./sudocode.png"; import _sudocode from "./sudocode.png";
import _sudocodeUs from "./sudocode-us.png"; import _sudocodeUs from "./sudocode-us.png";
import _teamorouter from "./TeamoRouter-icon-dark.png"; import _teamorouter from "./TeamoRouter-icon-dark.png";
@@ -128,6 +129,7 @@ export const iconUrls: Record<string, string> = {
sudocode: _sudocode, sudocode: _sudocode,
"sudocode-us": _sudocodeUs, "sudocode-us": _sudocodeUs,
teamorouter: _teamorouter, teamorouter: _teamorouter,
tokenrhythm: _tokenrhythm,
unity2: _unity2, unity2: _unity2,
zetaapi: _zetaapi, zetaapi: _zetaapi,
}; };
+7
View File
@@ -696,6 +696,13 @@ export const iconMetadata: Record<string, IconMetadata> = {
keywords: ["stepfun", "step", "jieyue", "阶跃星辰"], keywords: ["stepfun", "step", "jieyue", "阶跃星辰"],
defaultColor: "#005AFF", defaultColor: "#005AFF",
}, },
tokenrhythm: {
name: "tokenrhythm",
displayName: "TokenRhythm",
category: "ai-provider",
keywords: ["tokenrhythm", "token rhythm", "基元律动", "aggregator", "relay", "gateway", "opensquilla"],
defaultColor: "#0D0D0C",
},
}; };
export function getIconMetadata(name: string): IconMetadata | undefined { export function getIconMetadata(name: string): IconMetadata | undefined {
+27
View File
@@ -0,0 +1,27 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 189 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="&#229;&#159;&#186;&#229;&#133;&#131;&#229;&#190;&#139;&#229;&#138;&#168;logo0630 2 [Vectorized]" opacity="0.75">
<path id="Vector" d="M28.2869 15.3313L52.3032 15.3318C56.6495 15.332 61.1409 15.3885 65.4782 15.3038C62.4476 17.8251 58.7745 21.2659 55.8259 23.9557C54.451 24.0375 52.5027 23.9839 51.0859 23.9842C48.459 23.9946 45.832 23.9874 43.2052 23.9626C44.8543 25.4287 47.4485 28.1941 49.1119 29.8604L61.0355 41.7999L68.4647 49.2348C68.9123 49.6831 71.0764 51.7891 71.2735 52.1321L71.1537 52.1688C67.1352 52.0344 62.9706 52.1522 58.9688 51.9728L45.811 38.8082C42.8897 35.885 39.8219 32.8966 36.9616 29.9257L36.9693 44.1437C34.1378 46.911 31.1624 49.6754 28.2845 52.4082L28.2869 15.3313Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_2" d="M98.7525 30.6261L100.21 30.6376L100.194 32.1506L103.548 32.1483L103.537 34.4298L100.204 34.4441C100.223 36.862 100.217 39.28 100.188 41.6978C101.416 41.6652 102.754 41.6897 103.991 41.6874C103.956 42.3315 103.988 43.3183 103.992 43.9834C102.257 43.9898 100.451 44.0225 98.723 43.9532C100.293 45.3913 102.261 45.9617 104.247 46.4987C104.03 47.4093 103.842 48.4144 103.65 49.3374C102.178 48.9282 99.9596 48.0446 98.7359 47.1392L98.7303 48.6004C97.2215 48.6082 95.7126 48.6069 94.2028 48.5966V50.1824C97.2187 50.1304 100.388 50.1795 103.416 50.1805C103.358 50.8001 103.384 51.7645 103.382 52.4095L85.6626 52.41L82.2408 52.4069C82.1845 51.6493 82.0959 50.9327 82.0033 50.1798L91.2904 50.1778L91.3044 48.6046L86.8554 48.6069L86.7349 47.176C84.9932 48.2367 83.7603 48.7314 81.8149 49.3328C81.5976 48.432 81.4066 47.4129 81.2142 46.4981C83.4621 45.9232 84.8957 45.4246 86.7564 43.9544C85.2596 44.0446 83.2197 43.9867 81.6781 43.9816C81.6749 43.3384 81.5832 42.3121 81.4779 41.6911L85.2003 41.6896L85.202 34.4349L82.4254 34.4319C82.3711 33.6655 82.326 32.9125 82.2188 32.1509L85.1936 32.1508L85.1881 30.652C86.1541 30.6266 87.1534 30.6348 88.1224 30.631L88.0896 32.1462L97.2732 32.1504L97.2824 30.6572L98.7525 30.6261ZM91.2808 44.7054L94.2028 44.7065L94.2038 46.2956C94.5083 46.5151 96.9954 46.3764 97.6672 46.4214C96.8117 45.5574 96.2802 45.0332 95.6194 43.9894C93.6999 44.0104 91.7815 44.0086 89.8628 43.984C89.1274 45.024 88.7137 45.528 87.7696 46.42C88.9413 46.3968 90.1131 46.3965 91.2848 46.419L91.2808 44.7054ZM97.2824 35.4409L97.2852 34.439C94.5175 34.4424 90.8129 34.5608 88.1224 34.4435L88.0839 35.4132L94.0229 35.4172C94.9817 35.4175 96.3485 35.3769 97.2824 35.4409ZM97.2741 41.6939C97.2824 41.4051 97.3359 40.9901 97.1892 40.7622C96.3189 40.6695 94.8212 40.7158 93.9066 40.7186L88.1216 40.7332C88.112 41.0237 88.088 41.3984 88.0937 41.6812C91.1174 41.683 94.2601 41.6422 97.2741 41.6939ZM88.1146 37.5911C88.1072 37.8571 88.0611 38.2777 88.1768 38.4975C88.6462 38.6161 90.4817 38.56 91.0733 38.5629C93.1342 38.5734 95.2069 38.5231 97.2695 38.5662C97.2759 38.3047 97.3248 37.8151 97.2058 37.6159C94.4695 37.475 90.9224 37.6123 88.1146 37.5911Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_3" d="M143.557 30.6421C144.519 30.6365 145.481 30.6369 146.444 30.6431L146.416 32.0838C147.712 32.0952 150.587 31.7416 151.532 32.6334C152.238 33.2995 152.073 34.9116 152.037 35.836L153.455 35.8341L153.452 38.2574L152.061 38.2665C152.068 38.6574 152.073 39.0484 152.074 39.4393C152.077 40.817 151.714 41.9762 150.071 42.0028C148.863 42.0222 147.644 41.9835 146.435 41.9641L146.417 43.4188L152.471 43.4164C152.45 44.172 152.462 44.9907 152.458 45.7513C150.451 45.767 148.443 45.7646 146.434 45.7444L146.421 47.307L153.455 47.3061L153.448 49.7244L146.433 49.7289C146.427 50.7883 146.426 51.8478 146.432 52.9071C145.466 52.9302 144.528 52.9131 143.562 52.8971L143.57 52.0394L143.562 49.7232C141.674 49.7332 139.786 49.7317 137.898 49.7187L137.652 47.305C139.595 47.2752 141.613 47.3037 143.563 47.3043V45.7518L138.841 45.7562C138.807 44.9585 138.72 44.2115 138.634 43.4192L143.567 43.4181L143.557 41.9638C142.283 41.9729 141.01 41.9746 139.735 41.9686C139.676 41.1971 139.589 40.4064 139.512 39.6348L143.557 39.6422L143.567 38.2609C142.025 38.2767 140.451 38.2581 138.907 38.2555C138.812 37.4492 138.73 36.6414 138.659 35.8325C140.283 35.8153 141.935 35.8318 143.561 35.8329L143.563 34.4377L139.734 34.4393C139.706 33.6753 139.59 32.8396 139.5 32.0747C140.831 32.1063 142.213 32.0809 143.561 32.1059L143.557 30.6421ZM146.418 35.8352C147.335 35.8344 148.302 35.817 149.215 35.8342C149.226 35.4685 149.29 35.001 149.104 34.7061C148.57 34.1891 147.223 34.4624 146.434 34.4293L146.418 35.8352ZM146.415 39.6429C147.046 39.6429 148.618 39.6765 149.164 39.578C149.198 39.1228 149.226 38.7194 149.231 38.263C148.344 38.2616 147.306 38.287 146.433 38.2546L146.415 39.6429Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_4" d="M106.528 38.04C113.768 38.1537 121.294 38.0359 128.569 38.0446L128.562 40.5572L122.09 40.5609C122.088 41.6707 121.918 49.1898 122.298 49.5794C122.677 49.9669 123.385 49.993 123.896 49.9976C124.384 50.0018 125.079 50.0346 125.443 49.658C126.096 48.9854 125.794 46.7277 125.787 45.8074C126.773 45.9027 127.712 46.0629 128.733 46.1458C128.717 47.4305 128.81 48.9848 128.679 50.2335C128.37 53.174 123.248 52.7047 121.193 52.341C118.598 51.6953 119.124 49.0866 119.126 46.9824L119.136 40.5538L115.088 40.5599C115.11 42.4962 115.153 44.316 114.498 46.1768C113.261 49.6902 110.052 51.4181 106.92 52.9188L106.102 49.9209C107.583 49.2936 109.128 48.4728 110.259 47.3244C112.25 45.3015 112.152 43.1551 112.126 40.5521L106.708 40.5516C106.715 39.8733 106.589 38.7478 106.528 38.04Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_5" d="M66.8075 16.3432C66.9854 16.5409 66.8988 26.5212 66.8939 27.8531C64.2096 30.6301 61.1162 33.5951 58.3448 36.3093L55.4318 33.3274C54.3728 32.2341 53.3067 31.1479 52.2334 30.0688L66.8075 16.3432Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_6" d="M168.959 30.6309C169.884 30.6563 170.931 30.6308 171.866 30.6301L171.852 34.4054C173.207 34.3801 175.717 34.1215 176.702 34.9053C177.766 36.6628 177.13 47.7169 177.116 50.1618C177.1 53.3678 173.527 52.8804 171.243 52.5637L170.593 50.0131C171.16 50.153 171.784 50.2022 172.368 50.2301C172.828 50.2521 173.663 50.2678 173.997 49.902C174.784 49.0385 174.376 39.5355 174.327 38.0864C174.322 37.7564 174.344 37.497 174.196 37.1993C173.703 36.7025 172.784 36.9975 171.856 36.8896C171.872 43.0678 171.482 47.246 168.791 52.9199L165.627 52.9227C168.446 46.8398 168.968 43.577 168.974 36.942L166.948 36.9497C166.846 36.141 166.779 35.2157 166.701 34.3979L168.97 34.3965L168.959 30.6309Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_7" d="M20.3634 15.2565C22.2816 15.2079 24.3745 15.2491 26.3065 15.2484C26.241 18.0694 26.2922 21.1045 26.2817 23.9411L16.4729 23.9386C14.5285 23.9342 12.3458 23.8846 10.4213 23.964C13.7597 21.1535 17.0176 18.0347 20.3634 15.2565Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_8" d="M155.77 38.0545L166.671 38.0592L166.66 40.4752C165.053 40.4843 163.446 40.4852 161.84 40.4778C161.528 43.4002 160.536 45.7653 159.075 48.266C160.563 48.2528 162.053 48.2562 163.542 48.2759C163.189 46.7011 162.709 45.1377 162.374 43.5428L162.451 43.4572L163.137 43.4397L165.225 43.4383C165.596 44.9311 165.967 46.4514 166.333 47.9353C166.75 49.6312 166.649 50.8135 164.561 50.8055C163.779 50.8026 162.918 50.8113 162.128 50.8104L155.98 50.8004C155.944 50.318 155.704 48.6508 155.865 48.2955L156.187 48.2595C156.442 48.0579 156.735 47.3129 156.883 46.9886C157.864 44.8345 158.443 42.8159 158.779 40.4811L155.978 40.4739L155.77 38.0545Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_9" d="M135.661 36.5098L138.529 36.5075C137.667 39.5604 136.341 41.2684 133.853 43.125C135.823 43.0944 136.683 42.9556 136.651 45.3019C136.642 45.9935 136.648 46.7679 136.646 47.4658L136.62 52.91C135.725 52.9391 134.714 52.9162 133.81 52.9155L133.813 44.8194L131.69 44.8038C131.463 43.9161 131.164 42.9683 130.903 42.0839C133.129 40.7958 134.759 38.9216 135.661 36.5098Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_10" d="M108.577 31.6073C114.355 31.739 120.686 31.6114 126.507 31.6117L126.501 34.1803C120.771 34.0572 114.56 34.1742 108.794 34.1751C108.692 33.3151 108.638 32.4706 108.577 31.6073Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_11" d="M119.479 15.71C120.798 15.693 123.164 15.5224 124.308 16.0558C126.299 16.9837 126.076 20.1731 123.91 20.9264L126.019 24.5984L124.235 24.5917C123.99 24.2657 122.699 21.6603 122.451 21.1619L121.062 21.1583C121.058 22.307 121.06 23.4556 121.068 24.6042L119.473 24.5985C119.566 21.8236 119.51 18.5035 119.479 15.71ZM121.051 19.7961C121.945 19.8033 123.018 19.9234 123.753 19.3913C124.995 17.011 122.631 17.0745 121.058 17.0804L121.051 19.7961Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_12" d="M155.37 18.0461C155.609 18.0631 156.076 18.0928 156.278 18.1888C156.662 18.3716 156.969 18.8524 157.439 18.8598C157.913 18.7376 157.723 18.2102 158.869 18.0577C162.365 17.5926 161.518 22.3556 161.493 24.6266C161.146 24.5826 160.371 24.5993 159.991 24.5975C160.008 23.7007 160.273 20.0376 159.597 19.6128C157.02 17.9932 157.607 22.7534 157.616 23.9524C157.627 24.1804 157.666 24.3672 157.523 24.5406C157.209 24.6959 156.557 24.606 156.131 24.6033C156.14 23.7629 156.368 20.0944 155.742 19.655C153.081 17.7873 153.733 23.3044 153.748 24.6107L152.219 24.596L152.224 18.1901L153.622 18.1893C153.646 18.4011 153.643 18.5972 153.772 18.7625C154.361 18.5998 154.068 18.2095 155.37 18.0461Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_13" d="M91.1429 18.0522C94.3302 18.122 94.6993 21.3866 93.4369 23.7784C93.088 24.4389 92.238 24.6456 91.5331 24.7415C89.621 24.7439 88.6302 23.9073 88.4773 21.9881C88.3105 19.8932 88.8263 18.2411 91.1429 18.0522ZM91.5428 23.479C92.8444 22.7222 93.0262 19.9867 91.5137 19.3544C91.3955 19.305 91.0173 19.3148 90.8734 19.3189C89.5574 20.1837 89.4669 23.4529 91.5428 23.479Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_14" d="M145.02 15.4482L146.555 15.456C146.534 16.612 146.558 17.8251 146.565 18.9856C146.847 18.6568 147.078 18.4626 147.403 18.1796C151.439 17.4693 150.425 21.8162 150.425 24.6083C149.943 24.5952 149.421 24.6041 148.934 24.6027L148.939 21.4117C148.948 20.7508 149.021 20.2024 148.532 19.6668C148.255 19.2434 147.363 18.9955 147.009 19.481C146.226 20.5554 146.536 23.3853 146.553 24.6374C146.158 24.5887 145.445 24.6029 145.024 24.5998C145.105 21.8847 145.105 18.1604 145.02 15.4482Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_15" d="M127.242 15.4528L128.754 15.4523L128.731 19.0125C129.054 18.6436 129.23 18.4927 129.609 18.1793C133.617 17.4115 132.634 21.915 132.617 24.626C132.225 24.5865 131.519 24.6004 131.104 24.5962C131.138 23.6667 131.118 22.6056 131.122 21.6658C131.127 21.2822 131.185 19.819 130.796 19.5937C128.046 18.0022 128.721 23.0514 128.732 24.6298C128.356 24.5837 127.656 24.6016 127.253 24.6009C127.299 21.6611 127.302 18.3882 127.242 15.4528Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_16" d="M104.703 18.0539C107.273 18.0208 107.522 19.7472 107.536 21.7736C107.485 21.7663 107.282 21.7393 107.24 21.7437C106.531 21.8174 103.885 21.4971 103.447 21.9005C103.203 22.6803 103.891 23.0805 104.486 23.4144C105.403 23.9286 105.93 23.2584 106.671 23.0228L107.281 23.9938C105.121 25.5435 102.096 24.8569 101.965 21.8757C101.873 19.7994 102.385 18.2445 104.703 18.0539ZM105.228 20.7215C105.635 20.7127 105.722 20.7826 106.002 20.5611C106.26 19.6405 105.264 19.2138 104.479 19.2263C103.984 19.3874 103.424 19.9315 103.375 20.4601C103.611 20.9109 104.71 20.7275 105.228 20.7215Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_17" d="M95.613 15.4501L97.1495 15.4595C97.0489 16.8341 97.132 19.5081 97.1476 20.9768C97.8592 20.0398 98.5873 19.1154 99.332 18.204L101.148 18.1842C100.305 19.0863 99.3081 20.3303 98.4959 21.2937C99.0164 21.8404 99.6947 22.698 100.195 23.2939C100.563 23.7308 100.935 24.1646 101.311 24.5952L99.44 24.5937C98.6621 23.6374 97.8536 22.6688 97.1255 21.6764C97.1273 22.6237 97.1089 23.6683 97.1532 24.6062C96.6299 24.6007 96.1288 24.6149 95.6056 24.6313C95.6683 21.6443 95.6314 18.4428 95.613 15.4501Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_18" d="M138.155 30.6314L138.216 30.6632C138.225 30.6912 138.25 30.7914 138.242 30.8154C137.368 33.7396 135.557 36.4317 132.96 38.0842C132.725 38.2333 132.282 38.5592 132.026 38.5579L131.893 38.3232L131.215 36.0397C133.275 34.5304 134.685 33.1812 135.475 30.6529C136.336 30.6234 137.286 30.6371 138.155 30.6314Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_19" d="M138.031 18.1923L139.634 18.187C139.113 19.4098 138.609 20.91 138.147 22.1743C137.664 23.3967 137.181 25.3724 136.413 26.3367C135.914 26.9644 135.196 26.9582 134.462 26.9886C134.433 25.9751 134.301 25.8813 135.281 25.5299C135.564 25.4284 135.723 24.84 135.83 24.5486L133.541 18.1904L135.218 18.1897C135.53 19.4879 136.212 21.5023 136.617 22.8346L138.031 18.1923Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_20" d="M112.102 18.046C115.346 18.1544 114.465 22.2111 114.441 24.6366C114.084 24.5819 113.334 24.6005 112.944 24.6C112.968 23.2884 113.132 21.3365 112.826 20.0538C112.623 19.2009 111.715 19.2205 110.994 19.5094C110.308 20.3316 110.553 23.3734 110.563 24.6263C110.172 24.5862 109.502 24.601 109.088 24.5984L109.092 18.1938C109.399 18.1918 110.308 18.1335 110.496 18.2688L110.576 18.8185C110.925 18.811 110.951 18.218 112.102 18.046Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_21" d="M157.03 32.3117L165.63 32.3144C165.624 33.1227 165.608 33.931 165.584 34.7389C165.222 34.8005 163.328 34.7749 162.89 34.7741L157.199 34.7598C157.159 33.9511 157.089 33.1214 157.03 32.3117Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_22" d="M81.1911 15.7075L87.5055 15.7099L87.4955 17.1429C86.7222 17.1164 85.9486 17.1026 85.1749 17.1016L85.1705 22.3787L85.1727 24.6281C84.799 24.5835 84.0131 24.599 83.6075 24.596L83.6191 17.1192C82.8556 17.1088 82.092 17.115 81.3288 17.1376C81.3098 16.6005 81.267 16.2407 81.1911 15.7075Z" fill="var(--fill-0, #0D0D0C)"/>
<path id="Vector_23" d="M140.921 16.2497L142.45 16.2545C142.414 16.8395 142.427 17.6034 142.421 18.2023L143.71 18.1906L143.703 19.4062L142.427 19.3996C142.426 20.1072 142.287 22.5398 142.611 23.1728C142.719 23.383 143.27 23.3172 143.532 23.3359C143.762 23.5842 143.697 23.5858 143.741 23.9739L143.686 24.4864L143.561 24.5789C139.822 24.8423 141.248 21.9367 140.879 19.4935C140.535 19.4014 139.948 19.3017 139.914 18.8681C139.846 18.0247 140.318 18.2036 140.926 18.1833L140.921 16.2497Z" fill="var(--fill-0, #0D0D0C)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large. Load diff
+62716 -596
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -4,6 +4,13 @@ import snapshot from "./models-dev.json";
* Compact per-model reference data from models.dev * Compact per-model reference data from models.dev
* (see scripts/fetch-models-dev.mjs). * (see scripts/fetch-models-dev.mjs).
*/ */
export interface ModelCost {
input?: number;
output?: number;
cache_read?: number;
cache_write?: number;
}
export interface ModelRef { export interface ModelRef {
name?: string; name?: string;
context?: number; context?: number;
@@ -12,9 +19,19 @@ export interface ModelRef {
structured_output?: boolean; structured_output?: boolean;
image?: boolean; image?: boolean;
video?: boolean; video?: boolean;
/** $/M tokens from models.dev (added by fetch-models-dev.mjs). */
cost?: ModelCost;
} }
const data: Record<string, ModelRef> = snapshot; const raw = snapshot as Record<string, unknown>;
// Drop the "last_updated" metadata key; the rest is "<provider>/<model>".
const data: Record<string, ModelRef> = {};
for (const [key, value] of Object.entries(raw)) {
if (key !== "last_updated" && value && typeof value === "object") {
data[key] = value as ModelRef;
}
}
// models.dev keys are "<lab>/<model>" and may be mixed-case (e.g. // models.dev keys are "<lab>/<model>" and may be mixed-case (e.g.
// "minimax/MiniMax-M3"), while provider APIs return bare model ids // "minimax/MiniMax-M3"), while provider APIs return bare model ids