Compare commits

..
3 Commits
Author SHA1 Message Date
KimiSwitch Dev fcd08eae32 feat: 阈值预警/加力钱包/api_key_env/版本跟踪器/一键体检 — bump to v0.7.20
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-20 09:49:49 +08:00
KimiSwitch Dev 35581103ed feat(usage): plan:kimi_coding 兼容 kimi-code 0.43.1 quota 用量模型
服务端 api.kimi.com/coding/v1/usages 已由绝对 used/limit 行切换为
quota 模型(usages.limit_5h/limit_7d/limit_month_total/limit_month_code,
条目为 used_ratio 0-1 比率 + reset_time),见上游 #3787。

- parse_kimi_coding 优先解析新结构,旧 limits/usage 结构保留为回退
- used_ratio >1 时按已是百分数处理(防御)
- 新 tier id month_code + 前端 planLabel/i18n 中英文案
- 新增 3 个测试:四窗口解析 / 比率防御 / 空窗口回退旧结构
2026-09-16 14:14:29 +08:00
KimiSwitch Dev 1b80d66239 chore(flags): 适配 kimi-code 0.43.0 — 移除转正 auto_session_title 实验 flag — bump to v0.7.19
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-14 22:40:44 +08:00
41 changed files with 2982 additions and 263 deletions

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.18", "version": "0.7.20",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+10
View File
@@ -0,0 +1,10 @@
# KimiSwitch v0.7.19
## 适配
- **适配 kimi-code 0.43.0 实验 flag 镜像(7→5)**:移除已转正的 `auto_session_title` 开关(上游 #3749,AI 会话标题不再需要 flag,首轮对话后自动生成、可在重命名处重新生成)。设置页「实验性 flag」、环境变量探测(`get_experimental_env_status`)、中英文案与测试断言同步清理;上游 0.43.0 注册表现剩 5 个 flag: `wait_for` / `tool-select` / `notify_user` / `tower` / `subagent_fork`
- **兼容性核查结论(无需改动)**:0.43.0 的 wire 持久化重建(#3737)不影响用量统计——`usage.record` / `token_counting.measured` / `context.apply_compaction` / `turn.prompt` 记录全部保留(`turn.prompt` 仅新增可选 `turnId` 字段);新增配置项 `loop_control.compaction_max_attempts`(#3750)经现有合并式写入验证保存不丢失;MCP `deferred` 字段(#3667)不涉及本应用管理的配置节
## 备注
- 若此前在 `[experimental]` 中显式开启过 `auto_session_title`,残留键会被上游静默忽略,无需手动清理
+23
View File
@@ -0,0 +1,23 @@
# KimiSwitch v0.7.20
## 新增
- **套餐用量阈值预警**:用量配置面板新增「预警阈值 %」(0-100,0 或空 = 关闭)。每次账单查询后,任一百分比用量窗口达到阈值即红色高亮 + ⚠ 标记,低于阈值自动消除;自动查询间隔会周期性重复评估
- **Kimi For Coding 加力钱包**:0.43.1 起服务端 quota 响应中的 `boosterWallet`(加力钱包)现在解析展示为一行余额(💰 月度用量 / 总额 / 余额,含币种),与四个用量窗口并列
- **供应商凭证环境变量化(`api_key_env`)**:适配 kimi-code 2.0.0 #3762——供应商编辑页「凭证来源」可切换「直接填写 / 环境变量」,密钥不写进 config.toml;导出时与 `api_key` 互斥;账单查询 / 获取模型列表会实时解析环境变量(变量未设置给出明确错误);SQLite 新增列(含自动迁移)
- **Kimi Code 版本跟踪器**:设置页新增「Kimi Code 版本」卡片——本机已装版本(`kimi --version` 检测)、上游最新版本、适配状态徽标(绿已适配 / 黄建议升级 KimiSwitch / 橙部分兼容 / 灰未安装),可一键打开上游 Releases
- **供应商一键体检**:供应商列表新增「一键体检」按钮——并发探活所有启用供应商的推理端点(`GET /v1/models`)并顺带重新查询账单,每行状态点 + 延迟 tooltip + 顶部汇总。状态判定为三态:账单或探活任一真实通过 = 绿;鉴权失败必红;端点不支持模型列表(404)计中性不误报
## 设置
- **压缩重试次数**:Agent 设置新增 `loop_control.compaction_max_attempts`(上游 0.43.0 #3750,默认 5,0/空不写键)
- **文件监听开关**:新增 `[watch] enabled` 开关(上游 2.0.1 #3892,2.0.2 起上游默认关闭),并探测 `KIMI_CODE_WATCH` 环境变量
## 适配
- **kimi-code 2.0.2 已适配确认**:wire 记录格式、实验 flag(5 个)、config 模式均无变化;`api_key_env` / `[watch]` / `[database]` 新增配置节经现有合并式写入验证无损往返
## 备注
- 版本跟踪器的「最新版本」查询直连 GitHub,受限网络环境下可能显示 `—`(已装版本与状态判定不受影响)
- 体检跳过大模型平台类供应商(anthropic / google-genai / vertexai 无 `/v1/models` 路由),显示灰点「未检测」
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.18" version = "0.7.20"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.18" version = "0.7.20"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+951 -19
View File
File diff suppressed because it is too large. Load diff
+18 -12
View File
@@ -81,6 +81,7 @@ pub fn init_db() -> DbResult<Connection> {
provider_type TEXT NOT NULL, provider_type TEXT NOT NULL,
base_url TEXT, base_url TEXT,
api_key TEXT, api_key TEXT,
api_key_env TEXT,
env TEXT, env TEXT,
note TEXT, note TEXT,
official_url TEXT, official_url TEXT,
@@ -141,6 +142,9 @@ pub fn init_db() -> DbResult<Connection> {
for stmt in [ for stmt in [
"ALTER TABLE providers ADD COLUMN icon TEXT", "ALTER TABLE providers ADD COLUMN icon TEXT",
"ALTER TABLE providers ADD COLUMN icon_color TEXT", "ALTER TABLE providers ADD COLUMN icon_color TEXT",
// `api_key_env` (kimi-code 2.0.0+): the name of the environment
// variable holding the credential, an alternative to `api_key`.
"ALTER TABLE providers ADD COLUMN api_key_env TEXT",
] { ] {
if let Err(e) = conn.execute(stmt, []) { if let Err(e) = conn.execute(stmt, []) {
let msg = e.to_string(); let msg = e.to_string();
@@ -162,28 +166,29 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
let mut providers = IndexMap::new(); let mut providers = IndexMap::new();
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"SELECT name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other "SELECT name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other
FROM providers WHERE agent = ?1 ORDER BY id", FROM providers WHERE agent = ?1 ORDER BY id",
)?; )?;
let provider_rows = stmt.query_map(params![agent.as_str()], |row| { let provider_rows = stmt.query_map(params![agent.as_str()], |row| {
let provider_type: String = row.get(1)?; let provider_type: String = row.get(1)?;
let env_json: Option<String> = row.get(4)?; let env_json: Option<String> = row.get(5)?;
let raw_json: Option<String> = row.get(12)?; let raw_json: Option<String> = row.get(13)?;
Ok(Provider { Ok(Provider {
name: row.get(0)?, name: row.get(0)?,
provider_type: provider_type_for_str(&provider_type), provider_type: provider_type_for_str(&provider_type),
base_url: row.get(2)?, base_url: row.get(2)?,
api_key: row.get(3)?, api_key: row.get(3)?,
api_key_env: row.get(4)?,
env: env_json env: env_json
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(), .unwrap_or_default(),
note: row.get(5)?, note: row.get(6)?,
official_url: row.get(6)?, official_url: row.get(7)?,
managed: row.get::<_, i32>(7)? != 0, managed: row.get::<_, i32>(8)? != 0,
enabled: row.get::<_, i32>(8)? != 0, enabled: row.get::<_, i32>(9)? != 0,
active: row.get::<_, i32>(9)? != 0, active: row.get::<_, i32>(10)? != 0,
icon: row.get(10)?, icon: row.get(11)?,
icon_color: row.get(11)?, icon_color: row.get(12)?,
raw_other: raw_json raw_other: raw_json
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(Value::Null), .unwrap_or(Value::Null),
@@ -253,8 +258,8 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
{ {
let mut insert_provider = tx.prepare( let mut insert_provider = tx.prepare(
"INSERT INTO providers "INSERT INTO providers
(agent, name, provider_type, base_url, api_key, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other) (agent, name, provider_type, base_url, api_key, api_key_env, env, note, official_url, managed, enabled, active, icon, icon_color, raw_other)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?; )?;
for provider in config.providers.values() { for provider in config.providers.values() {
@@ -264,6 +269,7 @@ pub fn save_config(agent: &Agent, config: &Config) -> DbResult<()> {
provider.provider_type.as_str(), provider.provider_type.as_str(),
provider.base_url, provider.base_url,
provider.api_key, provider.api_key,
provider.api_key_env,
serde_json::to_string(&provider.env).ok(), serde_json::to_string(&provider.env).ok(),
provider.note, provider.note,
provider.official_url, provider.official_url,
+148 -2
View File
@@ -153,6 +153,14 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string()); let api_key = table.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string());
// kimi-code 2.0.0+: `api_key_env` names the environment variable
// holding the credential; it is mutually exclusive with `api_key`.
// Captured as its own field (not left in raw_other) so export can
// enforce the exclusivity and the UI can edit it.
let api_key_env = table
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let managed = table.contains_key("oauth") let managed = table.contains_key("oauth")
|| table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false); || table.get("managed").and_then(|v| v.as_bool()).unwrap_or(false);
let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true); let enabled = table.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
@@ -181,6 +189,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
rest.remove("type"); rest.remove("type");
rest.remove("base_url"); rest.remove("base_url");
rest.remove("api_key"); rest.remove("api_key");
rest.remove("api_key_env");
rest.remove("managed"); rest.remove("managed");
rest.remove("enabled"); rest.remove("enabled");
rest.remove("env"); rest.remove("env");
@@ -200,6 +209,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
provider_type, provider_type,
base_url: base_url.filter(|s| !s.is_empty()), base_url: base_url.filter(|s| !s.is_empty()),
api_key: api_key.filter(|s| !s.is_empty()), api_key: api_key.filter(|s| !s.is_empty()),
api_key_env: api_key_env.filter(|s| !s.is_empty()),
env, env,
note: None, note: None,
official_url: None, official_url: None,
@@ -289,11 +299,30 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) { if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
pt.insert("base_url".to_string(), TomlValue::String(base_url)); pt.insert("base_url".to_string(), TomlValue::String(base_url));
} }
// `api_key_env` (kimi-code 2.0.0+) and `api_key` are mutually
// exclusive upstream: emitting both makes the CLI reject the provider
// block, so the env var name alone is written whenever the provider is
// configured that way. A legacy row that still carries the key in
// raw_other (imports before the field existed) is used as the source
// too, so the setting survives the upgrade.
let api_key_env = provider
.api_key_env
.clone()
.or_else(|| {
provider
.raw_other
.get("api_key_env")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty());
// Managed (OAuth) providers persist an empty api_key line so the block // Managed (OAuth) providers persist an empty api_key line so the block
// matches the official CLI's provisioned shape (apiKey: ''). A // matches the official CLI's provisioned shape (apiKey: ''). A
// user-set api_key on a managed provider (api_key outranks OAuth in // user-set api_key on a managed provider (api_key outranks OAuth in
// the CLI's credential priority) must survive the round-trip. // the CLI's credential priority) must survive the round-trip.
if provider.managed if let Some(env_key) = api_key_env {
pt.insert("api_key_env".to_string(), TomlValue::String(env_key));
} else if provider.managed
&& provider.api_key.as_deref().map_or(true, |s| s.is_empty()) && provider.api_key.as_deref().map_or(true, |s| s.is_empty())
{ {
pt.insert("api_key".to_string(), TomlValue::String("".to_string())); pt.insert("api_key".to_string(), TomlValue::String("".to_string()));
@@ -328,10 +357,12 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
}); });
pt.insert("oauth".to_string(), oauth); pt.insert("oauth".to_string(), oauth);
} }
// Merge remaining raw fields (oauth and env are handled explicitly above). // Merge remaining raw fields (oauth, env and api_key_env are handled
// explicitly above).
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) { if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth"); extra.remove("oauth");
extra.remove("env"); extra.remove("env");
extra.remove("api_key_env");
// Strip Kimi-Switch-private field: the remembered per-provider // Strip Kimi-Switch-private field: the remembered per-provider
// default model is stored in raw_other.default_model and must NOT // default model is stored in raw_other.default_model and must NOT
// leak into the agent's config.toml. // leak into the agent's config.toml.
@@ -562,6 +593,7 @@ api_key = ""
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://fast.cdks.work".to_string()), base_url: Some("https://fast.cdks.work".to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -623,6 +655,7 @@ api_key = ""
provider_type: ProviderType::Openai, provider_type: ProviderType::Openai,
base_url: Some(base_url.to_string()), base_url: Some(base_url.to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -760,6 +793,7 @@ default_effort = "low"
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://example.com".to_string()), base_url: Some("https://example.com".to_string()),
api_key: Some("sk-test".to_string()), api_key: Some("sk-test".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -868,6 +902,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: Some("https://a.example.com".to_string()), base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".to_string()), api_key: Some("sk-a".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -888,6 +923,7 @@ max_context_size = 1048576
provider_type: ProviderType::Openai, provider_type: ProviderType::Openai,
base_url: Some("https://b.example.com".to_string()), base_url: Some("https://b.example.com".to_string()),
api_key: Some("sk-b".to_string()), api_key: Some("sk-b".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -929,6 +965,7 @@ max_context_size = 1048576
provider_type: ProviderType::Anthropic, provider_type: ProviderType::Anthropic,
base_url: None, base_url: None,
api_key: Some("sk-x".to_string()), api_key: Some("sk-x".to_string()),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -974,6 +1011,7 @@ max_context_size = 1048576
provider_type: ProviderType::Kimi, provider_type: ProviderType::Kimi,
base_url: Some("https://api.kimi.com/coding/v1".to_string()), base_url: Some("https://api.kimi.com/coding/v1".to_string()),
api_key: api_key.map(String::from), api_key: api_key.map(String::from),
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
@@ -1153,4 +1191,112 @@ enabled = true
"user-removed section must be dropped on export" "user-removed section must be dropped on export"
); );
} }
#[test]
fn kimi_code_import_extracts_api_key_env() {
// kimi-code 2.0.0+: `api_key_env` names the environment variable that
// holds the credential. It must become its own Provider field and stop
// living in raw_other, so export can enforce the api_key exclusivity.
let toml_str = r#"
default_model = "p1/m1"
[providers.p1]
type = "openai"
base_url = "https://proxy.example.com/v1"
api_key_env = "MY_PROXY_KEY"
[models."p1/m1"]
provider = "p1"
model = "m1"
max_context_size = 128000
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let provider = config.providers.get("p1").unwrap();
assert_eq!(provider.api_key_env.as_deref(), Some("MY_PROXY_KEY"));
assert_eq!(provider.api_key, None);
assert!(
provider.raw_other.get("api_key_env").is_none(),
"api_key_env must not stay in raw_other"
);
let exported = config_to_kimi_code(&config, Some(&value));
let exported_provider = exported
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p1").unwrap()
.as_table().unwrap();
assert_eq!(
exported_provider.get("api_key_env").and_then(|v| v.as_str()),
Some("MY_PROXY_KEY")
);
assert!(
!exported_provider.contains_key("api_key"),
"api_key and api_key_env are mutually exclusive upstream"
);
}
#[test]
fn kimi_code_export_api_key_env_wins_over_stored_api_key() {
// A provider switched to env-var credentials in the UI must export
// ONLY `api_key_env`: writing both keys makes the CLI reject the block.
// A legacy raw_other copy of the key acts as the source when the field
// is empty (rows saved before the field existed).
let make_provider = |api_key: Option<&str>, api_key_env: Option<&str>, raw: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://proxy.example.com/v1".to_string()),
api_key: api_key.map(String::from),
api_key_env: api_key_env.map(String::from),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other: raw,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let config = Config {
default_model: None,
providers: IndexMap::from([("p".to_string(), p)]),
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
config_to_kimi_code(&config, None)
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p").unwrap()
.as_table()
.unwrap()
.clone()
};
// Both fields set (a DB row saved before the UI cleared the key).
let pt = export(make_provider(Some("sk-stale"), Some("MY_PROXY_KEY"), Value::Null));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("MY_PROXY_KEY"));
assert!(!pt.contains_key("api_key"), "the stored key must be dropped");
// Legacy row: only raw_other carries the key.
let pt = export(make_provider(
Some("sk-stale"),
None,
serde_json::json!({"api_key_env": "LEGACY_KEY"}),
));
assert_eq!(pt.get("api_key_env").and_then(|v| v.as_str()), Some("LEGACY_KEY"));
assert!(!pt.contains_key("api_key"));
// No env reference → the stored key is written as before.
let pt = export(make_provider(Some("sk-keep"), None, Value::Null));
assert_eq!(pt.get("api_key").and_then(|v| v.as_str()), Some("sk-keep"));
assert!(!pt.contains_key("api_key_env"));
}
} }
+4
View File
@@ -103,10 +103,14 @@ pub fn run() {
commands::list_provider_models, commands::list_provider_models,
commands::test_connectivity, commands::test_connectivity,
commands::query_provider_usage, commands::query_provider_usage,
commands::health_check_all,
commands::debug_log, commands::debug_log,
commands::get_app_setting, commands::get_app_setting,
commands::set_app_setting, commands::set_app_setting,
commands::check_for_update, commands::check_for_update,
commands::get_kimi_code_version,
commands::get_kimi_code_latest,
commands::get_kimi_code_version_info,
commands::download_update, commands::download_update,
commands::open_installer, commands::open_installer,
commands::open_external_url, commands::open_external_url,
+13
View File
@@ -107,6 +107,12 @@ pub struct Provider {
pub provider_type: ProviderType, pub provider_type: ProviderType,
pub base_url: Option<String>, pub base_url: Option<String>,
pub api_key: Option<String>, pub api_key: Option<String>,
/// kimi-code 2.0.0+: name of the process environment variable holding the
/// credential, as an alternative to a stored `api_key` (the two are
/// mutually exclusive upstream — exporting both makes the CLI reject the
/// provider block). Only the variable *name* is stored, never its value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
#[serde(default)] #[serde(default)]
pub env: IndexMap<String, String>, pub env: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +155,7 @@ impl PartialEq for Provider {
&& self.provider_type == other.provider_type && self.provider_type == other.provider_type
&& self.base_url == other.base_url && self.base_url == other.base_url
&& self.api_key == other.api_key && self.api_key == other.api_key
&& self.api_key_env == other.api_key_env
&& self.env == other.env && self.env == other.env
&& self.note == other.note && self.note == other.note
&& self.official_url == other.official_url && self.official_url == other.official_url
@@ -172,6 +179,7 @@ impl std::fmt::Debug for Provider {
.field("provider_type", &self.provider_type) .field("provider_type", &self.provider_type)
.field("base_url", &self.base_url) .field("base_url", &self.base_url)
.field("api_key", &"<redacted>") .field("api_key", &"<redacted>")
.field("api_key_env", &self.api_key_env)
.field("env", &"<redacted>") .field("env", &"<redacted>")
.field("managed", &self.managed) .field("managed", &self.managed)
.field("enabled", &self.enabled) .field("enabled", &self.enabled)
@@ -285,6 +293,11 @@ pub struct UsageConfig {
/// Per-request timeout in seconds; 0/None = default (8s). /// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>, pub timeout_seconds: Option<u64>,
/// 用量预警阈值(百分比,0-100);None/0 = 关闭。判定在前端
/// src/hooks/useUsageQuery.ts,Rust 只负责透传——缺了此字段,serde 会在
/// 保存时把前端写下的 threshold 静默丢掉。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub threshold: Option<u32>,
} }
impl UsageConfig { impl UsageConfig {
+1
View File
@@ -659,6 +659,7 @@ fn provision_managed_provider(region: KimiRegion, oauth_key: &str) -> Result<(),
provider_type: crate::models::ProviderType::Kimi, provider_type: crate::models::ProviderType::Kimi,
base_url: None, base_url: None,
api_key: None, api_key: None,
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
+3
View File
@@ -332,6 +332,9 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
provider_type, provider_type,
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()), base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()), api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
// Pi's provider file has no env-var credential reference; the
// field stays empty so a Kimi-Code-only setting never leaks here.
api_key_env: None,
env: IndexMap::new(), env: IndexMap::new(),
note: None, note: None,
official_url: None, official_url: None,
+177 -4
View File
@@ -9,6 +9,9 @@
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100, //! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。 //! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//! //!
//! 例外:Kimi 的加力钱包(`PLAN_BOOSTER_WALLET`)不是百分比窗口而是金额行,
//! `used` / `total` / `remaining` 均为货币金额(分换算而来),`unit` 为币种。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。 //! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched}; use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
@@ -16,11 +19,17 @@ use super::usage_types::{UsageData, UsageResult};
use std::time::Duration; use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及 // 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及
// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖 // 未来新增)都只用这四个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
// 此约定做本地化映射——新增 tier id 时必须同步加映射。 // 此约定做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour"; const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit"; const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
const TIER_MONTHLY_LIMIT: &str = "monthly_limit"; const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
/// Kimi quota 模型的月度代码窗口(kimi-code #3787 后 `limit_month_code`)。
const TIER_MONTH_CODE: &str = "month_code";
/// Kimi quota 响应里的加力钱包(boosterWallet)。**不是百分比 tier**:这条是
/// 金额行,前端 src/lib/usage-display.ts 的 planLabel() 负责本地化,
/// UsageFooter 按「金额行」而非配额行渲染(used/total 是钱数,不是配额比例)。
const PLAN_BOOSTER_WALLET: &str = "booster_wallet";
/// 套餐条目的统一构造:按百分比表示用量。 /// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData { fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
@@ -70,7 +79,13 @@ fn parse_f64(value: &serde_json::Value) -> Option<f64> {
// GET {base_url}/usages // GET {base_url}/usages
// 默认 https://api.kimi.com/coding/v1/usages // 默认 https://api.kimi.com/coding/v1/usages
// global: https://api.kimi.ai/coding/v1/usages // global: https://api.kimi.ai/coding/v1/usages
// Response: { limits: [{ detail: { limit, remaining, resetTime } }], // Response(kimi-code #3787 / 0.43.1 起服务端切换为 quota 模型):
// { usages: { limit_5h, limit_7d, limit_month_total, limit_month_code:
// { used_ratio: 0-1, reset_time?: ISO 8601 } },
// boosterWallet: { balanceCents, totalCents, monthlyChargeLimitEnabled,
// monthlyChargeLimitCents, monthlyUsedCents, currency },
// goods_version }
// 旧结构(兼容保留): { limits: [{ detail: { limit, remaining, resetTime } }],
// usage: { limit, remaining, resetTime } } // usage: { limit, remaining, resetTime } }
/// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。 /// 由 base_url 拼接 usages 查询 URL;base_url 为空/空白时回退大陆默认。
@@ -95,8 +110,9 @@ pub async fn query_kimi_coding(
Fetched::Body(body) => { Fetched::Body(body) => {
let tiers = parse_kimi_coding(&body); let tiers = parse_kimi_coding(&body);
if tiers.is_empty() { if tiers.is_empty() {
// 响应里没有可解析的套餐档位(limits/usage 缺失或字段变了)。 // 响应里没有可解析的套餐档位(quota/usages 与旧 limits/usage
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。 // 均缺失或字段变了)。把原始响应(仅用量数字,无密钥)透出,
// 方便对照接口结构修复。
let preview = serde_json::to_string(&body) let preview = serde_json::to_string(&body)
.unwrap_or_else(|_| "<unserializable body>".into()); .unwrap_or_else(|_| "<unserializable body>".into());
let trimmed: String = preview.chars().take(400).collect(); let trimmed: String = preview.chars().take(400).collect();
@@ -111,6 +127,11 @@ pub async fn query_kimi_coding(
} }
fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> { fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
// 新 quota 模型(kimi-code #3787 / 0.43.1 起服务端下发):优先解析。
if let Some(tiers) = parse_kimi_quota(body) {
return tiers;
}
let mut tiers = Vec::new(); let mut tiers = Vec::new();
// 5 小时窗口限额(优先显示) // 5 小时窗口限额(优先显示)
@@ -130,6 +151,67 @@ fn parse_kimi_coding(body: &serde_json::Value) -> Vec<UsageData> {
tiers tiers
} }
/// 解析 quota 模型的 `usages` 窗口映射(`limit_5h` / `limit_7d` /
/// `limit_month_total` / `limit_month_code`)+ 加力钱包。窗口与钱包都缺失或
/// 无可解析条目时返回 None,让调用方回退旧结构。
fn parse_kimi_quota(body: &serde_json::Value) -> Option<Vec<UsageData>> {
let usages = body.get("usages")?;
let mut tiers = Vec::new();
for (key, tier) in [
("limit_5h", TIER_FIVE_HOUR),
("limit_7d", TIER_WEEKLY_LIMIT),
("limit_month_total", TIER_MONTHLY_LIMIT),
("limit_month_code", TIER_MONTH_CODE),
] {
let Some(entry) = usages.get(key) else { continue };
let Some(ratio) = entry.get("used_ratio").and_then(parse_f64) else {
continue;
};
// used_ratio 规范为 0-1 比率;>1 时视为已是百分数,原样使用。
let used_percent = if ratio <= 1.0 { ratio * 100.0 } else { ratio };
let resets_at = entry.get("reset_time").and_then(extract_reset_time);
tiers.push(percent_tier(tier, used_percent, resets_at));
}
// 加力钱包排在四个窗口之后(钱包是余额,不是滚动窗口)。
if let Some(wallet) = body.get("boosterWallet").and_then(booster_wallet_tier) {
tiers.push(wallet);
}
if tiers.is_empty() { None } else { Some(tiers) }
}
/// 解析加力钱包(boosterWallet)为金额行。字段单位为「分」,统一换算成元/美元。
///
/// 只在响应含新版 `*Cents` 字段时产出条目:kimi-code #3787 之前的
/// `boosterWallet` 只有 `balance`(无币种/精度语义),产出全 0 的伪行只会误导。
fn booster_wallet_tier(wallet: &serde_json::Value) -> Option<UsageData> {
let cents = |key: &str| wallet.get(key).and_then(parse_f64);
let (balance, total, used) = (
cents("balanceCents"),
cents("totalCents"),
cents("monthlyUsedCents"),
);
if balance.is_none() && total.is_none() && used.is_none() {
return None;
}
// 币种缺失时不猜(前端 formatAmount 会退化为纯数字展示)。
let unit = wallet
.get("currency")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Some(UsageData {
plan_name: Some(PLAN_BOOSTER_WALLET.to_string()),
remaining: balance.map(|c| c / 100.0),
total: total.map(|c| c / 100.0),
used: used.map(|c| c / 100.0),
unit,
is_valid: Some(true),
// 钱包没有窗口重置时间。
resets_at: None,
})
}
fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData { fn kimi_limit_tier(name: &str, detail: &serde_json::Value) -> UsageData {
let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0); let limit = detail.get("limit").and_then(parse_f64).unwrap_or(1.0);
let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0); let remaining = detail.get("remaining").and_then(parse_f64).unwrap_or(0.0);
@@ -459,6 +541,97 @@ mod tests {
); );
} }
#[test]
fn kimi_coding_quota_model_four_windows() {
// kimi-code #3787 / 0.43.1 起服务端下发的 quota 模型:used_ratio 为
// 0-1 比率,reset_time 为 ISO 8601 字符串。
let body = json!({
"goods_version": 3,
"usages": {
"limit_5h": { "used_ratio": 0.6, "reset_time": "2026-09-15T17:00:00Z" },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1, "reset_time": "2026-10-01T00:00:00Z" },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": { "balance": 0 }
});
let tiers = parse_kimi_coding(&body);
// 旧版 boosterWallet(只有 balance,无 *Cents)不产出钱包行。
assert_eq!(tiers.len(), 4);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(60.0));
assert_eq!(tiers[0].remaining, Some(40.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(
tiers[0].resets_at.as_deref(),
Some("2026-09-15T17:00:00Z")
);
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(25.0));
assert!(tiers[1].resets_at.is_none());
assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(tiers[2].used, Some(10.0));
assert_eq!(tiers[3].plan_name.as_deref(), Some("month_code"));
assert_eq!(tiers[3].used, Some(88.0));
assert_eq!(tiers[3].remaining, Some(12.0));
}
#[test]
fn kimi_coding_quota_appends_booster_wallet_row() {
// 加力钱包是金额行(分 → 元/美元),追加在四个窗口之后。
let body = json!({
"usages": {
"limit_5h": { "used_ratio": 0.6 },
"limit_7d": { "used_ratio": 0.25 },
"limit_month_total": { "used_ratio": 0.1 },
"limit_month_code": { "used_ratio": 0.88 }
},
"boosterWallet": {
"balanceCents": 1234,
"totalCents": 5000,
"monthlyChargeLimitEnabled": true,
"monthlyChargeLimitCents": 2000,
"monthlyUsedCents": 800,
"currency": "USD"
}
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 5);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
let wallet = &tiers[4];
assert_eq!(wallet.plan_name.as_deref(), Some("booster_wallet"));
assert_eq!(wallet.used, Some(8.0));
assert_eq!(wallet.total, Some(50.0));
assert_eq!(wallet.remaining, Some(12.34));
assert_eq!(wallet.unit.as_deref(), Some("USD"));
assert_eq!(wallet.is_valid, Some(true));
assert!(wallet.resets_at.is_none());
}
#[test]
fn kimi_coding_quota_ratio_above_one_is_percent() {
// 防御:>1 的 used_ratio 视为已是百分数,不再乘 100。
let body = json!({
"usages": { "limit_5h": { "used_ratio": 42.0 } }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].used, Some(42.0));
}
#[test]
fn kimi_coding_quota_missing_windows_falls_back_to_legacy() {
// usages 存在但没有任何可解析条目 → 回退旧结构解析。
let body = json!({
"usages": { "limit_5h": {} },
"usage": { "limit": 1000, "remaining": 900, "resetTime": "2026-08-01T00:00:00Z" }
});
let tiers = parse_kimi_coding(&body);
assert_eq!(tiers.len(), 1);
assert_eq!(tiers[0].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[0].used, Some(10.0));
}
#[test] #[test]
fn kimi_coding_reset_time_seconds_vs_millis() { fn kimi_coding_reset_time_seconds_vs_millis() {
// 秒级时间戳自动 ×1000 // 秒级时间戳自动 ×1000
+2
View File
@@ -10,6 +10,7 @@
pub mod balance; pub mod balance;
pub mod coding_plan; pub mod coding_plan;
pub mod probe;
pub mod usage_types; pub mod usage_types;
pub use usage_types::{UsageData, UsageResult}; pub use usage_types::{UsageData, UsageResult};
@@ -336,6 +337,7 @@ mod tests {
user_id: uid.map(str::to_string), user_id: uid.map(str::to_string),
auto_query_interval_minutes: None, auto_query_interval_minutes: None,
timeout_seconds: None, timeout_seconds: None,
threshold: None,
}; };
// 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误) // 缺 config / 缺任一项 / 空字符串 → None(确定性配置错误)
assert_eq!(newapi_creds(None), None); assert_eq!(newapi_creds(None), None);
+155
View File
@@ -0,0 +1,155 @@
//! 供应商推理端点探活(「一键体检」的第二步)。
//!
//! GET `{base_url}/v1/models`,带 `Authorization: Bearer <api_key>`。
//! 2xx 且响应体是合法 JSON 即算通过(不校验内容结构,只验证端点能应答);
//! 其余状态码 / 网络错误归类成确定性错误文案。
//!
//! 错误文案与 balance.rs 一致用英文短句,前端 localizeHealthError 负责
//! 双语展示(见 src/hooks/useHealthCheck.ts)。
use std::time::Duration;
/// 探活请求超时。体检要快速出结果、不卡住 UI,比账单查询的 8s 更短。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
/// 由 base_url 拼出 `/v1/models` 探测地址。
///
/// 供应商配置里的 base_url 多数已经带 `/v1` 后缀(如
/// `https://api.kimi.com/coding/v1`),直接再拼 `/v1/models` 会得到
/// `/v1/v1/models`,所以先剥掉重复后缀;结尾斜杠与首尾空白一并归一。
/// 空/纯空白返回 None(调用方直接判失败,不发请求)。纯函数,便于单测。
fn models_url(base_url: &str) -> Option<String> {
let base = base_url.trim().trim_end_matches('/');
if base.is_empty() {
return None;
}
let root = base.strip_suffix("/v1").unwrap_or(base);
Some(format!("{root}/v1/models"))
}
/// 探活前置校验:返回最终请求 URL。base_url 缺失、api_key 为空都是确定性
/// 配置错误,不发请求即失败(没有 key 时必然 401,直接给出更可操作的结论)。
fn validate_probe_input(base_url: &str, api_key: &str) -> Result<String, String> {
let Some(url) = models_url(base_url) else {
return Err("missing base_url".to_string());
};
if api_key.trim().is_empty() {
return Err("no API key configured".to_string());
}
Ok(url)
}
/// 探活一个供应商的推理端点。`Ok(())` = 端点正常应答且返回合法 JSON。
pub async fn probe_provider(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<(), String> {
let url = validate_probe_input(base_url, api_key)?;
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("failed to build HTTP client: {e}"))?;
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let resp = client
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {api_key}"))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
"request timed out".to_string()
} else if e.is_connect() {
"connection failed (DNS or refused)".to_string()
} else {
format!("network error: {e}")
}
})?;
let status = resp.status();
if status.is_success() {
// 先取完整响应体再解析:读体超时/中断是瞬时错误,与「返回的不是
// JSON」区分开(reqwest 的 .json() 会把两者都包成 decode)。
let raw = resp
.bytes()
.await
.map_err(|e| format!("failed to read response: {e}"))?;
return match serde_json::from_slice::<serde_json::Value>(&raw) {
Ok(_) => Ok(()),
Err(_) => Err("response is not valid JSON".to_string()),
};
}
match status.as_u16() {
401 | 403 => Err(format!("Authentication failed (HTTP {status})")),
404 => Err(format!("endpoint not found (HTTP {status})")),
code => Err(format!("API error (HTTP {code})")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn models_url_dedupes_v1_suffix() {
assert_eq!(
models_url("https://api.kimi.com/coding/v1").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
// 不带 /v1 的裸域补上 /v1
assert_eq!(
models_url("https://api.deepseek.com").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
}
#[test]
fn models_url_normalizes_slashes_and_whitespace() {
// 结尾斜杠(含多个)与首尾空白都要归一,且不得拼出 /v1/v1
assert_eq!(
models_url("https://api.kimi.com/coding/v1/").as_deref(),
Some("https://api.kimi.com/coding/v1/models")
);
assert_eq!(
models_url(" https://api.deepseek.com/v1/// ").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
assert_eq!(
models_url("https://api.deepseek.com/").as_deref(),
Some("https://api.deepseek.com/v1/models")
);
// 非 /v1 的路径后缀原样保留(只有 /v1 需要去重)
assert_eq!(
models_url("https://api.novita.ai/v3").as_deref(),
Some("https://api.novita.ai/v3/v1/models")
);
}
#[test]
fn models_url_rejects_empty_base() {
assert_eq!(models_url(""), None);
assert_eq!(models_url(" "), None);
assert_eq!(models_url("/"), None);
}
#[test]
fn probe_input_validation_rejects_empty_base_and_missing_key() {
// 两者都是确定性配置错误,不发请求即返回。
assert_eq!(
validate_probe_input(" ", "sk-x").unwrap_err(),
"missing base_url"
);
assert_eq!(
validate_probe_input("https://example.com", " ").unwrap_err(),
"no API key configured"
);
assert_eq!(
validate_probe_input("https://example.com/v1", "sk-x").unwrap(),
"https://example.com/v1/models"
);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.18", "version": "0.7.20",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+27
View File
@@ -78,6 +78,10 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
update({ background: { ...settings.background, ...patch } }); update({ background: { ...settings.background, ...patch } });
}; };
const updateWatch = (patch: Partial<AgentSettings["watch"]>) => {
update({ watch: { ...settings.watch, ...patch } });
};
const setRules = (rules: PermissionRule[]) => { const setRules = (rules: PermissionRule[]) => {
update({ permission: { rules } }); update({ permission: { rules } });
}; };
@@ -141,6 +145,15 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
value={settings.loop_control?.reserved_context_size ?? 50000} value={settings.loop_control?.reserved_context_size ?? 50000}
onChange={(v) => updateLoopControl({ reserved_context_size: v })} onChange={(v) => updateLoopControl({ reserved_context_size: v })}
/> />
{/* kimi-code 0.43.0+ `loop_control.compaction_max_attempts`. The
upstream default is 5; an empty field or 0 removes the key instead
of writing a literal 0 (see setAgentSettings). */}
<NumberField
label={t("compactionMaxAttempts")}
value={settings.loop_control?.compaction_max_attempts ?? 5}
onChange={(v) => updateLoopControl({ compaction_max_attempts: v })}
/>
<p className="text-xs text-content-muted">{t("compactionMaxAttemptsHint")}</p>
</Card> </Card>
<Card title={t("backgroundSettings")}> <Card title={t("backgroundSettings")}>
@@ -156,6 +169,20 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
/> />
</Card> </Card>
<Card title={t("watchSettings")}>
{/* Top-level `[watch] enabled` (kimi-code 2.0.1+; off by default since
2.0.2). KIMI_CODE_WATCH outranks this config at runtime — the env
var is probed by get_experimental_env_status as a non-flag entry
but deliberately locks nothing here, the toggle just documents the
config value that applies when the env var is unset. */}
<Checkbox
label={t("watchEnabled")}
checked={settings.watch?.enabled ?? false}
onChange={(checked) => updateWatch({ enabled: checked })}
/>
<p className="text-xs text-content-muted">{t("watchEnabledDesc")}</p>
</Card>
<Card title={t("permissionRules")}> <Card title={t("permissionRules")}>
{/* kimi-code `[permission] dangerous_command_guard`. The env {/* kimi-code `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false") var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
+61 -1
View File
@@ -11,6 +11,7 @@ import { AgentSettingsPanel } from "./AgentSettingsPanel";
import { KimiOAuthDialog } from "./KimiOAuthDialog"; import { KimiOAuthDialog } from "./KimiOAuthDialog";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { IconPicker } from "./IconPicker"; import { IconPicker } from "./IconPicker";
import { Segmented } from "./ui/controls";
import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types"; import type { Agent, DiscoveredModel, Model, Provider, ProviderType } from "../types";
const KNOWN_CAPABILITIES = [ const KNOWN_CAPABILITIES = [
@@ -127,6 +128,19 @@ export function ProviderEdit({
// 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.
const preset = findPresetForProvider(provider); const preset = findPresetForProvider(provider);
/**
* Credential source. `api_key_env` (kimi-code 2.0.0+) names an environment
* variable instead of storing the key in config.toml; the two are mutually
* exclusive upstream, so the UI is a mode switch and each mode clears the
* other field. Derived from the provider itself (no local state) so the
* switch follows provider switches and reloads; an empty-but-present value
* ("env mode picked, name not typed yet") still reads as env mode.
* Only Kimi Code understands `api_key_env` — Pi's provider file has no
* equivalent, so the switch and the env input are both gated on the agent
* (a Pi provider that somehow carries the key keeps its plain key input).
*/
const apiKeyEnvSupported = agent === "kimi_code";
const apiKeyEnvMode = apiKeyEnvSupported && provider.api_key_env != null;
useEffect(() => { useEffect(() => {
const def = defaultBaseUrl(agent, provider.provider_type); const def = defaultBaseUrl(agent, provider.provider_type);
@@ -322,9 +336,49 @@ export function ProviderEdit({
{!provider.managed && ( {!provider.managed && (
<div> <div>
{apiKeyEnvSupported && (
<div className="flex items-center gap-3 mb-2">
<span className="text-sm text-content-muted">
{t("apiKeySource")}
</span>
<Segmented
options={[
{ key: "direct", label: t("apiKeySourceDirect") },
{ key: "env", label: t("apiKeySourceEnv") },
]}
value={apiKeyEnvMode ? "env" : "direct"}
onChange={(mode) => {
// Mutual exclusion: picking a source clears the
// other one so export never writes both keys.
if (mode === "env") {
onChange({
...provider,
api_key: null,
api_key_env: provider.api_key_env ?? "",
});
} else {
onChange({ ...provider, api_key_env: null });
}
}}
/>
</div>
)}
<label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5"> <label htmlFor={apiKeyId} className="block text-sm text-content-muted mb-1.5">
{t("apiKey")} <span className="text-red-500">*</span> {apiKeyEnvMode ? t("apiKeyEnvName") : t("apiKey")}{" "}
<span className="text-red-500">*</span>
</label> </label>
{apiKeyEnvMode ? (
<input
id={apiKeyId}
type="text"
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={provider.api_key_env ?? ""}
placeholder="KIMI_API_KEY"
onChange={(e) =>
onChange({ ...provider, api_key_env: e.target.value })
}
/>
) : (
<div className="relative"> <div className="relative">
<input <input
id={apiKeyId} id={apiKeyId}
@@ -343,6 +397,12 @@ export function ProviderEdit({
{showApiKey ? t("hide") : t("show")} {showApiKey ? t("hide") : t("show")}
</button> </button>
</div> </div>
)}
{apiKeyEnvMode && (
<p className="mt-1.5 text-xs text-content-muted">
{t("apiKeyEnvHint")}
</p>
)}
{(() => { {(() => {
const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null; const linkUrl = preset?.apiKeyUrl ?? provider.official_url ?? null;
if (!linkUrl) return null; if (!linkUrl) return null;
+114 -4
View File
@@ -1,10 +1,26 @@
import { useState } from "react"; import { useState } from "react";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react"; import {
Pencil,
Copy,
Activity,
Loader2,
Trash2,
BarChart3,
Stethoscope,
} from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { ProviderIcon } from "./ProviderIcon"; import { ProviderIcon } from "./ProviderIcon";
import { UsageFooter } from "./UsageFooter"; import { UsageFooter } from "./UsageFooter";
import { useUsageQuery } from "../hooks/useUsageQuery"; import { useUsageQuery } from "../hooks/useUsageQuery";
import {
useHealthCheck,
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type HealthResult,
} from "../hooks/useHealthCheck";
import type { Agent, Model, Provider } from "../types"; import type { Agent, Model, Provider } from "../types";
interface ConnectivityResult { interface ConnectivityResult {
@@ -40,6 +56,12 @@ interface ProviderCardProps {
models: Record<string, Model>; models: Record<string, Model>;
/** Inline connectivity-test state for this provider, or undefined when idle. */ /** Inline connectivity-test state for this provider, or undefined when idle. */
ts: TestState | undefined; ts: TestState | undefined;
/** 一键体检结论;undefined = 本轮未出结果(检测中或未检测)。 */
health: HealthResult | undefined;
/** 体检请求进行中。 */
healthRunning: boolean;
/** 体检至少跑过一轮(决定是否渲染灰色「未检测」点)。 */
healthRan: boolean;
onEdit: (name: string) => void; onEdit: (name: string) => void;
onDelete: (name: string) => void; onDelete: (name: string) => void;
onDuplicate: (name: string) => void; onDuplicate: (name: string) => void;
@@ -54,6 +76,9 @@ function ProviderCard({
defaultModel, defaultModel,
models, models,
ts, ts,
health,
healthRunning,
healthRan,
onEdit, onEdit,
onDelete, onDelete,
onDuplicate, onDuplicate,
@@ -70,7 +95,8 @@ function ProviderCard({
provider.name, provider.name,
provider.usageKinds, provider.usageKinds,
provider.usageConfig?.autoQueryIntervalMinutes, provider.usageConfig?.autoQueryIntervalMinutes,
provider.usageConfig?.enabled === false provider.usageConfig?.enabled === false,
provider.usageConfig?.threshold
); );
const providerModels = Object.values(models).filter( const providerModels = Object.values(models).filter(
(m) => m.provider === provider.name (m) => m.provider === provider.name
@@ -80,6 +106,17 @@ function ProviderCard({
: null; : null;
const isActive = provider.active === true; const isActive = provider.active === true;
// 一键体检状态点 tooltip:两项检测各自如实展示——探活可能是「不支持模型
// 列表」(中性),账单可能是「失败: xxx」,都不掩盖。
const healthTip = health
? [
health.probe ? probeLine(health.probe, health.latencyMs, t) : null,
health.usage ? usageLine(health.usage, t) : null,
]
.filter((s): s is string => s !== null)
.join(" · ")
: "";
return ( return (
<div <div
className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${ className={`group relative flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer w-full ${
@@ -102,6 +139,34 @@ function ProviderCard({
<h3 className="font-semibold text-content-primary truncate"> <h3 className="font-semibold text-content-primary truncate">
{provider.name} {provider.name}
</h3> </h3>
{/* 一键体检状态点:绿=正常、红=失败、灰=未判定(未检测 / 检测中) */}
{health ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
health.verdict
)}`}
title={healthTip}
aria-label={healthTip}
/>
) : healthRunning ? (
<span
className="shrink-0 flex items-center"
title={t("healthCheckRunning")}
aria-label={t("healthCheckRunning")}
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-content-muted" />
</span>
) : healthRan ? (
<span
role="img"
className={`w-2.5 h-2.5 shrink-0 rounded-full ${healthDotClass(
"neutral"
)}`}
title={t("healthNotChecked")}
aria-label={t("healthNotChecked")}
/>
) : null}
{isActive && ( {isActive && (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30"> <span className="text-xs px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border border-green-300 dark:border-green-500/30">
{t("inUse")} {t("inUse")}
@@ -273,6 +338,7 @@ export function ProviderList({
}: ProviderListProps) { }: ProviderListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [testState, setTestState] = useState<Record<string, TestState>>({}); const [testState, setTestState] = useState<Record<string, TestState>>({});
const health = useHealthCheck(agent);
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。 // managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
const sortedProviders = [...providers].sort( const sortedProviders = [...providers].sort(
@@ -312,8 +378,48 @@ export function ProviderList({
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-border"> <div className="flex items-center justify-between gap-3 px-4 py-3 border-b border-border">
<h2 className="font-medium text-content-primary">{t("providers")}</h2> <div className="flex items-center gap-3 min-w-0">
<h2 className="font-medium text-content-primary shrink-0">
{t("providers")}
</h2>
{/* 体检汇总:检测中 / 请求失败 / x 正常 / y 失败 */}
{(health.running || health.ran || health.error) && (
<span
className={`text-xs tabular-nums truncate ${
health.error ? "text-red-500 dark:text-red-400" : "text-content-muted"
}`}
title={health.error ? localizeHealthError(health.error, t) : undefined}
>
{health.error
? t("healthRequestFailed", {
error: localizeHealthError(health.error, t),
})
: health.running
? t("healthCheckRunning")
: t("healthSummary", {
ok: health.okCount,
fail: health.failCount,
})}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={health.run}
disabled={health.running}
title={t("healthCheckHint")}
aria-label={t("healthCheck")}
className="flex items-center gap-1.5 h-9 px-3 rounded-full border border-border text-sm text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 focus:ring-2 focus:ring-blue-500 focus:outline-none transition-colors whitespace-nowrap"
>
{health.running ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Stethoscope className="w-4 h-4" />
)}
{t("healthCheck")}
</button>
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
@@ -335,6 +441,7 @@ export function ProviderList({
</svg> </svg>
</button> </button>
</div> </div>
</div>
<div className="flex-1 overflow-auto p-4"> <div className="flex-1 overflow-auto p-4">
{providers.length === 0 ? ( {providers.length === 0 ? (
@@ -371,6 +478,9 @@ export function ProviderList({
defaultModel={defaultModel} defaultModel={defaultModel}
models={models} models={models}
ts={testState[provider.name]} ts={testState[provider.name]}
health={health.results[provider.name]}
healthRunning={health.running}
healthRan={health.ran}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
onDuplicate={onDuplicate} onDuplicate={onDuplicate}
+120
View File
@@ -5,12 +5,20 @@ import { listen } from "@tauri-apps/api/event";
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";
import {
useKimiCodeVersion,
isNewerVersion,
type KimiCodeStatus,
} from "../hooks/useKimiCodeVersion";
function formatVersion(version: string): string { function formatVersion(version: string): string {
const v = version.trim(); const v = version.trim();
return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`; return v.startsWith("v") || v.startsWith("V") ? v : `v${v}`;
} }
/** 上游 kimi-code releases 页面。 */
const KIMI_CODE_RELEASES_URL = "https://github.com/MoonshotAI/kimi-code/releases";
interface SettingsModalProps { interface SettingsModalProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -30,6 +38,11 @@ export function SettingsModal({
}: SettingsModalProps) { }: SettingsModalProps) {
const { t, lang, setLang } = useTranslation(); const { t, lang, setLang } = useTranslation();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const {
info: kimiCodeInfo,
checking: kimiCodeChecking,
checkNow: checkKimiCode,
} = useKimiCodeVersion();
const [appVersion, setAppVersion] = useState(""); const [appVersion, setAppVersion] = useState("");
const [downloadProgress, setDownloadProgress] = useState<number | null>(null); const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
const [downloadedPath, setDownloadedPath] = useState<string | null>(null); const [downloadedPath, setDownloadedPath] = useState<string | null>(null);
@@ -158,6 +171,47 @@ export function SettingsModal({
return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US"); return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US");
}; };
// 状态徽标:绿=已适配 / 黄=建议升级 KimiSwitch / 橙=部分兼容 / 灰=未知或未安装
const kimiCodeBadge = (status: KimiCodeStatus) => {
switch (status) {
case "current":
return {
label: t("kimiCodeStatusCurrent"),
className:
"bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400 border-green-300 dark:border-green-500/30",
};
case "outdated":
return {
label: t("kimiCodeStatusOutdated"),
className:
"bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 border-yellow-300 dark:border-yellow-500/30",
};
case "partial":
return {
label: t("kimiCodeStatusPartial"),
className:
"bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-500/30",
};
case "unknown":
return {
label: t("kimiCodeStatusUnknown"),
className: "bg-hover-2 text-content-muted border-border",
};
default:
return {
label: t("kimiCodeStatusNotInstalled"),
className: "bg-hover-2 text-content-muted border-border",
};
}
};
const kimiCodeUpdateAvailable =
!!kimiCodeInfo?.installed &&
!!kimiCodeInfo?.latest &&
isNewerVersion(kimiCodeInfo.latest, kimiCodeInfo.installed);
const kimiCodeBadgeInfo = kimiCodeInfo ? kimiCodeBadge(kimiCodeInfo.status) : null;
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4" className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@@ -362,6 +416,72 @@ export function SettingsModal({
</div> </div>
</section> </section>
{/* Kimi Code CLI version & compatibility */}
<section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
{t("kimiCodeSection")}
</h3>
<div className="space-y-2 rounded-lg border border-border bg-input p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeInstalled")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.installed ? formatVersion(kimiCodeInfo.installed) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeLatest")}</span>
<span className="font-medium text-content-primary tabular-nums">
{kimiCodeInfo?.latest ? formatVersion(kimiCodeInfo.latest) : "—"}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-content-muted">{t("kimiCodeStatusLabel")}</span>
{kimiCodeBadgeInfo ? (
<span
className={`text-xs px-2 py-0.5 rounded-full border ${kimiCodeBadgeInfo.className}`}
>
{kimiCodeBadgeInfo.label}
</span>
) : (
<span className="text-xs text-content-muted">—</span>
)}
</div>
<div className="pt-2 flex items-center gap-2">
<button
type="button"
onClick={checkKimiCode}
disabled={kimiCodeChecking}
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={kimiCodeChecking ? "animate-spin" : ""}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<polyline points="21 3 21 9 15 9" />
</svg>
{kimiCodeChecking ? t("checking") : t("kimiCodeCheck")}
</button>
{kimiCodeUpdateAvailable && (
<button
type="button"
onClick={() => openExternal(KIMI_CODE_RELEASES_URL)}
className="px-3 py-1.5 text-sm rounded border border-border hover:bg-hover-2 transition-colors"
>
{t("kimiCodeOpenReleases")}
</button>
)}
</div>
</div>
</section>
{/* About */} {/* About */}
<section> <section>
<h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted"> <h3 className="mb-3 text-[11px] uppercase tracking-wider text-content-muted">
-4
View File
@@ -48,10 +48,6 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" }, notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" }, tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" }, subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
}; };
/** Validation-error i18n key per engine rule, for the pre-write self-check. */ /** Validation-error i18n key per engine rule, for the pre-write self-check. */
+26 -1
View File
@@ -364,7 +364,7 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
)} )}
</div> </div>
{/* timeout + auto query interval */} {/* timeout + auto query interval + alert threshold */}
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6"> <div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label> <label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
@@ -394,6 +394,31 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
/> />
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p> <p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
</div> </div>
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageThreshold")} %</label>
<input
type="number"
min={0}
max={100}
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
value={cfg.threshold ?? ""}
onChange={(e) => {
const raw = e.target.value.trim();
// 清空输入框写回 undefined(关闭),而不是 0:两者判定等价,
// 但 undefined 会被 serde 的 skip_serializing_if 从 SQLite
// JSON 里省掉,不留冗余字段。
if (raw === "") {
setCfg({ ...cfg, threshold: undefined });
return;
}
const n = parseInt(raw, 10);
// 非数字(如 "1e" / "-")不落库,保留上一次的有效值。
if (Number.isNaN(n)) return;
setCfg({ ...cfg, threshold: Math.min(100, Math.max(0, n)) });
}}
/>
<p className="mt-1 text-xs text-content-muted">{t("usageThresholdHint")}</p>
</div>
</div> </div>
</> </>
)} )}
+38 -11
View File
@@ -1,7 +1,13 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Loader2, RefreshCw } from "lucide-react"; import { Loader2, RefreshCw } from "lucide-react";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import { formatAmount, localizeUsageError, planLabel } from "../lib/usage-display"; import {
BOOSTER_WALLET_PLAN,
formatAmount,
localizeUsageError,
planLabel,
} from "../lib/usage-display";
import { isAlertTier } from "../hooks/useUsageQuery";
import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery"; import type { UsageData, UsageQueryState } from "../hooks/useUsageQuery";
interface UsageFooterProps { interface UsageFooterProps {
@@ -14,6 +20,20 @@ interface UsageFooterProps {
variant?: "detail" | "compact"; variant?: "detail" | "compact";
} }
/**
* 金额行 vs 配额行:NewAPI 与 Kimi 加力钱包(booster_wallet)只报金额,其
* used/total 是钱数而不是配额比例,必须走 formatAmount 渲染路径。判定只能看
* planName —— sub2api 的 USD 行 unit 同样不是 "%",但语义仍是「额度用了百分之
* 多少」,按 unit 判定会把它误渲染成余额。
*/
function isMoneyRow(d: UsageData): boolean {
return d.planName === "NewAPI" || d.planName === BOOSTER_WALLET_PLAN;
}
function isPlanRow(d: UsageData): boolean {
return !!d.planName && !isMoneyRow(d) && (d.total != null || d.used != null);
}
export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) { export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { status, data, error, updatedAt, supported, refresh } = usage; const { status, data, error, updatedAt, supported, refresh } = usage;
@@ -85,10 +105,7 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
// A "plan" is a quota tier (used/total, e.g. 5h / weekly limit). Balance // 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 / // entries carry only remaining + unit — their plan_name is a currency /
// brand label and must render as an amount, not a percentage. // brand label and must render as an amount, not a percentage.
const isPlan = const isPlan = isPlanRow(d);
!!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" };
} }
@@ -207,13 +224,14 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
{data.map((d, i) => { {data.map((d, i) => {
// Same plan-vs-balance rule as compactSummary: balance rows have no // Same plan-vs-balance rule as compactSummary: balance rows have no
// quota fields (total/used), so they render as an amount. // quota fields (total/used), so they render as an amount.
const isPlan = const isPlan = isPlanRow(d);
!!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 = // 预警高亮:usage.alert 已按「查询成功」门控,失败时保留的上次数据
pct == null // 不会被误标红(此处是唯一的高亮来源,随每次查询结果重算)。
const rowAlert = usage.alert && isAlertTier(d, usage.threshold);
const color = rowAlert
? "text-red-500 dark:text-red-400"
: pct == null
? "text-content-muted" ? "text-content-muted"
: pct < 70 : pct < 70
? "text-green-600 dark:text-green-400" ? "text-green-600 dark:text-green-400"
@@ -248,6 +266,15 @@ export function UsageFooter({ usage, variant = "detail" }: UsageFooterProps) {
> >
{main} {main}
</span> </span>
{rowAlert && (
<span
className="shrink-0 text-red-500 dark:text-red-400"
title={t("usageThresholdAlert")}
aria-label={t("usageThresholdAlert")}
>
⚠
</span>
)}
{resetText && ( {resetText && (
<span className="text-content-muted shrink-0">{resetText}</span> <span className="text-content-muted shrink-0">{resetText}</span>
)} )}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import {
localizeHealthError,
probeLine,
usageLine,
healthDotClass,
type CheckResult,
} from "./useHealthCheck";
/**
* 只回显 key,便于断言选中的是哪条文案(真实插值由 i18n Provider 负责)。
*/
const t = ((key: string) => `<${key}>`) as unknown as Parameters<
typeof localizeHealthError
>[1];
/** 带插值的假 t:`<key|{a}=1>`,用于断言参数透传。 */
const tWithVars = ((key: string, vars?: Record<string, string | number>) =>
vars
? `<${key}|${Object.entries(vars)
.map(([k, v]) => `${k}=${v}`)
.join(",")}>`
: `<${key}>`) as unknown as Parameters<typeof localizeHealthError>[1];
describe("localizeHealthError", () => {
it("探活错误按前缀映射到对应文案", () => {
expect(localizeHealthError("missing base_url", t)).toBe(
"<healthErrNoBaseUrl>"
);
expect(localizeHealthError("no API key configured", t)).toBe(
"<healthErrNoKey>"
);
expect(localizeHealthError("Authentication failed (HTTP 401)", t)).toBe(
"<healthErrAuth>"
);
expect(localizeHealthError("endpoint not found (HTTP 404)", t)).toBe(
"<healthErrNotFound>"
);
expect(localizeHealthError("request timed out", t)).toBe(
"<healthErrTimeout>"
);
expect(
localizeHealthError("connection failed (DNS or refused)", t)
).toBe("<healthErrUnreachable>");
expect(localizeHealthError("response is not valid JSON", t)).toBe(
"<healthErrBadJson>"
);
});
it("未知错误原样透出(HTTP 状态码等诊断细节)", () => {
expect(localizeHealthError("API error (HTTP 502)", t)).toBe(
"API error (HTTP 502)"
);
});
it("探活 + 账单两段拼接时逐段映射", () => {
expect(
localizeHealthError(
"endpoint not found (HTTP 404); usage query disabled in config panel",
t
)
).toBe("<healthErrNotFound>; <usageErrDisabled>");
});
it("账单链路的报错复用用量错误映射", () => {
// commands.rs 的 kinds 循环会把单条错误包成 `{kind}: {msg}`。
expect(
localizeHealthError(
"Authentication failed (HTTP 401); balance:deepseek: Authentication failed (HTTP 401)",
t
)
).toBe("<healthErrAuth>; <usageInvalidKey>");
});
});
describe("probeLine", () => {
it("通过时带延迟", () => {
expect(probeLine({ state: "pass" }, 123, tWithVars)).toBe(
"<healthProbeOk|latency=123>"
);
});
it("中性(404)说明端点不支持模型列表,而不是报失败", () => {
expect(
probeLine(
{ state: "neutral", error: "endpoint not found (HTTP 404)" },
0,
tWithVars
)
).toBe("<healthProbeUnsupported>");
});
it("失败时透出本地化后的原因", () => {
expect(
probeLine({ state: "fail", error: "request timed out" }, 0, tWithVars)
).toBe("<healthProbeFail|error=<healthErrTimeout>>");
});
});
describe("usageLine", () => {
it("通过 / 失败 / 兜底三态", () => {
const pass: CheckResult = { state: "pass" };
expect(usageLine(pass, tWithVars)).toBe("<healthUsageOk>");
expect(
usageLine(
{ state: "fail", error: "Authentication failed (HTTP 401)" },
tWithVars
)
).toBe("<healthUsageFail|error=<healthErrAuth>>");
// 账单链路不会产生中性结论,兜底按「未判定」展示
expect(usageLine({ state: "neutral" }, tWithVars)).toBe(
"<healthNotChecked>"
);
});
});
describe("healthDotClass", () => {
it("绿=通过、红=失败、灰=未判定(中性)", () => {
expect(healthDotClass("pass")).toBe("bg-green-500");
expect(healthDotClass("fail")).toBe("bg-red-500");
expect(healthDotClass("neutral")).toBe("bg-gray-400 dark:bg-gray-500");
});
});
+175
View File
@@ -0,0 +1,175 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { Agent } from "../types";
import type { TranslationKey } from "../i18n/zh";
import { localizeUsageError } from "../lib/usage-display";
type TranslateFn = (
key: TranslationKey,
vars?: Record<string, string | number>
) => string;
/** 单项检测的三态结论(mirrors Rust `HealthCheck`)。 */
export type HealthCheck = "pass" | "fail" | "neutral";
/** 单项检测结论(探活 / 账单各一份,mirrors Rust `CheckResult`)。 */
export interface CheckResult {
state: HealthCheck;
/** 失败 / 中性原因的原始文案;通过为 null。 */
error?: string | null;
}
/** Mirrors the Rust `HealthResult` (camelCase via serde). */
export interface HealthResult {
providerName: string;
/** 总体判定(Rust 侧是唯一事实来源):pass=绿 / fail=红 / neutral=灰。 */
verdict: HealthCheck;
/** verdict === "pass" 的便捷布尔。 */
ok: boolean;
/** 失败原因汇总(仅 fail 项,`; ` 分隔);无失败为 null。 */
error?: string | null;
/** 账单/用量结论;null/undefined = 未执行。 */
usage?: CheckResult | null;
/** 推理端点探活结论;探活是必做项,恒为 Some。 */
probe?: CheckResult | null;
/** 探活耗时(毫秒)。 */
latencyMs: number;
}
/**
* 单条 Rust 错误文案 → 本地化文案。未知错误原样透出(HTTP 状态码等诊断
* 细节)。前缀与 src-tauri/src/services/probe.rs、commands.rs 保持一致。
*/
function localizeHealthPart(error: string, t: TranslateFn): string {
if (error.startsWith("missing base_url")) return t("healthErrNoBaseUrl");
if (error.startsWith("no API key configured")) return t("healthErrNoKey");
if (error.startsWith("Authentication failed")) return t("healthErrAuth");
if (error.startsWith("endpoint not found")) return t("healthErrNotFound");
if (error.startsWith("request timed out")) return t("healthErrTimeout");
if (error.startsWith("connection failed")) return t("healthErrUnreachable");
if (error.startsWith("response is not valid JSON")) return t("healthErrBadJson");
// 账单链路的报错复用已有的用量错误映射(禁用 / OAuth 过期 / NewAPI 凭据…)。
return localizeUsageError(error, t);
}
/**
* 体检结论里的错误文案本地化。探活与账单两条链路的错误由 Rust 用 `; `
* 拼进同一个字段,逐段映射后再拼回,保证两段都能翻译。
*/
export function localizeHealthError(error: string, t: TranslateFn): string {
return error
.split("; ")
.map((part) => localizeHealthPart(part, t))
.join("; ");
}
/** 探活一项的 tooltip 文案。 */
export function probeLine(
probe: CheckResult,
latencyMs: number,
t: TranslateFn
): string {
if (probe.state === "pass") return t("healthProbeOk", { latency: latencyMs });
// 中性 = 端点未实现 /v1/models(404),如实说明「不支持」而不是报失败。
if (probe.state === "neutral") return t("healthProbeUnsupported");
return t("healthProbeFail", {
error: probe.error ? localizeHealthError(probe.error, t) : "",
});
}
/** 账单一项的 tooltip 文案。 */
export function usageLine(usage: CheckResult, t: TranslateFn): string {
if (usage.state === "pass") return t("healthUsageOk");
if (usage.state === "fail") {
return t("healthUsageFail", {
error: usage.error ? localizeHealthError(usage.error, t) : "",
});
}
// 账单链路不产生中性结论(只有探活 404 会),兜底按「未判定」展示。
return t("healthNotChecked");
}
/**
* 状态点颜色:绿=正常、红=失败、灰=未判定(未检测 / 检测中 / 全部中性)。
* 判定本身由 Rust 的 overall_verdict 给出,这里只做映射。
*/
export function healthDotClass(verdict: HealthCheck): string {
switch (verdict) {
case "pass":
return "bg-green-500";
case "fail":
return "bg-red-500";
default:
return "bg-gray-400 dark:bg-gray-500";
}
}
export interface HealthCheckState {
/** 体检进行中:按钮转圈、尚未出结果的行显示灰色检测中指示。 */
running: boolean;
/** 至少跑过一轮(决定是否渲染状态点)。 */
ran: boolean;
/** provider name → 结果。 */
results: Record<string, HealthResult>;
okCount: number;
failCount: number;
/** 整体请求失败(配置读取失败 / 总超时)的文案;null = 无错误。 */
error: string | null;
run: () => void;
}
/**
* 一键体检:invoke `health_check_all`,把结果按 provider 名索引。
* 结果保留到下次点击或组件卸载(页面刷新),不在后台轮询。
*/
export function useHealthCheck(agent: Agent): HealthCheckState {
const [running, setRunning] = useState(false);
const [ran, setRan] = useState(false);
const [results, setResults] = useState<Record<string, HealthResult>>({});
const [error, setError] = useState<string | null>(null);
// Generation counter: 重跑/卸载后到达的旧响应直接丢弃,不写回状态。
const genRef = useRef(0);
useEffect(
() => () => {
genRef.current += 1;
},
[]
);
const run = useCallback(async () => {
const gen = ++genRef.current;
setRunning(true);
setError(null);
// 清空上一轮结果:检测中的行显示灰色转圈,而不是留着旧结论误导。
setResults({});
try {
const list = await invoke<HealthResult[]>("health_check_all", { agent });
if (genRef.current !== gen) return;
const map: Record<string, HealthResult> = {};
for (const r of list) map[r.providerName] = r;
setResults(map);
} catch (e) {
if (genRef.current !== gen) return;
// invoke 拒绝时 Rust 侧 Err(String) 是字符串,不是 Error 实例。
setError(typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
} finally {
if (genRef.current === gen) {
setRunning(false);
setRan(true);
}
}
}, [agent]);
const values = Object.values(results);
return {
running,
ran,
results,
// 中性(灰)既不算正常也不算失败,不计入汇总。
okCount: values.filter((r) => r.verdict === "pass").length,
failCount: values.filter((r) => r.verdict === "fail").length,
error,
run: () => void run(),
};
}
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { isNewerVersion } from "./useKimiCodeVersion";
describe("isNewerVersion", () => {
it("同版本不算有更新", () => {
expect(isNewerVersion("2.0.2", "2.0.2")).toBe(false);
expect(isNewerVersion("v2.0.2", "2.0.2")).toBe(false);
});
it("按 major/minor/patch 逐段比较", () => {
expect(isNewerVersion("2.0.2", "2.0.1")).toBe(true);
expect(isNewerVersion("2.1.0", "2.0.9")).toBe(true);
expect(isNewerVersion("3.0.0", "2.99.99")).toBe(true);
expect(isNewerVersion("2.0.1", "2.0.2")).toBe(false);
expect(isNewerVersion("1.9.9", "2.0.0")).toBe(false);
});
it("忽略 v 前缀与预发布后缀", () => {
expect(isNewerVersion("v0.44.0-beta.1", "0.43.1")).toBe(true);
expect(isNewerVersion("0.43.1", "0.43.1-rc.2")).toBe(false);
});
it("缺位补 0,无法解析时不显示更新", () => {
expect(isNewerVersion("2.1", "2.0.9")).toBe(true);
expect(isNewerVersion("abc", "2.0.1")).toBe(false);
expect(isNewerVersion("2.0.2", "abc")).toBe(false);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
/** kimi-code 与本应用的适配状态(Rust 侧基线表决定)。 */
export type KimiCodeStatus =
| "current"
| "outdated"
| "partial"
| "unknown"
| "not_installed";
export interface KimiCodeVersionInfo {
/** 本机安装的 kimi-code 版本;未安装 / 检测失败为 null。 */
installed: string | null;
/** 上游最新 release tag(已去掉 v 前缀);查询失败为 null。 */
latest: string | null;
status: KimiCodeStatus;
}
const THROTTLE_KEY = "kimi-switch-last-kc-version-check";
// 与 useUpdateCheck 一致:节流窗口与定时复查同为 8h,长时间挂着的窗口也能
// 自动拿到新版本,同时避免短时间反复触发后端调用。
const CHECK_INTERVAL_MS = 8 * 60 * 60 * 1000; // 8 hours
/** 解析 major.minor.patch 三元组;缺位补 0,无法解析返回 null。 */
function versionTriple(version: string): [number, number, number] | null {
const core = version.trim().replace(/^[vV]/, "").split(/[-+]/)[0];
const nums = core.split(".").slice(0, 3).map((p) => Number.parseInt(p.trim(), 10));
if (nums.length === 0 || nums.some((n) => !Number.isFinite(n) || n < 0)) return null;
while (nums.length < 3) nums.push(0);
return [nums[0], nums[1], nums[2]];
}
/** latest 是否严格高于 installed(用于决定是否显示 releases 链接)。 */
export function isNewerVersion(latest: string, installed: string): boolean {
const l = versionTriple(latest);
const i = versionTriple(installed);
if (!l || !i) return false;
for (let k = 0; k < 3; k += 1) {
if (l[k] !== i[k]) return l[k] > i[k];
}
return false;
}
/**
* 读取「本机 kimi-code 版本 / 上游最新版 / 适配状态」,模式与 useUpdateCheck
* 相同:挂载时按 8h 节流自动查一次,之后定时复查,checkNow 强制刷新。
*/
export function useKimiCodeVersion() {
const [info, setInfo] = useState<KimiCodeVersionInfo | null>(null);
const [checking, setChecking] = useState(false);
const [lastChecked, setLastChecked] = useState<number | null>(() => {
try {
const v = localStorage.getItem(THROTTLE_KEY);
return v ? Number(v) : null;
} catch {
return null;
}
});
const doCheck = useCallback(
async (force: boolean) => {
if (!force) {
if (lastChecked && Date.now() - lastChecked < CHECK_INTERVAL_MS) return;
}
setChecking(true);
try {
const next = await invoke<KimiCodeVersionInfo>("get_kimi_code_version_info");
setInfo(next);
const now = Date.now();
setLastChecked(now);
try {
localStorage.setItem(THROTTLE_KEY, String(now));
} catch {
// ignore
}
} catch {
// 静默失败:检测不到 kimi / 网络不通都不该打扰用户,手动检查仍可用。
} finally {
setChecking(false);
}
},
[lastChecked]
);
// 定时器不随 lastChecked 重建,所以用 ref 持有最新的 doCheck。
const doCheckRef = useRef(doCheck);
useEffect(() => {
doCheckRef.current = doCheck;
}, [doCheck]);
useEffect(() => {
doCheckRef.current(false);
const interval = setInterval(() => doCheckRef.current(false), CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
const checkNow = useCallback(() => doCheck(true), [doCheck]);
return { info, checking, checkNow, lastChecked };
}
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { isAlertTier, type UsageData } from "./useUsageQuery";
/** 百分比窗口行(five_hour 等);套餐类 tier 的 unit 恒为 "%"。 */
function percentTier(used: number): UsageData {
return { planName: "five_hour", used, total: 100, remaining: 100 - used, unit: "%" };
}
describe("isAlertTier", () => {
it("阈值缺省或 0(关闭)时不告警", () => {
expect(isAlertTier(percentTier(100), undefined)).toBe(false);
expect(isAlertTier(percentTier(100), null)).toBe(false);
expect(isAlertTier(percentTier(100), 0)).toBe(false);
});
it("低于阈值不告警", () => {
expect(isAlertTier(percentTier(79), 80)).toBe(false);
expect(isAlertTier(percentTier(79.9), 80)).toBe(false);
});
it("等于或超过阈值告警", () => {
expect(isAlertTier(percentTier(80), 80)).toBe(true);
expect(isAlertTier(percentTier(80.5), 80)).toBe(true);
expect(isAlertTier(percentTier(100), 80)).toBe(true);
});
it("非 % unit 的行不告警(金额行的 used 是钱数)", () => {
// Kimi 加力钱包:used = monthlyUsedCents/100,与百分比阈值不可比。
expect(
isAlertTier(
{ planName: "booster_wallet", used: 8, total: 50, remaining: 12.34, unit: "USD" },
5
)
).toBe(false);
// sub2api 的 USD 配额行同理,不参与百分比预警。
expect(
isAlertTier({ planName: "daily_limit", used: 90, total: 100, unit: "USD" }, 50)
).toBe(false);
});
it("used 缺失时不告警", () => {
expect(isAlertTier({ planName: "five_hour", unit: "%", remaining: 20 }, 50)).toBe(false);
});
});
+30 -1
View File
@@ -56,6 +56,23 @@ export interface UsageQueryState {
updatedAt: number | null; updatedAt: number | null;
refresh: (force: boolean) => void; refresh: (force: boolean) => void;
supported: boolean; supported: boolean;
/** 生效的预警阈值(0/缺失已归一为 undefined);footer 用它判定单行高亮。 */
threshold?: number;
/** 查询成功且任一百分比行 used ≥ threshold。失败/加载中恒为 false, */
/** 所以失败时保留的上次数据不会被误高亮。 */
alert: boolean;
}
/**
* 单行是否触发预警。只判定百分比行(unit === "%"):余额/钱包等金额行的
* used 是钱数,与百分比阈值不可比。threshold 缺失或 ≤0(关闭)时恒为 false。
*/
export function isAlertTier(
d: UsageData,
threshold?: number | null
): boolean {
if (threshold == null || threshold <= 0) return false;
return d.unit === "%" && d.used != null && d.used >= threshold;
} }
export function useUsageQuery( export function useUsageQuery(
@@ -63,7 +80,8 @@ export function useUsageQuery(
providerName: string, providerName: string,
usageKinds?: string[], usageKinds?: string[],
autoIntervalMinutes?: number, autoIntervalMinutes?: number,
disabled?: boolean disabled?: boolean,
threshold?: number
): UsageQueryState { ): UsageQueryState {
const supported = (usageKinds?.length ?? 0) > 0; const supported = (usageKinds?.length ?? 0) > 0;
// Cache key includes the agent so a Kimi Code provider and a Pi provider // Cache key includes the agent so a Kimi Code provider and a Pi provider
@@ -175,6 +193,15 @@ export function useUsageQuery(
return () => clearInterval(id); return () => clearInterval(id);
}, [supported, autoIntervalMinutes, runQuery, disabled]); }, [supported, autoIntervalMinutes, runQuery, disabled]);
// 阈值 0/负数/非有限值一律视为关闭(与配置面板「0 或空 = 关闭」一致)。
// 每次查询结果写入 data 后此处重新求值,因此刷新后预警自动出现/消除。
const alertThreshold =
typeof threshold === "number" && Number.isFinite(threshold) && threshold > 0
? threshold
: undefined;
const alert =
status === "success" && data.some((d) => isAlertTier(d, alertThreshold));
return { return {
status, status,
data, data,
@@ -182,5 +209,7 @@ export function useUsageQuery(
updatedAt, updatedAt,
refresh: runQuery, refresh: runQuery,
supported, supported,
threshold: alertThreshold,
alert,
}; };
} }
+50 -2
View File
@@ -43,6 +43,26 @@ export const enTranslations: Record<TranslationKey, string> = {
connectivityOk: "{name} reachable ({latency}ms)", connectivityOk: "{name} reachable ({latency}ms)",
connectivitySlow: "{name} reachable but slow ({latency}ms)", connectivitySlow: "{name} reachable but slow ({latency}ms)",
connectivityFail: "{name} unreachable: {error}", connectivityFail: "{name} unreachable: {error}",
// Health check (ProviderList toolbar button + per-row status dot)
healthCheck: "Health check",
healthCheckHint:
"Probe every enabled provider's inference endpoint and billing query in parallel",
healthCheckRunning: "Checking…",
healthSummary: "{ok} OK / {fail} failed",
healthProbeOk: "Endpoint OK ({latency}ms)",
healthProbeUnsupported: "Endpoint exposes no model list (404)",
healthProbeFail: "Endpoint failed: {error}",
healthUsageOk: "Billing OK",
healthUsageFail: "Billing failed: {error}",
healthNotChecked: "Not checked",
healthRequestFailed: "Health check request failed: {error}",
healthErrNoBaseUrl: "Missing base URL",
healthErrNoKey: "No API key configured",
healthErrAuth: "Authentication failed",
healthErrNotFound: "Endpoint not found",
healthErrTimeout: "Connection timed out",
healthErrUnreachable: "Cannot connect (DNS failure or connection refused)",
healthErrBadJson: "Response is not valid JSON",
copiedProvider: "Duplicated as {name}", copiedProvider: "Duplicated as {name}",
// Provider edit // Provider edit
@@ -78,6 +98,12 @@ export const enTranslations: Record<TranslationKey, string> = {
authField: "Auth Field", authField: "Auth Field",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "Get API Key", getApiKeyLink: "Get API Key",
apiKeySource: "Credential source",
apiKeySourceDirect: "Enter key",
apiKeySourceEnv: "Environment variable",
apiKeyEnvName: "Variable name",
apiKeyEnvHint:
"Only the variable name (api_key_env) is written; Kimi Code reads the secret from its environment. The two sources are mutually exclusive, so switching clears the other.",
show: "Show", show: "Show",
hide: "Hide", hide: "Hide",
requestUrl: "Request URL", requestUrl: "Request URL",
@@ -142,9 +168,16 @@ export const enTranslations: Record<TranslationKey, string> = {
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step", maxAttemptsPerStep: "Max attempts per step",
reservedContextSize: "Reserved context size", reservedContextSize: "Reserved context size",
compactionMaxAttempts: "Max compaction attempts",
compactionMaxAttemptsHint:
"Retry limit after a failed context compaction. Empty or 0 leaves the key out of the config (upstream default 5).",
backgroundSettings: "Background Tasks", backgroundSettings: "Background Tasks",
maxRunningTasks: "Max running tasks", maxRunningTasks: "Max running tasks",
keepAliveOnExit: "Keep alive on exit", keepAliveOnExit: "Keep alive on exit",
watchSettings: "File Watching",
watchEnabled: "Enable file watching",
watchEnabledDesc:
"Watch config files and the workspace for changes (configurable since kimi-code 2.0.1, off by default since 2.0.2). The KIMI_CODE_WATCH environment variable overrides this setting.",
permissionRules: "Permission Rules", permissionRules: "Permission Rules",
permissionDecision: "Decision", permissionDecision: "Decision",
permissionPattern: "Pattern", permissionPattern: "Pattern",
@@ -283,6 +316,18 @@ export const enTranslations: Record<TranslationKey, string> = {
install: "Install", install: "Install",
updateCheckFailed: "Update check failed", updateCheckFailed: "Update check failed",
lastChecked: "Last checked", lastChecked: "Last checked",
// Kimi Code version tracking
kimiCodeSection: "Kimi Code version",
kimiCodeInstalled: "Installed",
kimiCodeLatest: "Latest",
kimiCodeStatusLabel: "Compatibility",
kimiCodeStatusCurrent: "Supported",
kimiCodeStatusOutdated: "Update KimiSwitch recommended",
kimiCodeStatusPartial: "Partially compatible",
kimiCodeStatusUnknown: "Unknown version",
kimiCodeStatusNotInstalled: "Not installed",
kimiCodeCheck: "Check",
kimiCodeOpenReleases: "Open releases",
appDescription: "Windows desktop LLM provider config manager", appDescription: "Windows desktop LLM provider config manager",
settingsWebsite: "Official website", settingsWebsite: "Official website",
kimiEyesSite: "Kimi Eyes plugin site", kimiEyesSite: "Kimi Eyes plugin site",
@@ -449,6 +494,11 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTierDaily: "Daily", usageTierDaily: "Daily",
usageTierWeekly: "Weekly", usageTierWeekly: "Weekly",
usageTierMonthly: "Monthly", usageTierMonthly: "Monthly",
usageTierMonthCode: "Monthly (code)",
usageTierBoosterWallet: "Booster",
usageThreshold: "Alert threshold",
usageThresholdHint: "Highlight any window that reaches this percentage; 0 = off",
usageThresholdAlert: "Threshold reached",
usageErrNoKey: "No API key configured", usageErrNoKey: "No API key configured",
usageErrDisabled: "Usage query is disabled in the config panel", usageErrDisabled: "Usage query is disabled in the config panel",
usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again", usageErrLoginExpired: "Kimi Code login expired and auto-refresh failed; run `kimi login` again",
@@ -490,8 +540,6 @@ export const enTranslations: Record<TranslationKey, string> = {
flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)", flagSubagentForkDesc: "Agent/AgentSwarm tools can start a subagent with a snapshot of the calling agent's history (fork parameter)",
flagWaitFor: "WaitFor Tool", flagWaitFor: "WaitFor Tool",
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn", flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
flagNotifyUser: "NotifyUser tool", flagNotifyUser: "NotifyUser tool",
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)", flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
flagDefaultOn: "On by default", flagDefaultOn: "On by default",
+48 -2
View File
@@ -41,6 +41,25 @@ export const zhTranslations = {
connectivityOk: "{name} 连通正常({latency}ms)", connectivityOk: "{name} 连通正常({latency}ms)",
connectivitySlow: "{name} 连通较慢({latency}ms)", connectivitySlow: "{name} 连通较慢({latency}ms)",
connectivityFail: "{name} 无法连通:{error}", connectivityFail: "{name} 无法连通:{error}",
// 一键体检(ProviderList 工具栏按钮 + 行内状态点)
healthCheck: "一键体检",
healthCheckHint: "并行检测所有启用供应商的推理端点与账单查询",
healthCheckRunning: "体检中…",
healthSummary: "{ok} 正常 / {fail} 失败",
healthProbeOk: "端点正常({latency}ms)",
healthProbeUnsupported: "端点不支持模型列表(404)",
healthProbeFail: "端点失败:{error}",
healthUsageOk: "账单正常",
healthUsageFail: "账单失败:{error}",
healthNotChecked: "未检测",
healthRequestFailed: "体检请求失败:{error}",
healthErrNoBaseUrl: "缺少 base_url",
healthErrNoKey: "未配置 API Key",
healthErrAuth: "鉴权失败",
healthErrNotFound: "端点不存在",
healthErrTimeout: "连接超时",
healthErrUnreachable: "无法连接(DNS 解析失败或连接被拒绝)",
healthErrBadJson: "响应不是合法 JSON",
copiedProvider: "已复制为 {name}", copiedProvider: "已复制为 {name}",
// Provider edit // Provider edit
@@ -76,6 +95,12 @@ export const zhTranslations = {
authField: "认证字段", authField: "认证字段",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "获取 API Key", getApiKeyLink: "获取 API Key",
apiKeySource: "凭证来源",
apiKeySourceDirect: "直接填写",
apiKeySourceEnv: "环境变量",
apiKeyEnvName: "环境变量名",
apiKeyEnvHint:
"只写入变量名(api_key_env),密钥由 Kimi Code 从运行环境读取;两者互斥,切换后会清空另一种。",
show: "显示", show: "显示",
hide: "隐藏", hide: "隐藏",
requestUrl: "请求地址", requestUrl: "请求地址",
@@ -140,9 +165,15 @@ export const zhTranslations = {
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数", maxAttemptsPerStep: "单步最大尝试次数",
reservedContextSize: "上下文预留大小", reservedContextSize: "上下文预留大小",
compactionMaxAttempts: "压缩最大尝试次数",
compactionMaxAttemptsHint: "上下文压缩失败后的重试上限,留空或 0 表示不写入该项(上游默认 5)。",
backgroundSettings: "后台任务", backgroundSettings: "后台任务",
maxRunningTasks: "最大并发数", maxRunningTasks: "最大并发数",
keepAliveOnExit: "退出时保持运行", keepAliveOnExit: "退出时保持运行",
watchSettings: "文件监听",
watchEnabled: "启用文件监听",
watchEnabledDesc:
"监听配置文件与工作目录变化(kimi-code 2.0.1 起可配置,2.0.2 起默认关闭)。环境变量 KIMI_CODE_WATCH 会覆盖本配置。",
permissionRules: "权限规则", permissionRules: "权限规则",
permissionDecision: "处置", permissionDecision: "处置",
permissionPattern: "模式", permissionPattern: "模式",
@@ -277,6 +308,18 @@ export const zhTranslations = {
install: "安装", install: "安装",
updateCheckFailed: "检查更新失败", updateCheckFailed: "检查更新失败",
lastChecked: "上次检查", lastChecked: "上次检查",
// Kimi Code 版本跟踪
kimiCodeSection: "Kimi Code 版本",
kimiCodeInstalled: "已安装版本",
kimiCodeLatest: "最新版本",
kimiCodeStatusLabel: "适配状态",
kimiCodeStatusCurrent: "已适配",
kimiCodeStatusOutdated: "建议升级 KimiSwitch",
kimiCodeStatusPartial: "部分兼容",
kimiCodeStatusUnknown: "未知版本",
kimiCodeStatusNotInstalled: "未安装",
kimiCodeCheck: "检查",
kimiCodeOpenReleases: "打开 Releases",
appDescription: "Windows 桌面端 LLM 供应商配置管理器", appDescription: "Windows 桌面端 LLM 供应商配置管理器",
settingsWebsite: "官方网站", settingsWebsite: "官方网站",
kimiEyesSite: "Kimi Eyes 插件站", kimiEyesSite: "Kimi Eyes 插件站",
@@ -443,6 +486,11 @@ export const zhTranslations = {
usageTierDaily: "每日", usageTierDaily: "每日",
usageTierWeekly: "7天", usageTierWeekly: "7天",
usageTierMonthly: "30天", usageTierMonthly: "30天",
usageTierMonthCode: "30天(代码)",
usageTierBoosterWallet: "加力钱包",
usageThreshold: "预警阈值",
usageThresholdHint: "任一用量窗口达到该百分比时高亮提醒,0 为关闭",
usageThresholdAlert: "已达预警阈值",
usageErrNoKey: "未配置 API Key", usageErrNoKey: "未配置 API Key",
usageErrDisabled: "用量查询已在配置面板中停用", usageErrDisabled: "用量查询已在配置面板中停用",
usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`", usageErrLoginExpired: "Kimi Code 登录已过期且自动续期失败,请重新运行 `kimi login`",
@@ -484,8 +532,6 @@ export const zhTranslations = {
flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)", flagSubagentForkDesc: "Agent/AgentSwarm 工具可携带主代理会话快照启动子代理(fork 参数)",
flagWaitFor: "WaitFor 工具", flagWaitFor: "WaitFor 工具",
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒", flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagNotifyUser: "NotifyUser 工具", flagNotifyUser: "NotifyUser 工具",
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)", flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
flagDefaultOn: "默认开启", flagDefaultOn: "默认开启",
+96 -2
View File
@@ -134,10 +134,104 @@ describe("loop_control — v1/v2 key handling", () => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means // loop_control.compaction_max_attempts (kimi-code 0.43.0) — read via the loop
// upstream default ON; an explicit false must survive even with no rules // spread, written only when set, and 0/empty removes the key (upstream
// default 5 applies)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("loop_control.compaction_max_attempts", () => {
it("reads an existing value through the loop spread", () => {
expect(
getAgentSettings({ loop_control: { compaction_max_attempts: 7 } })
.loop_control?.compaction_max_attempts
).toBe(7);
});
it("reads as undefined when the key is absent (UI falls back to 5)", () => {
expect(
getAgentSettings({ loop_control: {} }).loop_control?.compaction_max_attempts
).toBeUndefined();
});
it("writes the value when the patch sets it", () => {
const next = setAgentSettings({}, {
loop_control: { compaction_max_attempts: 8 },
});
expect(loopOf(next).compaction_max_attempts).toBe(8);
});
it("drops the key for 0 and for a cleared (undefined) field", () => {
const raw = { loop_control: { compaction_max_attempts: 6 } };
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: 0 },
}))).not.toHaveProperty("compaction_max_attempts");
expect(loopOf(setAgentSettings(raw, {
loop_control: { compaction_max_attempts: undefined },
}))).not.toHaveProperty("compaction_max_attempts");
});
it("does not materialize the upstream default on an unrelated save", () => {
// An absent key must stay absent — a plain save must not pin `5`.
const next = setAgentSettings({ thinking: { effort: "high" } }, {});
expect(loopOf(next)).not.toHaveProperty("compaction_max_attempts");
// An existing value survives a save that does not touch it.
const kept = setAgentSettings(
{ loop_control: { compaction_max_attempts: 4 } },
{ thinking: { effort: "low" } }
);
expect(loopOf(kept).compaction_max_attempts).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [watch] enabled (kimi-code 2.0.1+) — top-level section, off by default since
// 2.0.2; mirrors the [background] handling
// ---------------------------------------------------------------------------
function watchOf(raw: unknown): Record<string, unknown> {
const w = (raw as { watch?: unknown })?.watch;
return w && typeof w === "object" && !Array.isArray(w)
? (w as Record<string, unknown>)
: {};
}
describe("[watch] enabled", () => {
it("reads as false when the section or key is absent (2.0.2 default)", () => {
expect(getAgentSettings({}).watch?.enabled).toBe(false);
expect(getAgentSettings({ watch: {} }).watch?.enabled).toBe(false);
});
it("reads an explicit value", () => {
expect(getAgentSettings({ watch: { enabled: true } }).watch?.enabled).toBe(true);
expect(getAgentSettings({ watch: { enabled: false } }).watch?.enabled).toBe(false);
});
it("writes the top-level [watch] section", () => {
const next = setAgentSettings({}, { watch: { enabled: true } }) as {
watch?: Record<string, unknown>;
};
expect(next.watch).toEqual({ enabled: true });
});
it("keeps the section on saves that do not touch it", () => {
const next = setAgentSettings(
{ watch: { enabled: true } },
{ thinking: { effort: "high" } }
) as { watch?: Record<string, unknown> };
expect(next.watch).toEqual({ enabled: true });
});
it("does not disturb the other managed sections", () => {
const next = setAgentSettings(
{ background: { max_running_tasks: 2 } },
{ watch: { enabled: true } }
) as { background?: Record<string, unknown> };
expect(next.background?.max_running_tasks).toBe(2);
expect(watchOf(next).enabled).toBe(true);
});
});
describe("permission.dangerous_command_guard", () => { describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => { it("reads as undefined when the key is absent (default on)", () => {
expect( expect(
+17
View File
@@ -13,6 +13,11 @@ const DEFAULT_SETTINGS: AgentSettings = {
background: { background: {
keep_alive_on_exit: false, keep_alive_on_exit: false,
}, },
// `[watch] enabled` — kimi-code 2.0.1 added the key, 2.0.2 flipped the
// default to off, so an absent key reads as "off".
watch: {
enabled: false,
},
permission: { rules: [] }, permission: { rules: [] },
hooks: [], hooks: [],
}; };
@@ -76,6 +81,10 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
...DEFAULT_SETTINGS.background, ...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"), ...getSection<AgentSettings["background"]>(rawOther, "background"),
}, },
watch: {
...DEFAULT_SETTINGS.watch,
...getSection<AgentSettings["watch"]>(rawOther, "watch"),
},
permission: { permission: {
rules: sectionPermission?.rules ?? [], rules: sectionPermission?.rules ?? [],
// `dangerous_command_guard` stays undefined unless the config carries // `dangerous_command_guard` stays undefined unless the config carries
@@ -111,6 +120,7 @@ export function setAgentSettings(
thinking: { ...current.thinking, ...patch.thinking }, thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control }, loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background }, background: { ...current.background, ...patch.background },
watch: { ...current.watch, ...patch.watch },
permission, permission,
hooks: patch.hooks ?? current.hooks ?? [], hooks: patch.hooks ?? current.hooks ?? [],
}; };
@@ -129,9 +139,16 @@ export function setAgentSettings(
if (!opts?.legacyV1) { if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step; delete next.loop_control?.max_retries_per_step;
} }
// `compaction_max_attempts` (kimi-code 0.43.0+): 0 / undefined means "leave
// the key out" — the config then falls back to the upstream default (5)
// instead of pinning a literal 0.
if (!next.loop_control?.compaction_max_attempts) {
delete next.loop_control?.compaction_max_attempts;
}
if (next.thinking) root.thinking = next.thinking; if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control; if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background; if (next.background) root.background = next.background;
if (next.watch) root.watch = next.watch;
// Keep `[permission]` when it carries rules *or* an explicit // Keep `[permission]` when it carries rules *or* an explicit
// dangerous_command_guard — dropping the section would silently lose a // dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules // guard=false write (absent key = upstream default on). An empty rules
+173 -96
View File
@@ -1,5 +1,5 @@
{ {
"last_updated": "2026-09-13", "last_updated": "2026-09-14",
"providers": { "providers": {
"subconscious": { "subconscious": {
"id": "subconscious", "id": "subconscious",
@@ -16470,6 +16470,20 @@
"output_limit": 32768, "output_limit": 32768,
"image": true "image": true
}, },
{
"id": "alibaba/deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash (Alibaba Cloud)",
"cost": {
"input": 0.3,
"output": 1.2,
"cache_read": 0.03
},
"context": 1000000,
"output_limit": 393216,
"reasoning": true,
"tool_call": true,
"image": true
},
{ {
"id": "alibaba/qwen-coder-plus", "id": "alibaba/qwen-coder-plus",
"name": "Qwen Coder Plus (Alibaba Cloud)", "name": "Qwen Coder Plus (Alibaba Cloud)",
@@ -16730,7 +16744,7 @@
"id": "scx-ai-gp/glm-5.2", "id": "scx-ai-gp/glm-5.2",
"name": "GLM-5.2 (SCX.ai)", "name": "GLM-5.2 (SCX.ai)",
"cost": { "cost": {
"input": 0.8, "input": 0.88,
"output": 2.55, "output": 2.55,
"cache_read": 0.16 "cache_read": 0.16
}, },
@@ -18921,6 +18935,17 @@
"reasoning": true, "reasoning": true,
"tool_call": true "tool_call": true
}, },
{
"id": "consensusprotocol/deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash (Consensus Protocol)",
"cost": {
"input": 0.2,
"output": 0.6,
"cache_read": 0.005
},
"context": 1048576,
"output_limit": 384000
},
{ {
"id": "consensusprotocol/glm-5.3-flash", "id": "consensusprotocol/glm-5.3-flash",
"name": "GLM-5.3 Flash (Consensus Protocol)", "name": "GLM-5.3 Flash (Consensus Protocol)",
@@ -38111,9 +38136,9 @@
"id": "gemma-4-26b-a4b-it", "id": "gemma-4-26b-a4b-it",
"name": "Gemma 4 26B A4B IT", "name": "Gemma 4 26B A4B IT",
"cost": { "cost": {
"input": 0.102, "input": 0.1,
"output": 0.356, "output": 0.374,
"cache_read": 0.051 "cache_read": 0.05
}, },
"context": 256000, "context": 256000,
"output_limit": 25600, "output_limit": 25600,
@@ -38129,7 +38154,7 @@
"output": 4.311648, "output": 4.311648,
"cache_read": 0.047907 "cache_read": 0.047907
}, },
"context": 1048576, "context": 1000000,
"output_limit": 262144, "output_limit": 262144,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
@@ -38195,9 +38220,9 @@
"id": "minimax-m2.7", "id": "minimax-m2.7",
"name": "MiniMax-M2.7", "name": "MiniMax-M2.7",
"cost": { "cost": {
"input": 0.396, "input": 0.462,
"output": 1.464, "output": 1.728,
"cache_read": 0.198 "cache_read": 0.231
}, },
"context": 262100, "context": 262100,
"output_limit": 6553, "output_limit": 6553,
@@ -38265,7 +38290,7 @@
"cost": { "cost": {
"input": 0.2, "input": 0.2,
"output": 0.4, "output": 0.4,
"cache_read": 0.04 "cache_write": 0.04
}, },
"context": 1000000, "context": 1000000,
"output_limit": 384000, "output_limit": 384000,
@@ -38281,7 +38306,7 @@
"output": 4.3552, "output": 4.3552,
"cache_read": 0.206872 "cache_read": 0.206872
}, },
"context": 262000, "context": 256000,
"output_limit": 16000, "output_limit": 16000,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
@@ -38305,12 +38330,12 @@
"id": "deepseek-v4.1-flash", "id": "deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash", "name": "DeepSeek V4.1 Flash",
"cost": { "cost": {
"input": 0.3, "input": 0.32664,
"output": 1.2, "output": 1.30656,
"cache_read": 0.03 "cache_read": 0.032664
}, },
"context": 1048576, "context": 1000000,
"output_limit": 26214, "output_limit": 13107,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -38425,9 +38450,9 @@
"id": "glm-5", "id": "glm-5",
"name": "GLM-5", "name": "GLM-5",
"cost": { "cost": {
"input": 0.86, "input": 0.94,
"output": 2.752, "output": 3.008,
"cache_read": 0.43 "cache_read": 0.47
}, },
"context": 202752, "context": 202752,
"output_limit": 20275, "output_limit": 20275,
@@ -38452,9 +38477,9 @@
"id": "kimi-k2.5", "id": "kimi-k2.5",
"name": "Kimi K2.5", "name": "Kimi K2.5",
"cost": { "cost": {
"input": 0.5584, "input": 0.5344,
"output": 2.935, "output": 2.815,
"cache_read": 0.2792 "cache_read": 0.2672
}, },
"context": 262144, "context": 262144,
"output_limit": 26214, "output_limit": 26214,
@@ -38467,7 +38492,7 @@
"name": "GLM-5.1", "name": "GLM-5.1",
"cost": { "cost": {
"input": 1.318, "input": 1.318,
"output": 4.308, "output": 4.268,
"cache_read": 0.659 "cache_read": 0.659
}, },
"context": 202750, "context": 202750,
@@ -38496,7 +38521,7 @@
"cost": { "cost": {
"input": 2.4, "input": 2.4,
"output": 4.8, "output": 4.8,
"cache_read": 0.2 "cache_write": 0.2
}, },
"context": 1000000, "context": 1000000,
"output_limit": 384000, "output_limit": 384000,
@@ -38508,9 +38533,9 @@
"id": "gpt-oss-120b", "id": "gpt-oss-120b",
"name": "GPT OSS 120B", "name": "GPT OSS 120B",
"cost": { "cost": {
"input": 0.168, "input": 0.178,
"output": 0.66, "output": 0.68,
"cache_read": 0.084 "cache_read": 0.089
}, },
"context": 128072, "context": 128072,
"output_limit": 13107, "output_limit": 13107,
@@ -65693,6 +65718,21 @@
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
}, },
{
"id": "Qwen/Qwen3.8-Flash",
"name": "Qwen3.8 Flash",
"cost": {
"input": 0.113,
"output": 0.382,
"cache_read": 0.0141
},
"context": 1000000,
"output_limit": 131072,
"tool_call": true,
"structured_output": true,
"image": true,
"video": true
},
{ {
"id": "Qwen/Qwen3-VL-235B-A22B-Instruct", "id": "Qwen/Qwen3-VL-235B-A22B-Instruct",
"name": "Qwen3 VL 235B A22B Instruct", "name": "Qwen3 VL 235B A22B Instruct",
@@ -66211,8 +66251,8 @@
"input": 0.2275, "input": 0.2275,
"output": 0.91 "output": 0.91
}, },
"context": 131072, "context": 40960,
"output_limit": 8192, "output_limit": 16384,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -66976,12 +67016,12 @@
"id": "~deepseek/deepseek-v4-flash-latest", "id": "~deepseek/deepseek-v4-flash-latest",
"name": "DeepSeek: DeepSeek V4 Flash Latest", "name": "DeepSeek: DeepSeek V4 Flash Latest",
"cost": { "cost": {
"input": 0.0352, "input": 0.04,
"output": 0.1056, "output": 0.1,
"cache_read": 0.00112 "cache_read": 0.01
}, },
"context": 1048576, "context": 1048576,
"output_limit": 131072, "output_limit": 393216,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -67591,7 +67631,7 @@
"output": 0.45 "output": 0.45
}, },
"context": 262144, "context": 262144,
"output_limit": 16384, "output_limit": 235929,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -67641,8 +67681,8 @@
"output": 2.2, "output": 2.2,
"cache_read": 0.1 "cache_read": 0.1
}, },
"context": 256000, "context": 202800,
"output_limit": 32768, "output_limit": 182520,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -68842,7 +68882,8 @@
"name": "MoonshotAI: Kimi Latest", "name": "MoonshotAI: Kimi Latest",
"cost": { "cost": {
"input": 2.1, "input": 2.1,
"output": 10.95 "output": 10.95,
"cache_read": 0.23
}, },
"context": 1048576, "context": 1048576,
"output_limit": 943718, "output_limit": 943718,
@@ -70605,12 +70646,12 @@
"id": "~z-ai/glm-latest", "id": "~z-ai/glm-latest",
"name": "Z.ai: GLM Latest", "name": "Z.ai: GLM Latest",
"cost": { "cost": {
"input": 0.936, "input": 0.92,
"output": 3.168, "output": 3.1372,
"cache_read": 0.1872 "cache_read": 0.184
}, },
"context": 262144, "context": 1048576,
"output_limit": 235929, "output_limit": 943718,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -70718,7 +70759,8 @@
"name": "Inference.net: Schematron V2 Small", "name": "Inference.net: Schematron V2 Small",
"cost": { "cost": {
"input": 0.05, "input": 0.05,
"output": 0.23 "output": 0.23,
"cache_read": 0.05
}, },
"context": 128000, "context": 128000,
"output_limit": 4096, "output_limit": 4096,
@@ -70729,7 +70771,8 @@
"name": "Inference.net: Schematron V2 Turbo", "name": "Inference.net: Schematron V2 Turbo",
"cost": { "cost": {
"input": 0.03, "input": 0.03,
"output": 0.15 "output": 0.15,
"cache_read": 0.03
}, },
"context": 128000, "context": 128000,
"output_limit": 8192, "output_limit": 8192,
@@ -70997,8 +71040,8 @@
"output": 4.4, "output": 4.4,
"cache_read": 0.26 "cache_read": 0.26
}, },
"context": 202752, "context": 1048576,
"output_limit": 182476, "output_limit": 131072,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -71095,8 +71138,8 @@
"output": 4.4, "output": 4.4,
"cache_read": 0.26 "cache_read": 0.26
}, },
"context": 1048576, "context": 1048575,
"output_limit": 131072, "output_limit": 943717,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -81488,11 +81531,11 @@
"id": "qwen/qwen3-14b", "id": "qwen/qwen3-14b",
"name": "Qwen3 14B", "name": "Qwen3 14B",
"cost": { "cost": {
"input": 0.2275, "input": 0.12,
"output": 0.91 "output": 0.24
}, },
"context": 131072, "context": 131072,
"output_limit": 8192, "output_limit": 16384,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -82261,12 +82304,12 @@
"id": "~deepseek/deepseek-v4-flash-latest", "id": "~deepseek/deepseek-v4-flash-latest",
"name": "DeepSeek V4 Flash Latest", "name": "DeepSeek V4 Flash Latest",
"cost": { "cost": {
"input": 0.0352, "input": 0.04,
"output": 0.1056, "output": 0.1,
"cache_read": 0.00112 "cache_read": 0.01
}, },
"context": 1310720, "context": 1310720,
"output_limit": 131072, "output_limit": 393216,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -82859,11 +82902,11 @@
"id": "nvidia/nemotron-3-super-120b-a12b", "id": "nvidia/nemotron-3-super-120b-a12b",
"name": "Nemotron 3 Super 120B A12B", "name": "Nemotron 3 Super 120B A12B",
"cost": { "cost": {
"input": 0.085, "input": 0.08,
"output": 0.4 "output": 0.45
}, },
"context": 262144, "context": 262144,
"output_limit": 16384, "output_limit": 235929,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -82909,12 +82952,12 @@
"id": "nvidia/nemotron-3-ultra-550b-a55b", "id": "nvidia/nemotron-3-ultra-550b-a55b",
"name": "Nemotron 3 Ultra 550B A55B", "name": "Nemotron 3 Ultra 550B A55B",
"cost": { "cost": {
"input": 0.625, "input": 0.6,
"output": 3.125, "output": 2.4,
"cache_read": 0.1875 "cache_read": 0.12
}, },
"context": 262144, "context": 262144,
"output_limit": 32768, "output_limit": 182520,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -84157,7 +84200,8 @@
"name": "Kimi Latest", "name": "Kimi Latest",
"cost": { "cost": {
"input": 2.1, "input": 2.1,
"output": 10.95 "output": 10.95,
"cache_read": 0.23
}, },
"context": 1048576, "context": 1048576,
"output_limit": 943718, "output_limit": 943718,
@@ -84224,9 +84268,9 @@
"id": "deepseek/deepseek-v4-pro-0813", "id": "deepseek/deepseek-v4-pro-0813",
"name": "DeepSeek V4 Pro 0813", "name": "DeepSeek V4 Pro 0813",
"cost": { "cost": {
"input": 0.57948, "input": 0.9834,
"output": 1.73844, "output": 2.9502,
"cache_read": 0.019316 "cache_read": 0.03278
}, },
"context": 1048576, "context": 1048576,
"output_limit": 384000, "output_limit": 384000,
@@ -84238,9 +84282,9 @@
"id": "deepseek/deepseek-v4-flash-0731", "id": "deepseek/deepseek-v4-flash-0731",
"name": "DeepSeek V4 Flash 0731", "name": "DeepSeek V4 Flash 0731",
"cost": { "cost": {
"input": 0.04, "input": 0.06,
"output": 0.08, "output": 0.12,
"cache_read": 0.008 "cache_read": 0.012
}, },
"context": 1310720, "context": 1310720,
"output_limit": 943718, "output_limit": 943718,
@@ -84252,9 +84296,9 @@
"id": "deepseek/deepseek-v4-flash", "id": "deepseek/deepseek-v4-flash",
"name": "DeepSeek V4 Flash", "name": "DeepSeek V4 Flash",
"cost": { "cost": {
"input": 0.04788, "input": 0.08554,
"output": 0.09576, "output": 0.17108,
"cache_read": 0.009576 "cache_read": 0.017108
}, },
"context": 1048576, "context": 1048576,
"output_limit": 384000, "output_limit": 384000,
@@ -85829,12 +85873,12 @@
"id": "~z-ai/glm-latest", "id": "~z-ai/glm-latest",
"name": "GLM Latest", "name": "GLM Latest",
"cost": { "cost": {
"input": 0.936, "input": 0.92,
"output": 3.168, "output": 3.1372,
"cache_read": 0.1872 "cache_read": 0.184
}, },
"context": 1310720, "context": 1310720,
"output_limit": 235929, "output_limit": 943718,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -85942,7 +85986,8 @@
"name": "Schematron V2 Small", "name": "Schematron V2 Small",
"cost": { "cost": {
"input": 0.05, "input": 0.05,
"output": 0.23 "output": 0.23,
"cache_read": 0.05
}, },
"context": 128000, "context": 128000,
"output_limit": 4096, "output_limit": 4096,
@@ -85953,7 +85998,8 @@
"name": "Schematron V2 Turbo", "name": "Schematron V2 Turbo",
"cost": { "cost": {
"input": 0.03, "input": 0.03,
"output": 0.15 "output": 0.15,
"cache_read": 0.03
}, },
"context": 128000, "context": 128000,
"output_limit": 8192, "output_limit": 8192,
@@ -86062,9 +86108,9 @@
"id": "tencent/hy3", "id": "tencent/hy3",
"name": "Hy3", "name": "Hy3",
"cost": { "cost": {
"input": 0.0825, "input": 0.132,
"output": 0.33, "output": 0.528,
"cache_read": 0.020625 "cache_read": 0.033
}, },
"context": 262144, "context": 262144,
"input_limit": 192000, "input_limit": 192000,
@@ -86217,12 +86263,12 @@
"id": "z-ai/glm-5.2", "id": "z-ai/glm-5.2",
"name": "GLM-5.2", "name": "GLM-5.2",
"cost": { "cost": {
"input": 0.6, "input": 0.6832,
"output": 2, "output": 2.1472,
"cache_read": 0.15 "cache_read": 0.12688
}, },
"context": 1048576, "context": 1048576,
"output_limit": 182476, "output_limit": 131072,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -86231,9 +86277,9 @@
"id": "z-ai/glm-5.3-flash", "id": "z-ai/glm-5.3-flash",
"name": "GLM-5.3-Flash", "name": "GLM-5.3-Flash",
"cost": { "cost": {
"input": 0.075, "input": 0.15,
"output": 0.25, "output": 0.5,
"cache_read": 0.015 "cache_read": 0.03
}, },
"context": 1310720, "context": 1310720,
"output_limit": 131072, "output_limit": 131072,
@@ -86315,12 +86361,12 @@
"id": "z-ai/glm-5.3", "id": "z-ai/glm-5.3",
"name": "GLM-5.3", "name": "GLM-5.3",
"cost": { "cost": {
"input": 1.092, "input": 1.4,
"output": 3.432, "output": 4.4,
"cache_read": 0.2028 "cache_read": 0.26
}, },
"context": 1310720, "context": 1310720,
"output_limit": 131072, "output_limit": 943717,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true "structured_output": true
@@ -102924,8 +102970,9 @@
"id": "qwen/deepseek-v4-pro-0813", "id": "qwen/deepseek-v4-pro-0813",
"name": "DeepSeek V4 Pro 0813 (Alibaba)", "name": "DeepSeek V4 Pro 0813 (Alibaba)",
"cost": { "cost": {
"input": 0.5808, "input": 0.66,
"output": 1.7424 "output": 1.98,
"cache_read": 0.066
}, },
"context": 1000000, "context": 1000000,
"output_limit": 384000, "output_limit": 384000,
@@ -102937,8 +102984,9 @@
"id": "qwen/deepseek-v4-flash-0731", "id": "qwen/deepseek-v4-flash-0731",
"name": "DeepSeek V4 Flash 0731 (Alibaba)", "name": "DeepSeek V4 Flash 0731 (Alibaba)",
"cost": { "cost": {
"input": 0.176, "input": 0.22,
"output": 0.528 "output": 0.66,
"cache_read": 0.022
}, },
"context": 1000000, "context": 1000000,
"output_limit": 384000, "output_limit": 384000,
@@ -103080,6 +103128,21 @@
"output_limit": 8192, "output_limit": 8192,
"reasoning": true "reasoning": true
}, },
{
"id": "qwen/deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash (Alibaba)",
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.015
},
"context": 1000000,
"output_limit": 384000,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"image": true
},
{ {
"id": "qwen/qwen3-coder-next", "id": "qwen/qwen3-coder-next",
"name": "Qwen3 Coder Next", "name": "Qwen3 Coder Next",
@@ -104143,6 +104206,20 @@
"reasoning": true, "reasoning": true,
"tool_call": true "tool_call": true
}, },
{
"id": "tensorx/deepseek/deepseek-v4.1-flash",
"name": "DeepSeek V4.1 Flash (TensorX)",
"cost": {
"input": 0.5,
"output": 1.5,
"cache_read": 0.125
},
"context": 1048576,
"output_limit": 384000,
"reasoning": true,
"tool_call": true,
"image": true
},
{ {
"id": "tensorx/moonshotai/kimi-k2.5", "id": "tensorx/moonshotai/kimi-k2.5",
"name": "Kimi K2.5 (TensorX)", "name": "Kimi K2.5 (TensorX)",
+148 -81
View File
@@ -1,5 +1,5 @@
{ {
"last_updated": "2026-09-13", "last_updated": "2026-09-14",
"subconscious/subconscious/glm-5.2": { "subconscious/subconscious/glm-5.2": {
"name": "GLM-5.2", "name": "GLM-5.2",
"context": 1000000, "context": 1000000,
@@ -13257,6 +13257,18 @@
"cache_write": 0.25 "cache_write": 0.25
} }
}, },
"llmgateway-providers/alibaba/deepseek-v4.1-flash": {
"name": "DeepSeek V4.1 Flash (Alibaba Cloud)",
"context": 1000000,
"reasoning": true,
"tool_call": true,
"image": true,
"cost": {
"input": 0.3,
"output": 1.2,
"cache_read": 0.03
}
},
"llmgateway-providers/alibaba/qwen-coder-plus": { "llmgateway-providers/alibaba/qwen-coder-plus": {
"name": "Qwen Coder Plus (Alibaba Cloud)", "name": "Qwen Coder Plus (Alibaba Cloud)",
"context": 131072, "context": 131072,
@@ -13482,7 +13494,7 @@
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"cost": { "cost": {
"input": 0.8, "input": 0.88,
"output": 2.55, "output": 2.55,
"cache_read": 0.16 "cache_read": 0.16
} }
@@ -15353,6 +15365,15 @@
"cache_read": 0.01 "cache_read": 0.01
} }
}, },
"llmgateway-providers/consensusprotocol/deepseek-v4.1-flash": {
"name": "DeepSeek V4.1 Flash (Consensus Protocol)",
"context": 1048576,
"cost": {
"input": 0.2,
"output": 0.6,
"cache_read": 0.005
}
},
"llmgateway-providers/consensusprotocol/glm-5.3-flash": { "llmgateway-providers/consensusprotocol/glm-5.3-flash": {
"name": "GLM-5.3 Flash (Consensus Protocol)", "name": "GLM-5.3 Flash (Consensus Protocol)",
"context": 1048576, "context": 1048576,
@@ -31345,14 +31366,14 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.102, "input": 0.1,
"output": 0.356, "output": 0.374,
"cache_read": 0.051 "cache_read": 0.05
} }
}, },
"hyper/deepseek-v4-pro-0813": { "hyper/deepseek-v4-pro-0813": {
"name": "DeepSeek V4 Pro 0813", "name": "DeepSeek V4 Pro 0813",
"context": 1048576, "context": 1000000,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -31416,9 +31437,9 @@
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"cost": { "cost": {
"input": 0.396, "input": 0.462,
"output": 1.464, "output": 1.728,
"cache_read": 0.198 "cache_read": 0.231
} }
}, },
"hyper/kimi-k2.6": { "hyper/kimi-k2.6": {
@@ -31477,12 +31498,12 @@
"cost": { "cost": {
"input": 0.2, "input": 0.2,
"output": 0.4, "output": 0.4,
"cache_read": 0.04 "cache_write": 0.04
} }
}, },
"hyper/kimi-k2.7-code": { "hyper/kimi-k2.7-code": {
"name": "Kimi K2.7 Code", "name": "Kimi K2.7 Code",
"context": 262000, "context": 256000,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -31506,15 +31527,15 @@
}, },
"hyper/deepseek-v4.1-flash": { "hyper/deepseek-v4.1-flash": {
"name": "DeepSeek V4.1 Flash", "name": "DeepSeek V4.1 Flash",
"context": 1048576, "context": 1000000,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"image": true, "image": true,
"cost": { "cost": {
"input": 0.3, "input": 0.32664,
"output": 1.2, "output": 1.30656,
"cache_read": 0.03 "cache_read": 0.032664
} }
}, },
"hyper/qwen3.7-flash": { "hyper/qwen3.7-flash": {
@@ -31613,9 +31634,9 @@
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"cost": { "cost": {
"input": 0.86, "input": 0.94,
"output": 2.752, "output": 3.008,
"cache_read": 0.43 "cache_read": 0.47
} }
}, },
"hyper/qwen3.8-max": { "hyper/qwen3.8-max": {
@@ -31637,9 +31658,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.5584, "input": 0.5344,
"output": 2.935, "output": 2.815,
"cache_read": 0.2792 "cache_read": 0.2672
} }
}, },
"hyper/glm-5.1": { "hyper/glm-5.1": {
@@ -31650,7 +31671,7 @@
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 1.318, "input": 1.318,
"output": 4.308, "output": 4.268,
"cache_read": 0.659 "cache_read": 0.659
} }
}, },
@@ -31675,7 +31696,7 @@
"cost": { "cost": {
"input": 2.4, "input": 2.4,
"output": 4.8, "output": 4.8,
"cache_read": 0.2 "cache_write": 0.2
} }
}, },
"hyper/gpt-oss-120b": { "hyper/gpt-oss-120b": {
@@ -31685,9 +31706,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.168, "input": 0.178,
"output": 0.66, "output": 0.68,
"cache_read": 0.084 "cache_read": 0.089
} }
}, },
"hyper/glm-5.3": { "hyper/glm-5.3": {
@@ -54422,6 +54443,19 @@
"output": 0.55 "output": 0.55
} }
}, },
"deepinfra/Qwen/Qwen3.8-Flash": {
"name": "Qwen3.8 Flash",
"context": 1000000,
"tool_call": true,
"structured_output": true,
"image": true,
"video": true,
"cost": {
"input": 0.113,
"output": 0.382,
"cache_read": 0.0141
}
},
"deepinfra/Qwen/Qwen3-VL-235B-A22B-Instruct": { "deepinfra/Qwen/Qwen3-VL-235B-A22B-Instruct": {
"name": "Qwen3 VL 235B A22B Instruct", "name": "Qwen3 VL 235B A22B Instruct",
"context": 262144, "context": 262144,
@@ -54852,7 +54886,7 @@
}, },
"kilo/qwen/qwen3-14b": { "kilo/qwen/qwen3-14b": {
"name": "Qwen: Qwen3 14B", "name": "Qwen: Qwen3 14B",
"context": 131072, "context": 40960,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -55512,9 +55546,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.0352, "input": 0.04,
"output": 0.1056, "output": 0.1,
"cache_read": 0.00112 "cache_read": 0.01
} }
}, },
"kilo/dots-studio/dots-3-note-preview:free": { "kilo/dots-studio/dots-3-note-preview:free": {
@@ -56067,7 +56101,7 @@
}, },
"kilo/nvidia/nemotron-3-ultra-550b-a55b": { "kilo/nvidia/nemotron-3-ultra-550b-a55b": {
"name": "Nemotron 3 Ultra 550B A55B", "name": "Nemotron 3 Ultra 550B A55B",
"context": 256000, "context": 202800,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -57115,7 +57149,8 @@
"video": true, "video": true,
"cost": { "cost": {
"input": 2.1, "input": 2.1,
"output": 10.95 "output": 10.95,
"cache_read": 0.23
} }
}, },
"kilo/ibm-granite/granite-4.2-8b": { "kilo/ibm-granite/granite-4.2-8b": {
@@ -58592,14 +58627,14 @@
}, },
"kilo/~z-ai/glm-latest": { "kilo/~z-ai/glm-latest": {
"name": "Z.ai: GLM Latest", "name": "Z.ai: GLM Latest",
"context": 262144, "context": 1048576,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.936, "input": 0.92,
"output": 3.168, "output": 3.1372,
"cache_read": 0.1872 "cache_read": 0.184
} }
}, },
"kilo/moonshotai/kimi-k2-0905": { "kilo/moonshotai/kimi-k2-0905": {
@@ -58692,7 +58727,8 @@
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.05, "input": 0.05,
"output": 0.23 "output": 0.23,
"cache_read": 0.05
} }
}, },
"kilo/inference-net/schematron-v2-turbo": { "kilo/inference-net/schematron-v2-turbo": {
@@ -58701,7 +58737,8 @@
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.03, "input": 0.03,
"output": 0.15 "output": 0.15,
"cache_read": 0.03
} }
}, },
"kilo/cohere/north-mini-code:free": { "kilo/cohere/north-mini-code:free": {
@@ -58919,7 +58956,7 @@
}, },
"kilo/z-ai/glm-5.2": { "kilo/z-ai/glm-5.2": {
"name": "GLM-5.2", "name": "GLM-5.2",
"context": 202752, "context": 1048576,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -59003,7 +59040,7 @@
}, },
"kilo/z-ai/glm-5.3": { "kilo/z-ai/glm-5.3": {
"name": "GLM-5.3", "name": "GLM-5.3",
"context": 1048576, "context": 1048575,
"reasoning": true, "reasoning": true,
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
@@ -67739,8 +67776,8 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.2275, "input": 0.12,
"output": 0.91 "output": 0.24
} }
}, },
"openrouter/qwen/qwen3.6-plus": { "openrouter/qwen/qwen3.6-plus": {
@@ -68399,9 +68436,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.0352, "input": 0.04,
"output": 0.1056, "output": 0.1,
"cache_read": 0.00112 "cache_read": 0.01
} }
}, },
"openrouter/dots-studio/dots-3-note-preview:free": { "openrouter/dots-studio/dots-3-note-preview:free": {
@@ -68906,8 +68943,8 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.085, "input": 0.08,
"output": 0.4 "output": 0.45
} }
}, },
"openrouter/nvidia/nemotron-3-ultra-550b-a55b:free": { "openrouter/nvidia/nemotron-3-ultra-550b-a55b:free": {
@@ -68948,9 +68985,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.625, "input": 0.6,
"output": 3.125, "output": 2.4,
"cache_read": 0.1875 "cache_read": 0.12
} }
}, },
"openrouter/nvidia/nemotron-3-nano-30b-a3b": { "openrouter/nvidia/nemotron-3-nano-30b-a3b": {
@@ -70028,7 +70065,8 @@
"video": true, "video": true,
"cost": { "cost": {
"input": 2.1, "input": 2.1,
"output": 10.95 "output": 10.95,
"cache_read": 0.23
} }
}, },
"openrouter/ibm-granite/granite-4.2-8b": { "openrouter/ibm-granite/granite-4.2-8b": {
@@ -70083,9 +70121,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.57948, "input": 0.9834,
"output": 1.73844, "output": 2.9502,
"cache_read": 0.019316 "cache_read": 0.03278
} }
}, },
"openrouter/deepseek/deepseek-v4-flash-0731": { "openrouter/deepseek/deepseek-v4-flash-0731": {
@@ -70095,9 +70133,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.04, "input": 0.06,
"output": 0.08, "output": 0.12,
"cache_read": 0.008 "cache_read": 0.012
} }
}, },
"openrouter/deepseek/deepseek-v4-flash": { "openrouter/deepseek/deepseek-v4-flash": {
@@ -70107,9 +70145,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.04788, "input": 0.08554,
"output": 0.09576, "output": 0.17108,
"cache_read": 0.009576 "cache_read": 0.017108
} }
}, },
"openrouter/deepseek/deepseek-v4.1-flash": { "openrouter/deepseek/deepseek-v4.1-flash": {
@@ -71428,9 +71466,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.936, "input": 0.92,
"output": 3.168, "output": 3.1372,
"cache_read": 0.1872 "cache_read": 0.184
} }
}, },
"openrouter/moonshotai/kimi-k2-0905": { "openrouter/moonshotai/kimi-k2-0905": {
@@ -71523,7 +71561,8 @@
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.05, "input": 0.05,
"output": 0.23 "output": 0.23,
"cache_read": 0.05
} }
}, },
"openrouter/inference-net/schematron-v2-turbo": { "openrouter/inference-net/schematron-v2-turbo": {
@@ -71532,7 +71571,8 @@
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.03, "input": 0.03,
"output": 0.15 "output": 0.15,
"cache_read": 0.03
} }
}, },
"openrouter/cohere/north-mini-code:free": { "openrouter/cohere/north-mini-code:free": {
@@ -71625,9 +71665,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.0825, "input": 0.132,
"output": 0.33, "output": 0.528,
"cache_read": 0.020625 "cache_read": 0.033
} }
}, },
"openrouter/tencent/hy4-preview": { "openrouter/tencent/hy4-preview": {
@@ -71755,9 +71795,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.6, "input": 0.6832,
"output": 2, "output": 2.1472,
"cache_read": 0.15 "cache_read": 0.12688
} }
}, },
"openrouter/z-ai/glm-5.3-flash": { "openrouter/z-ai/glm-5.3-flash": {
@@ -71769,9 +71809,9 @@
"image": true, "image": true,
"video": true, "video": true,
"cost": { "cost": {
"input": 0.075, "input": 0.15,
"output": 0.25, "output": 0.5,
"cache_read": 0.015 "cache_read": 0.03
} }
}, },
"openrouter/z-ai/glm-4.5": { "openrouter/z-ai/glm-4.5": {
@@ -71839,9 +71879,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 1.092, "input": 1.4,
"output": 3.432, "output": 4.4,
"cache_read": 0.2028 "cache_read": 0.26
} }
}, },
"openrouter/z-ai/glm-5v-turbo": { "openrouter/z-ai/glm-5v-turbo": {
@@ -85559,8 +85599,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.5808, "input": 0.66,
"output": 1.7424 "output": 1.98,
"cache_read": 0.066
} }
}, },
"edenai/qwen/deepseek-v4-flash-0731": { "edenai/qwen/deepseek-v4-flash-0731": {
@@ -85570,8 +85611,9 @@
"tool_call": true, "tool_call": true,
"structured_output": true, "structured_output": true,
"cost": { "cost": {
"input": 0.176, "input": 0.22,
"output": 0.528 "output": 0.66,
"cache_read": 0.022
} }
}, },
"edenai/qwen/qwen3-coder-plus": { "edenai/qwen/qwen3-coder-plus": {
@@ -85688,6 +85730,19 @@
"output": 2.4 "output": 2.4
} }
}, },
"edenai/qwen/deepseek-v4.1-flash": {
"name": "DeepSeek V4.1 Flash (Alibaba)",
"context": 1000000,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"image": true,
"cost": {
"input": 0.15,
"output": 0.6,
"cache_read": 0.015
}
},
"edenai/qwen/qwen3-coder-next": { "edenai/qwen/qwen3-coder-next": {
"name": "Qwen3 Coder Next", "name": "Qwen3 Coder Next",
"context": 262144, "context": 262144,
@@ -86603,6 +86658,18 @@
"cache_read": 0.0625 "cache_read": 0.0625
} }
}, },
"edenai/tensorx/deepseek/deepseek-v4.1-flash": {
"name": "DeepSeek V4.1 Flash (TensorX)",
"context": 1048576,
"reasoning": true,
"tool_call": true,
"image": true,
"cost": {
"input": 0.5,
"output": 1.5,
"cache_read": 0.125
}
},
"edenai/tensorx/moonshotai/kimi-k2.5": { "edenai/tensorx/moonshotai/kimi-k2.5": {
"name": "Kimi K2.5 (TensorX)", "name": "Kimi K2.5 (TensorX)",
"context": 262144, "context": 262144,
+7 -6
View File
@@ -461,22 +461,23 @@ describe("validateSubagentPool", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("experimental flag registry", () => { describe("experimental flag registry", () => {
it("mirrors the 0.42.0 v2 registry (6 flags)", () => { it("mirrors the 0.43.0 v2 registry (5 flags)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([ expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"wait_for", "wait_for",
"tool-select", "tool-select",
"notify_user", "notify_user",
"tower", "tower",
"subagent_fork", "subagent_fork",
"auto_session_title",
]); ]);
// Removed upstream: file_history (0.41.0, turn-level file history is // Removed upstream: file_history (0.41.0, turn-level file history is
// always on) plus secondary-model / persistence_minidb_readmodel / // always on), auto_session_title (0.43.0, graduated — AI session titles
// remote-control / search_worker (0.42.0 — promoted or moved to // are always on, #3749), plus secondary-model /
// dedicated config, the minidb read-model now lives under [database]), // persistence_minidb_readmodel / remote-control / search_worker
// so the adapter must not mirror them either. // (0.42.0 — promoted or moved to dedicated config, the minidb read-model
// now lives under [database]), so the adapter must not mirror them either.
for (const id of [ for (const id of [
"file_history", "file_history",
"auto_session_title",
"secondary-model", "secondary-model",
"persistence_minidb_readmodel", "persistence_minidb_readmodel",
"remote-control", "remote-control",
+4 -7
View File
@@ -33,10 +33,11 @@ export interface ExperimentalFlagDef {
defaultEnabled?: boolean; defaultEnabled?: boolean;
} }
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry /** Known experimental flags — mirrors the kimi-code 0.43.0 v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src): * (the per-feature flag.ts files under packages/agent-core-v2/src):
* wait_for, tool-select, notify_user, tower, subagent_fork, * wait_for, tool-select, notify_user, tower, subagent_fork.
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control * Removed in 0.43.0: auto_session_title (graduated — AI session titles are
* always on, #3749). Removed in 0.42.0: secondary-model, remote-control
* and search_worker (promoted or dropped upstream — no flag left), and * and search_worker (promoted or dropped upstream — no flag left), and
* persistence_minidb_readmodel (now controlled by the `[database]` config * persistence_minidb_readmodel (now controlled by the `[database]` config
* section). `acp-v2` was removed upstream earlier; `file_history` was * section). `acp-v2` was removed upstream earlier; `file_history` was
@@ -53,10 +54,6 @@ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" }, { id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" }, { id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" }, { id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{
id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
]; ];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG"; export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
+1
View File
@@ -59,6 +59,7 @@ describe("planLabel", () => {
expect(planLabel("five_hour", t)).toBe("usageTier5h"); expect(planLabel("five_hour", t)).toBe("usageTier5h");
expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly"); expect(planLabel("weekly_limit", t)).toBe("usageTierWeekly");
expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly"); expect(planLabel("monthly_limit", t)).toBe("usageTierMonthly");
expect(planLabel("booster_wallet", t)).toBe("usageTierBoosterWallet");
expect(planLabel("NewAPI", t)).toBe("NewAPI"); expect(planLabel("NewAPI", t)).toBe("NewAPI");
expect(planLabel("¥", t)).toBe("¥"); expect(planLabel("¥", t)).toBe("¥");
}); });
+15 -3
View File
@@ -5,15 +5,23 @@ type TranslateFn = (
vars?: Record<string, string | number> vars?: Record<string, string | number>
) => string; ) => string;
/**
* Kimi quota 的加力钱包行 id(src-tauri/src/services/coding_plan.rs 的
* PLAN_BOOSTER_WALLET)。它是金额行而非配额百分比行,planLabel() 与
* UsageFooter 的金额行判定共用此常量,避免字面量散落。
*/
export const BOOSTER_WALLET_PLAN = "booster_wallet";
/** /**
* Map a raw Rust plan/tier name to a localized label. Unknown names (proper * Map a raw Rust plan/tier name to a localized label. Unknown names (proper
* nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged. * nouns like "OpenRouter", "NewAPI", currency codes) pass through unchanged.
* *
* Coverage note: ALL plan-type (套餐) queries flow through * Coverage note: ALL plan-type (套餐) queries flow through
* src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only * src-tauri/src/services/coding_plan.rs::percent_tier, whose names come only
* from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT — so this * from TIER_FIVE_HOUR / TIER_WEEKLY_LIMIT / TIER_MONTHLY_LIMIT /
* mapping covers every current and future plan provider automatically. If a * TIER_MONTH_CODE — so this mapping covers every current and future plan
* new tier id is ever added there, add a case here too. * provider automatically. If a new tier id is ever added there, add a case
* here too.
*/ */
export function planLabel(name: string, t: TranslateFn): string { export function planLabel(name: string, t: TranslateFn): string {
switch (name) { switch (name) {
@@ -25,6 +33,10 @@ export function planLabel(name: string, t: TranslateFn): string {
return t("usageTierWeekly"); return t("usageTierWeekly");
case "monthly_limit": case "monthly_limit":
return t("usageTierMonthly"); return t("usageTierMonthly");
case "month_code":
return t("usageTierMonthCode");
case BOOSTER_WALLET_PLAN:
return t("usageTierBoosterWallet");
default: default:
return name; return name;
} }
+4 -1
View File
@@ -50,7 +50,10 @@ export function validateProviders(
const reasons: string[] = []; const reasons: string[] = [];
const apiKey = provider.api_key?.trim(); const apiKey = provider.api_key?.trim();
if (!apiKey && !provider.managed) { // kimi-code 2.0.0+: the credential may come from an environment variable
// instead of a stored key — a provider configured that way is complete.
const apiKeyEnv = provider.api_key_env?.trim();
if (!apiKey && !apiKeyEnv && !provider.managed) {
reasons.push(t("validationNoApiKey")); reasons.push(t("validationNoApiKey"));
} }
+21
View File
@@ -15,6 +15,12 @@ export interface Provider {
provider_type: ProviderType; provider_type: ProviderType;
base_url: string | null; base_url: string | null;
api_key: string | null; api_key: string | null;
/**
* Name of the process environment variable holding the credential
* (kimi-code 2.0.0+). Mutually exclusive with `api_key` upstream — the
* export writes only one of the two. Only the variable *name* is stored.
*/
api_key_env?: string | null;
env: Record<string, string>; env: Record<string, string>;
/** Optional note / remark for the provider. */ /** Optional note / remark for the provider. */
note?: string | null; note?: string | null;
@@ -90,6 +96,9 @@ export interface UsageConfig {
autoQueryIntervalMinutes?: number; autoQueryIntervalMinutes?: number;
/** Per-request timeout in seconds; 0/undefined = default (8s). */ /** Per-request timeout in seconds; 0/undefined = default (8s). */
timeoutSeconds?: number; timeoutSeconds?: number;
/** 用量预警阈值(百分比 0-100);0/undefined = 关闭预警。
* 判定见 src/hooks/useUsageQuery.ts 的 isAlertTier()。 */
threshold?: number;
} }
/** A model discovered from a provider's API endpoint. */ /** A model discovered from a provider's API endpoint. */
@@ -125,6 +134,9 @@ export interface LoopControlConfig {
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */ * KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number; max_retries_per_step?: number;
reserved_context_size?: number; reserved_context_size?: number;
/** Retry budget for context compaction (kimi-code 0.43.0+). Absent/0 = the
* key is not written and upstream's default (5) applies. */
compaction_max_attempts?: number;
} }
export interface BackgroundConfig { export interface BackgroundConfig {
@@ -132,6 +144,13 @@ export interface BackgroundConfig {
keep_alive_on_exit?: boolean; keep_alive_on_exit?: boolean;
} }
/** Top-level `[watch]` section (kimi-code 2.0.1+). The key was added in
* 2.0.1 and its default flipped to off in 2.0.2, so an absent key reads as
* disabled. `KIMI_CODE_WATCH` overrides the config value at runtime. */
export interface WatchConfig {
enabled?: boolean;
}
export interface PermissionRule { export interface PermissionRule {
decision?: "allow" | "deny" | "ask"; decision?: "allow" | "deny" | "ask";
scope?: string; scope?: string;
@@ -150,6 +169,8 @@ export interface AgentSettings {
thinking?: ThinkingConfig; thinking?: ThinkingConfig;
loop_control?: LoopControlConfig; loop_control?: LoopControlConfig;
background?: BackgroundConfig; background?: BackgroundConfig;
/** Top-level `[watch]` section (kimi-code 2.0.1+). */
watch?: WatchConfig;
permission?: { permission?: {
rules?: PermissionRule[]; rules?: PermissionRule[];
/** kimi-code `[permission]` key. Default true when the key is /** kimi-code `[permission]` key. Default true when the key is