Compare commits

..
11 Commits
Author SHA1 Message Date
KimiSwitch Dev 00cf194b5c feat(usage): Sub2API 中转站账单查询 + 模型发现列表全选/反选 — bump to v0.7.17
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
- 新增 balance:sub2api:GET {base}/v1/usage 复用推理 sk- key,解析 quota /
  5h·1d·7d rate_limits / 订阅组日周月额度 / 钱包余额(USD)
- detect_provider 修正 codingplan.site 家族:主域=Sub2API,ai.=NewAPI
- UsageConfigModal 新增 Sub2API 模板(无需凭据,可选 Base URL 覆盖)
- ProviderEdit 模型发现列表新增 全选/取消全选/反选
- cargo test 124 通过(+6 sub2api 解析用例)、tsc 零错误
2026-09-13 14:51:09 +08:00
KimiSwitch Dev b9002281a3 chore(data): 同步 models-dev 2026-09-10(7615 模型 / 213 供应商)+ v0.7.16 release notes
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-10 23:37:15 +08:00
KimiSwitch Dev 0ea9e5b3de chore(release): bump to v0.7.16 — 适配 kimi-code 0.42.0 实验 flag 9→6 + 模型用量统计滚动 2026-09-10 13:19:54 +08:00
KimiSwitch Dev 36e64a1f72 fix(dashboard): 模型用量统计图表固定最大高度 360px 并滚动,与右侧模型用量表一致 2026-09-10 11:09:06 +08:00
KimiSwitch Dev 9ce1ed65b0 feat(settings): 适配 kimi-code 0.42.0 —— 实验 flag 镜像 9→6
- 删除已转正/移除的 4 个 flag:secondary-model(模型池恒开)、
  persistence_minidb_readmodel(改由 [database] 配置节)、remote-control、
  search_worker;env 探测清单同步 11→8
- 新增 notify_user flag(KIMI_CODE_EXPERIMENTAL_NOTIFY_USER,实验性
  Updates 面板,默认关闭)
- 设置页移除 secondary-model 专属卡片与「默认开启」徽章,6 个 flag 统一渲染;
  [secondary_model] 模型池配置逻辑不变
- 残留旧键上游 schema 宽容忽略,无需迁移
2026-09-09 19:48:41 +08:00
KimiSwitch Dev 33a27ba31b fix(release): publish-gitea.sh 修复含空格文件名上传失败(暂存去空格副本 + 点号命名) 2026-09-09 14:32:34 +08:00
KimiSwitch Dev 7bc2dee8b2 chore(release): 新增国内 Gitea 发布脚本 publish-gitea.sh(先国内后 GitHub 流程) 2026-09-09 14:29:29 +08:00
KimiSwitch Dev 5864d661c1 ci(release): Release 文案改为自动读取 release-notes-<tag>.md
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
不再硬编码过期文案;缺失时回退到提交记录说明,保留下载与 macOS 安装说明模板
2026-09-09 14:06:30 +08:00
KimiSwitch Dev 34a9c4c2d9 chore(release): bump to v0.7.15 — 昨天选项卡 + 模型用量统计 + Go 套餐 x-opencode-session 自动补头 2026-09-09 13:51:58 +08:00
KimiSwitch Dev 4f0b0cae12 feat(providers): Go 套餐导出自动补 x-opencode-session 头 + models-dev 2026-09-09
- opencode Console Go 网关强制要求 x-opencode-session 请求头(缺失 400),
  config 导出时对 zen/go 端点供应商自动补 custom_headers(保留用户已有值),
  zen/v1 按量端点不受影响
- models-dev 快照 7583 模型 / 213 供应商;public/ 前端静态目录同步补齐(原 08-27)
2026-09-09 13:51:57 +08:00
KimiSwitch Dev 35fd2e7b0e feat(dashboard): 用量统计新增「昨天」范围 + 跨供应商「模型用量统计」选项卡
- 范围新增 yesterday:本地时区昨日 0 点 ~ 今日 0 点,range_end 引入上界过滤
- RangeStats 新增 modelsByName:按裸模型名聚合,同一模型跨供应商合并,
  子代理记录归入 __secondary__ 单独成行,按 Token 降序
- 趋势选项卡第 4 个 tab「模型用量统计」:新增 ModelTotalsChart 横向条形图,
  悬停显示请求数/Token/缓存命中/费用
- 概览卡网格扩展为 5 张;i18n 中英同步
- 顺带:pricing 测试基准从已下架的 moonshotai/kimi-k2.5 切换到 kimi-k3
2026-09-09 13:51:43 +08:00
33 changed files with 86972 additions and 80654 deletions

No files matched your search

+28 -18
View File
@@ -69,24 +69,22 @@ jobs:
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Build and upload release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
releaseBody: |
## ${{ github.ref_name }}
### 本次更新
- **模型数据同步(models.dev 2026-08-27)**:7343 个模型 / 203 个供应商
- **新增 GLM-5.3 / GLM-5.3-Flash 全线接入**:智谱官方、OpenRouter、Cloudflare、DeepInfra、HuggingFace、火山引擎等约 20 条渠道;GLM-5.3-Flash 定价 $0.075 / $0.25(输入/输出,每百万 token)
- 新增 coding plan 渠道:`zhipuai-coding-plan/glm-5.3`、`glm-5.3-highspeed`、`glm-5.3-flash`
- 新增 Qwen3.8 系列、火山引擎豆包 Seed 2.x 全系、DeepSeek-V4 GA 版、MiniMax-M2.7/M3 等共 74 个模型条目
- 价格更新 58 处、能力信息修正 56 处(详见 release-notes-v0.7.9.md)
- name: Compose release body
id: notes
shell: bash
run: |
FILE="release-notes-${{ github.ref_name }}.md"
{
echo 'body<<RELEASE_BODY_EOF'
if [ -f "$FILE" ]; then
cat "$FILE"
else
echo "## ${{ github.ref_name }}"
echo
echo "见提交记录(未找到 $FILE)。"
fi
echo
cat <<'TAIL_EOF'
### Downloads by platform
- **Windows**: `.msi` installer
@@ -106,6 +104,18 @@ jobs:
3. 手动替代方案:右键 `Kimi Switch.app` → 打开 → 弹窗中点「打开」(或 系统设置 → 隐私与安全性 → 仍要打开)
Full changelog: see commit history.
TAIL_EOF
echo 'RELEASE_BODY_EOF'
} >> "$GITHUB_OUTPUT"
- name: Build and upload release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: ${{ github.ref_name }}
releaseBody: ${{ steps.notes.outputs.body }}
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-alpha') }}
overwrite: true
args: ${{ matrix.args }}
+11
View File
@@ -253,6 +253,17 @@ KimiSwitch 现有 `usageKinds` 枚举是**每家中转站一个 Rust 函数**的
## 5. 新增能力实现清单(改动点)
> 实施状态更新(2026-09-13):**Sub2API 兼容已实现(`balance:sub2api`)**。
> 实测澄清:`codingplan.site`(主域)**不是** NewAPI 而是 Wei-Shaw/sub2api 面板
> (NewAPI 端点 `/api/status`、`/api/user/self` 全 404;`/api/v1/settings/public`
> 返回 `data.affiliate_enabled` 指纹命中;`/v1/usage`、`/v1/sub2api/billing` 为
> API-key 网关端点)。`ai.codingplan.site` 才是 NewAPI 实例。detect_provider
> 已按此拆分家族规则。Sub2API 查询复用推理 sk- key(`GET {base}/v1/usage`,
> Bearer),解析三种 mode:quota_limited(quota + 5h/1d/7d rate_limits)、
> unrestricted 订阅组(daily/weekly/monthly usage/limit USD)、钱包余额。
> UsageConfigModal 模板加 "sub2api"(无需凭据,可选 Base URL 覆盖)。
> 验证:cargo test 124 通过(含 6 个 sub2api 解析用例)、tsc 零错误。
> 实施状态(2026-07-31):**P0 已实现,未验证、未提交**。
> 展示方案已确认为 **A(供应商卡片底部 UsageFooter,零前端改动)**。
> 改动文件:`src-tauri/src/services/balance.rs`(query_kimi/parse_kimi + 单测)、`src-tauri/src/services/mod.rs`(枚举/路由/detect + 单测)、`src/config/providerPresets.ts`(UsageKind/SUPPORTED_USAGE_KINDS + moonshot 预设挂 `balance:kimi`)。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "kimiswitch",
"private": true,
"version": "0.7.14",
"version": "0.7.17",
"type": "module",
"scripts": {
"dev": "vite",
+81341 -77441
View File
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.15
## 新功能
- **用量统计新增「昨天」选项卡**:时间范围现为 今天 / 昨天 / 7 天 / 30 天 / 全部,「昨天」按本地时区严格统计昨日 0 点至今日 0 点(不渗入今天),顶部概览卡同步扩展为 5 张
- **新增「模型用量统计」趋势选项卡**:在「供应商模型用量趋势」之后,不按天、跨供应商按模型名汇总(同一模型经不同供应商合并为一行),横向条形图按 Token 降序,悬停显示请求数 / Token / 缓存命中 / 费用;子代理模型仍单独成行
## 修复
- **opencode Go 套餐子代理请求 400**:Go 网关(opencode.ai/zen/go)近期强制要求 `x-opencode-session` 请求头用于路由与提示词缓存,缺失即 400 拒绝。现在导出配置时,凡指向 Go 端点的供应商会自动补写 `custom_headers.x-opencode-session`(已有用户自定义值保留不覆盖);按量计费的 `zen/v1` 端点不受影响
- 定价测试基准条目更新:models.dev 上游下架 `moonshotai/kimi-k2.5` 官方条目,测试基准切换到 `moonshotai/kimi-k3`
## 数据
- models-dev 快照同步至 2026-09-09(7583 模型 / 213 供应商),并补齐 `public/` 前端静态目录(原停留在 08-27)
- 注意:k2.5 官方定价条目下架后,`kimi/k2.5` 费用估算暂解析到 302ai 转售价格(0.66 / 3.3),略高于原官方价
+7
View File
@@ -0,0 +1,7 @@
# KimiSwitch v0.7.16
## 适配
- **适配 kimi-code 0.42.0 实验 flag 镜像(9→6)**:移除已转正/删除的 4 个开关(子代理次主力模型——模型池恒开、MiniDB 读模型——改由 `[database]` 配置节、远程控制、搜索 Worker),新增 `notify_user`(实验性 Updates 面板,默认关闭);环境变量探测清单同步;老配置残留旧键上游静默忽略,无需迁移
- 用量统计「模型用量统计」图表固定最大高度 360px 并支持滚动,与右侧模型用量表一致
- models-dev 快照同步至 2026-09-10(7615 模型 / 213 供应商),`public/` 前端静态目录同步
+16
View File
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.17
## 新增
- **账单查询适配 Sub2API 中转站(`balance:sub2api`)**:支持 Wei-Shaw/sub2api 面板的余额/额度查询(`GET {base}/v1/usage`),自动复用推理 `sk-` API Key,**无需**网页后台 Access Token;解析三种模式——单 Key 总额度(quota)、5 小时/每日/7 天速率窗口(含重置倒计时)、订阅组日/周/月额度与钱包余额(USD)
- 用量配置面板新增「Sub2API 中转站」模板,可选覆盖查询地址(Base URL)
- `detect_provider` 家族规则拆分:`codingplan.site` 主域识别为 Sub2API,`ai.codingplan.site` 识别为 NewAPI(修正此前主域被误判为 NewAPI 导致查询 404 的问题)
- **模型映射「获取模型列表」新增批量选择**:发现列表标题右侧新增「全选 / 取消全选 / 反选」三个按钮,配合已有的已添加标记与「添加选中的」,批量接入中转站模型列表更省事
## 修复
- 修复 `codingplan.site`(Sub2API 面板)走 NewAPI 模板查询必然失败的问题——NewAPI 模板(Access Token + User ID)仍适用于 NewAPI / OneAPI 站点,两者互不影响
## 备注
- Sub2API 查询的金额单位由上游硬编码为 USD;速率窗口标签沿用现有本地化(5 小时 / 每日 / 7 天)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Publish a release (notes + assets) to the domestic Gitea mirror
# (git.codingplan.site/admin/KimiCodeSwitch).
#
# Usage: publish-gitea.sh <tag> <asset> [asset...]
# tag : e.g. v0.7.15; notes read from release-notes-<tag>.md
# assets : files to attach (MSI, install-macos.sh, ...)
#
# Auth: reuses the stored git credential for git.codingplan.site
# (`git credential fill`); the secret never appears in argv or output.
set -euo pipefail
GITEA="https://git.codingplan.site/api/v1"
REPO="admin/KimiCodeSwitch"
TAG="${1:?usage: publish-gitea.sh <tag> <asset> [asset...]}"
shift
NOTES_FILE="release-notes-${TAG}.md"
json_escape() {
python - "$1" <<'PYEOF'
import json, sys
print(json.dumps(open(sys.argv[1], encoding="utf-8").read()))
PYEOF
}
if [[ -f "$NOTES_FILE" ]]; then
BODY=$(json_escape "$NOTES_FILE")
else
BODY="\"${TAG}\""
fi
# Resolve stored git credential for this host (no secret echoed).
CRED=$(printf 'protocol=https\nhost=git.codingplan.site\n\n' | git credential fill)
USER=$(grep '^username=' <<<"$CRED" | cut -d= -f2-)
PASS=$(grep '^password=' <<<"$CRED" | cut -d= -f2-)
[[ -n "$USER" && -n "$PASS" ]] || { echo "no stored credential for git.codingplan.site" >&2; exit 1; }
AUTH=(-u "$USER:$PASS")
# Create (or reuse) the release for this tag.
EXISTING=$(curl -sf --max-time 20 "${AUTH[@]}" "$GITEA/repos/$REPO/releases/tags/$TAG" || true)
if [[ -n "$EXISTING" && "$EXISTING" != *"message"* ]]; then
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$EXISTING" | head -1 | cut -d: -f2)
echo "release $TAG exists (id=$RELEASE_ID), attaching assets"
else
CREATED=$(curl -sf --max-time 20 "${AUTH[@]}" \
-H 'Content-Type: application/json' -X POST \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":$BODY,\"draft\":false,\"prerelease\":false}" \
"$GITEA/repos/$REPO/releases")
RELEASE_ID=$(grep -o '"id":[0-9]*' <<<"$CREATED" | head -1 | cut -d: -f2)
echo "created release $TAG (id=$RELEASE_ID)"
fi
for ASSET in "$@"; do
[[ -f "$ASSET" ]] || { echo "skip missing asset: $ASSET" >&2; continue; }
NAME=$(basename "$ASSET" | tr ' ' '.')
STAGED=$(mktemp "${TMPDIR:-/tmp}/upload.XXXXXX.${NAME##*.}")
cp "$ASSET" "$STAGED"
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 600 "${AUTH[@]}" \
-F "attachment=@$STAGED" \
"$GITEA/repos/$REPO/releases/$RELEASE_ID/assets?name=$NAME")
rm -f "$STAGED"
echo "upload $NAME -> HTTP $CODE"
[[ "$CODE" == 2* ]] || { echo "upload failed: $NAME" >&2; exit 1; }
done
echo "done: https://git.codingplan.site/$REPO/releases/tag/$TAG"
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.14"
version = "0.7.17"
dependencies = [
"anyhow",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.14"
version = "0.7.17"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
+19 -19
View File
@@ -690,19 +690,20 @@ pub async fn query_provider_usage(
let base_url = resolve_base_url(provider);
// NewAPI template: query the gateway's own usage endpoints with its
// dedicated credentials, bypassing usage_kinds entirely.
// NewAPI / Sub2API template: query the gateway's own usage endpoints.
// NewAPI needs web-console credentials (accessToken + userId); Sub2API
// reuses the inference API key directly — both bypass usage_kinds.
if let Some(cfg) = &provider.usage_config {
if cfg.template_type == UsageConfig::TEMPLATE_NEWAPI {
let template_kind = match cfg.template_type.as_str() {
UsageConfig::TEMPLATE_NEWAPI => Some(UsageKind::BalanceNewapi),
UsageConfig::TEMPLATE_SUB2API => Some(UsageKind::BalanceSub2Api),
_ => None,
};
if let Some(kind) = template_kind {
// Err = transient (network) → propagate for retry, same semantics
// as the kinds loop below. Config errors surface as Ok(failure).
let result = services::query_kind(
UsageKind::BalanceNewapi,
&base_url,
&api_key,
Some(cfg),
)
.await?;
let result =
services::query_kind(kind, &base_url, &api_key, Some(cfg)).await?;
if result.success {
usage_cache()
.lock()
@@ -1335,7 +1336,7 @@ pub async fn kimi_oauth_poll(
}
// ---------------------------------------------------------------------------
// Experimental feature env-var probe (Kimi Code secondary model etc.)
// Experimental feature env-var probe (Kimi Code experimental flags)
// ---------------------------------------------------------------------------
/// Read the Kimi Code environment variables that are currently set (non-empty)
@@ -1348,6 +1349,11 @@ pub async fn kimi_oauth_poll(
/// are probed too — the latter lets AgentSettingsPanel decide whether to
/// dual-write the v1 engine's loop_control keys.
///
/// As of Kimi Code 0.42.0 the upstream registry holds 6 experimental flags;
/// `secondary-model`, `persistence_minidb_readmodel`, `remote-control` and
/// `search_worker` have been promoted or removed upstream and are no longer
/// probed here.
///
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
/// src/lib/subagent-settings.ts; keep both lists in sync.
///
@@ -1355,19 +1361,13 @@ pub async fn kimi_oauth_poll(
/// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [(&str, &str); 11] = [
("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
const VARS: [(&str, &str); 8] = [
("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
(
"persistence_minidb_readmodel",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
),
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
("notify_user", "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER"),
// Non-flag probes consumed by the frontend:
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
("legacy", "KIMI_CODE_LEGACY_FLAG"),
+146 -21
View File
@@ -102,6 +102,9 @@ pub struct RangeStats {
pub totals: TotalsRow,
pub daily: Vec<DailyRow>,
pub models: Vec<ModelRow>,
/// Model totals keyed by bare model name (provider prefix stripped), so the
/// same model served by multiple providers merges into one row.
pub models_by_name: Vec<ModelRow>,
pub recent: Vec<RecentRow>,
pub recent_total: usize,
pub recent_limit: usize,
@@ -976,6 +979,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
totals: TotalsRow::default(),
daily: vec![],
models: vec![],
models_by_name: vec![],
recent: vec![],
recent_total: 0,
recent_limit: 500,
@@ -985,6 +989,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
let mut totals = TotalsRow::default();
let mut by_day: HashMap<String, (TotalsRow, HashMap<String, TotalsRow>, HashMap<String, HashMap<String, u64>>)> = HashMap::new();
let mut by_model: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
let mut by_name: HashMap<String, (ModelRow, TotalsRow)> = HashMap::new();
for r in &filtered {
totals.add(r);
@@ -1017,6 +1022,28 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
entry.1.add(r);
entry.0.cost_estimated = entry.0.cost_estimated || r.cost_estimated;
entry.0.is_secondary = entry.0.is_secondary || r.is_secondary;
// Provider-agnostic bucket: secondary (subagent) records keep the
// stable "__secondary__" marker; everything else keys on the bare
// model name so the same model across providers merges into one row.
let nk = if r.is_secondary { "__secondary__".to_string() } else { r.model_resolved.clone() };
let name_entry = by_name.entry(nk.clone()).or_insert_with(|| {
let m = ModelRow {
model: nk.clone(),
model_display: nk.clone(),
model_resolved: nk.clone(),
price_id: r.price_id.clone(),
cost_estimated: r.cost_estimated,
is_secondary: r.is_secondary,
requests: 0, input_other: 0, output: 0,
input_cache_read: 0, input_cache_creation: 0,
cost_usd: 0.0, total_tokens: 0, cache_hit_rate: 0.0,
};
(m, TotalsRow::default())
});
name_entry.1.add(r);
name_entry.0.cost_estimated = name_entry.0.cost_estimated || r.cost_estimated;
name_entry.0.is_secondary = name_entry.0.is_secondary || r.is_secondary;
}
let total_input = totals.input_other + totals.input_cache_read + totals.input_cache_creation;
@@ -1063,6 +1090,17 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
}).collect();
models.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
let mut models_by_name: Vec<ModelRow> = by_name.into_iter().map(|(_, (mut m, t))| {
let ti = t.input_other + t.input_cache_read + t.input_cache_creation;
m.cache_hit_rate = if ti > 0 { t.input_cache_read as f64 / ti as f64 } else { 0.0 };
m.requests = t.requests;
m.input_other = t.input_other; m.output = t.output;
m.input_cache_read = t.input_cache_read; m.input_cache_creation = t.input_cache_creation;
m.cost_usd = t.cost_usd; m.total_tokens = t.total_tokens;
m
}).collect();
models_by_name.sort_by(|a, b| b.total_tokens.cmp(&a.total_tokens));
let recent_total = filtered.len();
let recent_limit = 500;
let recent: Vec<RecentRow> = filtered.iter().take(recent_limit).map(|r| RecentRow {
@@ -1081,6 +1119,7 @@ fn aggregate(records: &[UsageRecord], range: &str, now_ms: u64) -> RangeStats {
totals,
daily,
models,
models_by_name,
recent,
recent_total,
recent_limit,
@@ -1109,24 +1148,40 @@ impl TotalsRow {
fn filter_by_range<'a>(records: &'a [UsageRecord], range: &str, now_ms: u64) -> Vec<&'a UsageRecord> {
if range == "all" { return records.iter().collect(); }
let start = range_start(range, now_ms);
records.iter().filter(|r| r.time >= start).collect()
match range_end(range, now_ms) {
Some(end) => records.iter().filter(|r| r.time >= start && r.time < end).collect(),
None => records.iter().filter(|r| r.time >= start).collect(),
}
}
/// Upper bound (exclusive) for bounded ranges. Only "yesterday" has one —
/// it must not bleed into today.
fn range_end(range: &str, now_ms: u64) -> Option<u64> {
match range {
"yesterday" => Some(local_midnight_ms(now_ms, 0)),
_ => None,
}
}
/// LOCAL midnight `days_ago` days before today, in epoch ms. Not UTC midnight:
/// without local-midnight anchoring, users east of UTC see day boundaries at
/// the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
fn local_midnight_ms(now_ms: u64, days_ago: i64) -> u64 {
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let date = now_local.date_naive() - chrono::Duration::days(days_ago);
let day_start = date.and_hms_opt(0, 0, 0).unwrap_or_default();
Local
.from_local_datetime(&day_start)
.single()
.map(|dt| dt.timestamp() as u64 * 1000)
.unwrap_or_else(|| day_start.and_utc().timestamp() as u64 * 1000)
}
fn range_start(range: &str, now_ms: u64) -> u64 {
match range {
"today" => {
// LOCAL midnight today — not UTC midnight. Without this, users east of UTC see
// "today" begin at the wrong hour (e.g. UTC+8 users get cutoff at local 08:00).
let now_utc = DateTime::from_timestamp((now_ms / 1000) as i64, 0).unwrap_or_default();
let now_local = now_utc.with_timezone(&Local);
let today_date = now_local.date_naive();
let today_start = today_date.and_hms_opt(0, 0, 0).unwrap_or_default();
Local
.from_local_datetime(&today_start)
.single()
.map(|dt| dt.timestamp() as u64 * 1000)
.unwrap_or_else(|| today_start.and_utc().timestamp() as u64 * 1000)
}
"today" => local_midnight_ms(now_ms, 0),
"yesterday" => local_midnight_ms(now_ms, 1),
"7d" => now_ms - 7 * 24 * 3600 * 1000,
"30d" => now_ms - 30 * 24 * 3600 * 1000,
_ => now_ms - 30 * 24 * 3600 * 1000,
@@ -1836,7 +1891,7 @@ pub fn get_summary(home_override: Option<String>, range: Option<String>, refresh
let all_model_count = all_models.len();
let mut range_totals = HashMap::new();
for r_k in ["today", "7d", "30d", "all"] {
for r_k in ["today", "yesterday", "7d", "30d", "all"] {
let s = aggregate(&records, r_k, now_ms);
range_totals.insert(r_k.to_string(), s.totals);
}
@@ -1977,10 +2032,10 @@ mod pricing_tests {
let idx = models_dev_cost_index();
// The compiled-in snapshot must carry real models.dev prices.
assert!(idx.len() > 1000, "expected >1000 priced models, got {}", idx.len());
let kimi = idx.get("moonshotai/kimi-k2.5").copied().expect("kimi-k2.5 present");
assert_eq!(kimi.input, 0.6);
assert_eq!(kimi.output, 3.0);
assert_eq!(kimi.cache_read, 0.1);
let kimi = idx.get("moonshotai/kimi-k3").copied().expect("kimi-k3 present");
assert_eq!(kimi.input, 3.0);
assert_eq!(kimi.output, 15.0);
assert_eq!(kimi.cache_read, 0.3);
}
#[test]
@@ -1992,9 +2047,9 @@ mod pricing_tests {
assert_eq!(input, 0.6);
assert_eq!(output, 2.2);
let (id, _, _, _, est) = match_price("kimi/k2.5");
let (id, _, _, _, est) = match_price("kimi/k3");
assert!(!est);
assert_eq!(id, "moonshotai/kimi-k2.5");
assert_eq!(id, "moonshotai/kimi-k3");
}
#[test]
@@ -2307,3 +2362,73 @@ mod session_tests {
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
}
}
#[cfg(test)]
mod range_and_model_totals_tests {
use super::*;
fn rec(time: u64, model: &str, resolved: &str, secondary: bool) -> UsageRecord {
UsageRecord {
time,
model: model.to_string(),
input_other: 100,
output: 50,
input_cache_read: 50,
input_cache_creation: 0,
cost_usd: 0.01,
cost_estimated: false,
price_id: String::new(),
model_resolved: resolved.to_string(),
model_display: model.to_string(),
provider: None,
from_env: false,
is_secondary: secondary,
}
}
#[test]
fn yesterday_range_is_bounded_to_yesterday() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let today_start = local_midnight_ms(now, 0);
let yesterday_start = local_midnight_ms(now, 1);
assert!(yesterday_start < today_start, "yesterday midnight precedes today's");
assert_eq!(range_end("yesterday", now), Some(today_start));
assert_eq!(range_end("today", now), None);
assert_eq!(range_end("7d", now), None);
let records = vec![
rec(yesterday_start + 1, "a/m1", "m1", false), // yesterday → in
rec(today_start + 1, "a/m1", "m1", false), // today → out
rec(yesterday_start.saturating_sub(1), "a/m1", "m1", false), // before → out
];
let stats = aggregate(&records, "yesterday", now);
assert_eq!(stats.totals.requests, 1, "only yesterday's record counts");
}
#[test]
fn models_by_name_merges_providers_and_keeps_secondary() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
let records = vec![
rec(now, "openai/gpt-x", "gpt-x", false),
rec(now, "azure/gpt-x", "gpt-x", false),
rec(now, "__secondary__", "__secondary__", true),
];
let stats = aggregate(&records, "all", now);
// by_model keeps provider-qualified rows separate
assert_eq!(stats.models.len(), 3);
// by_name merges the two gpt-x rows, subagent stays its own row
assert_eq!(stats.models_by_name.len(), 2);
let gptx = stats.models_by_name.iter().find(|m| m.model == "gpt-x").expect("merged gpt-x row");
assert_eq!(gptx.requests, 2);
assert_eq!(gptx.total_tokens, 2 * 200);
let sub = stats.models_by_name.iter().find(|m| m.model == "__secondary__").expect("secondary row");
assert!(sub.is_secondary);
// sorted by total_tokens desc — every record has equal tokens here, so
// just assert the set is complete and totals add up.
let total: u64 = stats.models_by_name.iter().map(|m| m.total_tokens).sum();
assert_eq!(total, 3 * 200);
}
}
+89
View File
@@ -236,6 +236,30 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
}
}
/// opencode Console Go (the "Go 套餐" gateway) rejects requests that lack an
/// `x-opencode-session` header (400: required for routing and prompt-cache
/// affinity). When exporting a provider pointing at the Go endpoint, ensure
/// the header exists; an existing user-set value is kept as-is.
fn ensure_opencode_go_session_header(provider: &Provider, pt: &mut Table) {
let is_go = provider
.base_url
.as_deref()
.map(|u| u.trim_end_matches('/').starts_with("https://opencode.ai/zen/go"))
.unwrap_or(false);
if !is_go {
return;
}
let mut headers = pt
.get("custom_headers")
.and_then(|v| v.as_table())
.cloned()
.unwrap_or_default();
headers
.entry("x-opencode-session".to_string())
.or_insert_with(|| TomlValue::String("kimi-switch".to_string()));
pt.insert("custom_headers".to_string(), TomlValue::Table(headers));
}
/// Export Kimi Switch's internal `Config` to a Kimi Code TOML config value.
///
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
@@ -316,6 +340,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
pt.insert(k, v);
}
}
ensure_opencode_go_session_header(provider, &mut pt);
providers_table.insert(name.clone(), TomlValue::Table(pt));
}
root.insert("providers".to_string(), TomlValue::Table(providers_table));
@@ -591,6 +616,70 @@ api_key = ""
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
}
#[test]
fn kimi_code_export_adds_opencode_go_session_header() {
let make_provider = |base_url: &str, raw_other: Value| Provider {
name: "p".to_string(),
provider_type: ProviderType::Openai,
base_url: Some(base_url.to_string()),
api_key: Some("sk-test".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
icon: None,
icon_color: None,
raw_other,
usage_kinds: None,
usage_config: None,
};
let export = |p: Provider| {
let mut providers = IndexMap::new();
providers.insert("p".to_string(), p);
let config = Config {
default_model: None,
providers,
models: IndexMap::new(),
raw_other: Value::Null,
imported_section_keys: Vec::new(),
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap().clone();
root.get("providers")
.and_then(|v| v.as_table())
.and_then(|t| t.get("p"))
.and_then(|v| v.as_table().cloned())
.unwrap()
};
// Go endpoint without headers → header injected.
let p = export(make_provider("https://opencode.ai/zen/go/v1", Value::Null));
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
assert_eq!(
headers.get("x-opencode-session").and_then(|v| v.as_str()),
Some("kimi-switch")
);
// Existing user-set value is preserved, not overwritten.
let raw = serde_json::json!({
"custom_headers": { "x-opencode-session": "my-session" }
});
let p = export(make_provider("https://opencode.ai/zen/go/v1", raw));
let headers = p.get("custom_headers").unwrap().as_table().unwrap();
assert_eq!(
headers.get("x-opencode-session").and_then(|v| v.as_str()),
Some("my-session")
);
// PAYG zen endpoint and unrelated providers are left alone.
let p = export(make_provider("https://opencode.ai/zen/v1", Value::Null));
assert!(p.get("custom_headers").is_none());
let p = export(make_provider("https://api.deepseek.com/v1", Value::Null));
assert!(p.get("custom_headers").is_none());
}
#[test]
fn kimi_code_export_syncs_raw_other_top_level_sections() {
// Regression: UI-managed top-level sections ([thinking],
+1
View File
@@ -290,6 +290,7 @@ pub struct UsageConfig {
impl UsageConfig {
pub const TEMPLATE_AUTO: &'static str = "auto";
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
pub const TEMPLATE_SUB2API: &'static str = "sub2api";
}
impl std::fmt::Debug for Config {
+254
View File
@@ -521,6 +521,159 @@ fn parse_newapi(
}])
}
// ── Sub2API (Wei-Shaw/sub2api) 中转站 ────────────────────────
// GET {base_url}/v1/usage
// 头:Authorization: Bearer <api_key>(就是推理用的 sk- key,无需额外凭据)
// Response(三种 mode):
// - quota_limited:key 设了总额度/速率限制 → quota{limit,used,remaining} +
// rate_limits[{window: 5h|1d|7d, limit, used, remaining, reset_at}]
// - unrestricted + subscription:订阅组 → planName + subscription 的
// daily/weekly/monthly usage/limit(USD)
// - unrestricted + 钱包:planName="钱包余额" → balance / remaining(USD)
//
// 站点指纹(可选探测):GET /api/v1/settings/public 返回 data.affiliate_enabled
// 即为 sub2api 面板。注意 NewAPI 的 /api/user/self、/api/status 在 sub2api 上
// 全部 404 —— 两套中转站协议不互通。
/// 把供应商 base_url 归一成站点根(去掉结尾 `/` 与 `/v1`),网关路由挂在根路径。
fn sub2api_site_root(base_url: &str) -> String {
let mut base = base_url.trim_end_matches('/');
if let Some(stripped) = base.strip_suffix("/v1") {
base = stripped;
}
base.to_string()
}
pub async fn query_sub2api(
base_url: &str,
api_key: &str,
timeout: Duration,
) -> Result<UsageResult, String> {
let url = format!("{}/v1/usage", sub2api_site_root(base_url));
match get_json(&url, api_key, AuthStyle::Bearer, timeout).await? {
Fetched::Body(body) => Ok(parse_sub2api(&body)),
Fetched::Failed(err) => Ok(err),
}
}
/// 速率窗口名 → 本地化 tier 名(前端 planLabel 会翻译;five_hour /
/// weekly_limit 已有映射,daily_limit 新增)。
fn sub2api_window_tier(window: &str) -> &'static str {
match window {
"5h" => "five_hour",
"1d" => "daily_limit",
"7d" => "weekly_limit",
_ => "window",
}
}
fn parse_sub2api(body: &serde_json::Value) -> UsageResult {
let is_valid = body
.get("isValid")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mode = body.get("mode").and_then(|v| v.as_str()).unwrap_or("");
if mode.is_empty() {
return UsageResult::failure(
"Missing 'mode' field in response (not a sub2api gateway?)".to_string(),
);
}
let mut out: Vec<UsageData> = Vec::new();
// key 总额度(quota_limited 模式才有)
if let Some(quota) = body.get("quota") {
if let Some(limit) = parse_f64_field(quota, "limit").filter(|&l| l > 0.0) {
let used = parse_f64_field(quota, "used").unwrap_or(0.0);
let remaining = parse_f64_field(quota, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
..Default::default()
});
}
}
// 速率窗口(5h / 1d / 7d;reset_at 为 RFC3339,窗口过期时缺省)
if let Some(rates) = body.get("rate_limits").and_then(|v| v.as_array()) {
for w in rates {
let window = w.get("window").and_then(|v| v.as_str()).unwrap_or("");
let Some(limit) = parse_f64_field(w, "limit").filter(|&l| l > 0.0) else {
continue;
};
let used = parse_f64_field(w, "used").unwrap_or(0.0);
let remaining = parse_f64_field(w, "remaining").unwrap_or(limit - used);
out.push(UsageData {
plan_name: Some(sub2api_window_tier(window).to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(used),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: w
.get("reset_at")
.and_then(|v| v.as_str())
.map(str::to_string),
});
}
}
// unrestricted 订阅组:daily/weekly/monthly 三窗口(usage_usd vs limit_usd)
if mode == "unrestricted" {
if let Some(sub) = body.get("subscription") {
for (prefix, tier) in [
("daily", "daily_limit"),
("weekly", "weekly_limit"),
("monthly", "monthly_limit"),
] {
let Some(usage) = parse_f64_field(sub, &format!("{prefix}_usage_usd")) else {
continue;
};
let Some(limit) = parse_f64_field(sub, &format!("{prefix}_limit_usd"))
.filter(|&l| l > 0.0)
else {
continue;
};
let remaining = limit - usage;
out.push(UsageData {
plan_name: Some(tier.to_string()),
remaining: Some(remaining),
total: Some(limit),
used: Some(usage),
unit: Some("USD".to_string()),
is_valid: Some(is_valid && remaining > 0.0),
resets_at: None,
});
}
}
}
// 钱包余额(unrestricted 非订阅)或以上全部缺失时的兜底
if out.is_empty() {
if let Some(balance) = parse_f64_field(body, "balance")
.or_else(|| parse_f64_field(body, "remaining"))
{
out.push(UsageData {
plan_name: Some("Sub2API".to_string()),
remaining: Some(balance),
unit: Some("USD".to_string()),
is_valid: Some(is_valid),
..Default::default()
});
}
}
if out.is_empty() {
UsageResult::failure("No quota/rate-limit/balance fields in response".to_string())
} else {
UsageResult::ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -688,4 +841,105 @@ mod tests {
assert_eq!(data[0].remaining, Some(1.0));
assert_eq!(data[0].used, Some(0.2));
}
#[test]
fn sub2api_site_root_strips_v1_and_slash() {
assert_eq!(sub2api_site_root("https://x.com"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1"), "https://x.com");
assert_eq!(sub2api_site_root("https://x.com/v1/"), "https://x.com");
}
#[test]
fn sub2api_quota_limited_parses_quota_and_windows() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 50.0, "used": 20.0, "remaining": 30.0, "unit": "USD" },
"remaining": 30.0,
"unit": "USD",
"rate_limits": [
{ "window": "5h", "limit": 10.0, "used": 4.0, "remaining": 6.0,
"window_start": "2026-09-13T00:00:00Z", "reset_at": "2026-09-13T05:00:00Z" },
{ "window": "1d", "limit": 30.0, "used": 10.0, "remaining": 20.0,
"window_start": "2026-09-13T00:00:00Z" }
]
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(30.0));
assert_eq!(data[0].total, Some(50.0));
assert_eq!(data[0].used, Some(20.0));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
assert_eq!(data[1].plan_name.as_deref(), Some("five_hour"));
assert_eq!(data[1].remaining, Some(6.0));
assert_eq!(data[1].resets_at.as_deref(), Some("2026-09-13T05:00:00Z"));
assert_eq!(data[2].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[2].resets_at, None);
}
#[test]
fn sub2api_subscription_windows() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "claude-pro",
"unit": "USD",
"subscription": {
"daily_usage_usd": 1.0, "daily_limit_usd": 10.0,
"weekly_usage_usd": 8.0, "weekly_limit_usd": 30.0,
"monthly_usage_usd": 55.0, "monthly_limit_usd": 60.0
}
});
let result = parse_sub2api(&body);
assert!(result.success);
let data = result.data.unwrap();
assert_eq!(data.len(), 3);
assert_eq!(data[0].plan_name.as_deref(), Some("daily_limit"));
assert_eq!(data[0].remaining, Some(9.0));
assert_eq!(data[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(data[1].remaining, Some(22.0));
assert_eq!(data[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(data[2].remaining, Some(5.0));
assert_eq!(data[2].is_valid, Some(true));
}
#[test]
fn sub2api_wallet_balance() {
let body = json!({
"mode": "unrestricted",
"isValid": true,
"planName": "钱包余额",
"remaining": 12.5,
"unit": "USD",
"balance": 12.5
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data.len(), 1);
assert_eq!(data[0].plan_name.as_deref(), Some("Sub2API"));
assert_eq!(data[0].remaining, Some(12.5));
assert_eq!(data[0].unit.as_deref(), Some("USD"));
}
#[test]
fn sub2api_missing_mode_is_deterministic_failure() {
let body = json!({ "foo": 1 });
let result = parse_sub2api(&body);
assert!(!result.success);
assert!(result.error.unwrap().contains("Missing 'mode'"));
}
#[test]
fn sub2api_exhausted_window_is_invalid() {
let body = json!({
"mode": "quota_limited",
"isValid": true,
"quota": { "limit": 10.0, "used": 10.0, "remaining": 0.0, "unit": "USD" }
});
let data = parse_sub2api(&body).data.unwrap();
assert_eq!(data[0].is_valid, Some(false));
}
}
+23 -8
View File
@@ -23,6 +23,7 @@ pub enum UsageKind {
BalanceNovita,
BalanceKimi,
BalanceNewapi,
BalanceSub2Api,
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
@@ -40,6 +41,7 @@ impl UsageKind {
UsageKind::BalanceNovita => "balance:novita",
UsageKind::BalanceKimi => "balance:kimi",
UsageKind::BalanceNewapi => "balance:newapi",
UsageKind::BalanceSub2Api => "balance:sub2api",
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
@@ -47,7 +49,7 @@ impl UsageKind {
}
}
pub const ALL: [UsageKind; 11] = [
pub const ALL: [UsageKind; 12] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
@@ -55,6 +57,7 @@ impl UsageKind {
UsageKind::BalanceNovita,
UsageKind::BalanceKimi,
UsageKind::BalanceNewapi,
UsageKind::BalanceSub2Api,
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
@@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind {
"balance:novita" => UsageKind::BalanceNovita,
"balance:kimi" => UsageKind::BalanceKimi,
"balance:newapi" => UsageKind::BalanceNewapi,
"balance:sub2api" => UsageKind::BalanceSub2Api,
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
@@ -97,10 +101,13 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("openrouter.ai") {
kinds.push(UsageKind::BalanceOpenrouter);
}
// codingplan.site 检测为 NewAPI 实例(本工具仅支持 NewAPI 查询);
// 家族匹配意味着 ai. 子域也自然命中该规则。
if url.contains("codingplan.site") {
// codingplan.site 家族:主域(含任意子域除 ai. 外)是 Sub2API 面板
// (/api/v1/settings/public 指纹命中,NewAPI 端点全 404);
// ai.codingplan.site 才是 NewAPI 实例(/api/status、/api/user/self 可用)。
if url.contains("ai.codingplan.site") {
kinds.push(UsageKind::BalanceNewapi);
} else if url.contains("codingplan.site") {
kinds.push(UsageKind::BalanceSub2Api);
}
if url.contains("api.stepfun.com") {
kinds.push(UsageKind::BalanceStepfun);
@@ -186,6 +193,14 @@ pub async fn query_kind(
.unwrap_or(base_url);
balance::query_newapi(url, token, uid, timeout).await
}
UsageKind::BalanceSub2Api => {
// sub2api 复用推理 API Key,无需额外凭据;base_url 同样允许面板覆盖。
let url = usage_config
.and_then(|c| c.base_url.as_deref())
.filter(|s| !s.is_empty())
.unwrap_or(base_url);
balance::query_sub2api(url, api_key, timeout).await
}
UsageKind::PlanKimiCoding => {
coding_plan::query_kimi_coding(base_url, api_key, timeout).await
}
@@ -279,15 +294,15 @@ mod tests {
#[test]
fn detect_provider_codingplan_site_family() {
// codingplan.site 是 NewAPI 实例(本工具仅支持 NewAPI 查询):
// 主域与 ai. 子域都命中家族规则,且只映射到 BalanceNewapi。
// codingplan.site 家族分家:主域是 Sub2API 面板(推理 key 直查 /v1/usage),
// ai. 子域才是 NewAPI 实例。
assert_eq!(
detect_provider("https://codingplan.site"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://codingplan.site/v1"),
vec![UsageKind::BalanceNewapi]
vec![UsageKind::BalanceSub2Api]
);
assert_eq!(
detect_provider("https://ai.codingplan.site"),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.14",
"version": "0.7.17",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",
+37
View File
@@ -527,6 +527,18 @@ function ModelMapping({
});
};
const selectAllModels = () => {
if (!discovered) return;
setSelected(new Set(discovered.map((m) => m.id)));
};
const deselectAllModels = () => setSelected(new Set());
const invertSelection = () => {
if (!discovered) return;
setSelected((prev) => new Set(discovered.filter((m) => !prev.has(m.id)).map((m) => m.id)));
};
const handleAddSelected = () => {
if (!discovered || selected.size === 0) return;
const existingIds = new Set(models.filter((m) => m.model).map((m) => m.model));
@@ -596,9 +608,34 @@ function ModelMapping({
{discovered && (
<div className="bg-panel border border-border rounded-xl p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{t("discoveredModels", { count: discovered.length })}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={selectAllModels}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("selectAll")}
</button>
<button
type="button"
onClick={deselectAllModels}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("deselectAll")}
</button>
<button
type="button"
onClick={invertSelection}
className="px-2 py-0.5 text-xs border border-border rounded hover:bg-hover-2 text-content-primary transition-colors"
>
{t("invertSelection")}
</button>
</div>
</div>
<label className="flex items-center gap-2 text-sm text-content-primary cursor-pointer">
<input
type="checkbox"
+5 -61
View File
@@ -43,21 +43,15 @@ const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
};
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
"secondary-model": { name: "flagSecondaryModel", desc: "flagSecondaryModelDesc" },
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
"tool-select": { name: "flagToolSelect", desc: "flagToolSelectDesc" },
persistence_minidb_readmodel: {
name: "flagMinidbReadmodel",
desc: "flagMinidbReadmodelDesc",
},
notify_user: { name: "flagNotifyUser", desc: "flagNotifyUserDesc" },
tower: { name: "flagTower", desc: "flagTowerDesc" },
subagent_fork: { name: "flagSubagentFork", desc: "flagSubagentForkDesc" },
wait_for: { name: "flagWaitFor", desc: "flagWaitForDesc" },
auto_session_title: {
name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc",
},
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
};
/** Validation-error i18n key per engine rule, for the pre-write self-check. */
@@ -185,21 +179,6 @@ export function SubagentSettingsPage({
// off — upgrading with force would create an invalid force+pool-table combo.
const canUpgrade = !!pool && !isPoolView && pool.model !== undefined && !pool.force;
const masterOn = isMasterEnvOn(env);
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag. Mirrors the kimi-code
* 0.40.1 priority: single-flag env > explicit [experimental] value >
* master env (force-on only) > upstream default. The master env no
* longer locks the toggle — only the flag's own env var does. */
const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
? forcedEnvValue(env, secondaryFlag)
: isExperimentalFlagSet(rawOther, secondaryFlag.id)
? flags["secondary-model"] === true
: masterOn
? true
: (secondaryFlag.defaultEnabled ?? false);
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
const aliasList = Object.keys(models).sort();
@@ -573,41 +552,6 @@ export function SubagentSettingsPage({
)}
<div className="border-t border-border" />
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-content-primary">
{t(FLAG_LABELS[secondaryFlag.id].name)}
{!secondaryLocked &&
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
secondaryFlag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code>
</div>
</div>
{secondaryLocked && (
<span className="text-xs text-content-muted shrink-0">
🔒 {t("lockedByEnv")}
</span>
)}
<Toggle
checked={secondaryEnabled}
disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) =>
onChange(
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
})
)
}
/>
</div>
{effectiveDefault !== undefined && (
<div className="rounded-lg border border-border bg-input/40 px-3 py-2.5 space-y-2">
@@ -636,9 +580,9 @@ export function SubagentSettingsPage({
<p className="text-xs text-content-muted">{t("secondaryModelPriorityHint")}</p>
</Card>
{/* Other experimental features (not subagent-related) */}
<Card title={t("otherExperimentalFlags")}>
{otherFlags.map(renderFlagRow)}
{/* Experimental feature flags — one unified list */}
<Card title={t("experimentalFlags")}>
{EXPERIMENTAL_FLAGS.map(renderFlagRow)}
<p className="text-xs text-content-muted">{t("experimentalFlagsHint")}</p>
</Card>
</div>
+22 -4
View File
@@ -30,12 +30,13 @@ interface UsageConfigModalProps {
}
const TEMPLATES: ReadonlyArray<{
id: "auto" | "newapi";
id: "auto" | "newapi" | "sub2api";
labelKey: TranslationKey;
hintKey: TranslationKey;
}> = [
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
{ id: "sub2api", labelKey: "usageTemplateSub2api", hintKey: "usageSub2apiHint" },
];
function defaultConfig(provider: Provider): UsageConfig {
@@ -225,6 +226,8 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
<p className="text-xs text-content-muted">
{cfg.templateType === "newapi"
? t("usageNewapiHint")
: cfg.templateType === "sub2api"
? t("usageSub2apiHint")
: t("usageAutoDetectHint")}
</p>
</>
@@ -237,9 +240,9 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
<span className="text-blue-500">{"{{baseUrl}}"}</span>
<span className="text-content-muted">=</span>
<span className="text-content-primary break-all">
{cfg.templateType === "newapi"
? cfg.baseUrl || provider.base_url || "-"
: provider.base_url || "-"}
{cfg.templateType === "auto"
? provider.base_url || "-"
: cfg.baseUrl || provider.base_url || "-"}
</span>
</div>
<div className="flex items-center gap-2 text-xs font-mono">
@@ -344,6 +347,21 @@ export function UsageConfigModal({ open, agent, provider, onClose, onSave }: Usa
</div>
</div>
)}
{/* sub2api: no extra credentials (reuses the inference key), optional gateway override */}
{!isManaged && cfg.templateType === "sub2api" && (
<div className="space-y-3">
<div>
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
<input
type="text"
className="w-full 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.baseUrl ?? ""}
placeholder={provider.base_url ?? "https://your-sub2api-site.com"}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
/>
</div>
</div>
)}
</div>
{/* timeout + auto query interval */}
+22 -3
View File
@@ -8,10 +8,11 @@ import { fmtInt, fmtPct, fmtTime, fmtTokens } from "../../lib/dashboard-format";
import { DailyBars } from "./DailyBars";
import { DailyDetailModal } from "./DailyDetailModal";
import { Heatmap } from "./Heatmap";
import { ModelTotalsChart } from "./ModelTotalsChart";
import { TrendLineChart } from "./TrendLineChart";
import type { DailyRow, HeatmapCell } from "../../types/dashboard";
const RANGES: DashboardRange[] = ["today", "7d", "30d", "all"];
const RANGES: DashboardRange[] = ["today", "yesterday", "7d", "30d", "all"];
/** Per-request cache hit rate: cached tokens / total input tokens. */
function cacheHitOf(r: { inputOther: number; inputCacheRead: number }): number {
@@ -59,13 +60,14 @@ export function DashboardPage() {
const { range, changeRange, data, loading, error, refresh, loadStats } = useDashboard();
const [showAllModels, setShowAllModels] = useState(false);
const [recentPage, setRecentPage] = useState(1);
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider">("daily");
const [trendTab, setTrendTab] = useState<"daily" | "model" | "provider" | "totals">("daily");
const [heatmapSelected, setHeatmapSelected] = useState<DailyRow | null>(null);
const daily = data?.stats.daily ?? [];
const RANGE_LABELS: Record<DashboardRange, string> = {
today: t("rangeToday"),
yesterday: t("rangeYesterday"),
"7d": t("range7d"),
"30d": t("range30d"),
all: t("rangeAll"),
@@ -192,7 +194,7 @@ export function DashboardPage() {
</div>
{/* Range overview cards */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{RANGES.map((r) => {
const tot = rangeTotals[r];
if (!tot) return null;
@@ -272,10 +274,23 @@ export function DashboardPage() {
>
{t("tabProviderTrend")}
</button>
<button
type="button"
onClick={() => setTrendTab("totals")}
className={`px-3 py-2 -mb-px border-b-2 text-sm font-medium transition-colors ${
trendTab === "totals"
? "border-blue-500 text-content-primary"
: "border-transparent text-content-muted hover:text-content-primary"
}`}
>
{t("tabModelTotals")}
</button>
</div>
<span className="pb-2 text-xs text-content-muted">
{trendTab === "daily"
? t("trendLineSub", { n: trendDaily.length })
: trendTab === "totals"
? t("cardModelUsageSub", { n: data.stats.modelsByName.length })
: daily.length > 30
? t("cardDailyTrendSubCapped", { n: daily.length })
: t("cardDailyTrendSub", { n: daily.length })}
@@ -287,6 +302,10 @@ export function DashboardPage() {
<div className="flex-1 min-h-[240px]">
<TrendLineChart daily={trendDaily} />
</div>
) : trendTab === "totals" ? (
<div className="flex-1 min-h-[240px]">
<ModelTotalsChart rows={data.stats.modelsByName} />
</div>
) : (
<div className="flex-1 min-h-[240px]">
<DailyBars
@@ -0,0 +1,73 @@
import { useTranslation } from "../../i18n";
import { useCurrency } from "../../hooks/useCurrency";
import { fmtInt, fmtPct, fmtTokens } from "../../lib/dashboard-format";
import { modelColor } from "./DailyBars";
import type { ModelRow } from "../../types/dashboard";
interface ModelTotalsChartProps {
/** Provider-agnostic model totals, pre-sorted by totalTokens desc. */
rows: ModelRow[];
}
/** Horizontal ranked bars: one row per bare model name, providers merged. */
export function ModelTotalsChart({ rows }: ModelTotalsChartProps) {
const { t } = useTranslation();
const { money } = useCurrency();
if (!rows.length) {
return (
<div className="flex h-48 items-center justify-center text-sm text-content-muted">
{t("noData")}
</div>
);
}
const max = Math.max(...rows.map((m) => m.totalTokens || 0), 1);
return (
<div className="flex max-h-[360px] flex-col gap-1 overflow-y-auto">
{rows.map((m, i) => {
const color = i < 10 ? modelColor(i) : "#6b7280";
const name = m.isSecondary ? t("subagentModel") : m.modelDisplay || m.model;
const tooltip = [
name,
`${t("kpiRequests")}: ${fmtInt(m.requests)}`,
`${t("totalTokens")}: ${fmtInt(m.totalTokens)}`,
`${t("colCacheHit")}: ${fmtPct(m.cacheHitRate)}`,
`${t("cost")}: ${money(m.costUsd)}`,
].join("\n");
return (
<div
key={m.model}
title={tooltip}
className="group flex items-center gap-2 rounded px-1 py-1 cursor-help hover:bg-hover"
>
<span
className="inline-block h-2.5 w-2.5 rounded-sm shrink-0"
style={{ backgroundColor: color }}
/>
<span className="w-40 shrink-0 truncate text-xs text-content-primary">
{name}
{m.costEstimated && (
<span className="ml-1 text-[10px] text-yellow-600">({t("estimate")})</span>
)}
</span>
<div className="flex h-4 flex-1 items-center overflow-hidden rounded-sm bg-hover-2/50">
<div
className="h-full rounded-sm transition-[width] group-hover:opacity-85"
style={{
width: `${Math.max(2, (m.totalTokens / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
<span className="w-16 shrink-0 text-right text-xs text-content-muted">
{fmtTokens(m.totalTokens)}
</span>
</div>
);
})}
</div>
);
}
+2
View File
@@ -15,6 +15,7 @@ export type UsageKind =
| "balance:novita"
| "balance:kimi"
| "balance:newapi"
| "balance:sub2api"
| "plan:kimi_coding"
| "plan:zhipu"
| "plan:minimax"
@@ -32,6 +33,7 @@ export const SUPPORTED_USAGE_KINDS: ReadonlySet<string> = new Set<UsageKind>([
"balance:novita",
"balance:kimi",
"balance:newapi",
"balance:sub2api",
"plan:kimi_coding",
"plan:zhipu",
"plan:minimax",
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import type { SummaryResult } from "../types/dashboard";
export type DashboardRange = "today" | "7d" | "30d" | "all";
export type DashboardRange = "today" | "yesterday" | "7d" | "30d" | "all";
const RANGE_STORAGE_KEY = "kimi-switch-dashboard-range";
@@ -10,7 +10,7 @@ export function useDashboard() {
const [range, setRange] = useState<DashboardRange>(() => {
try {
const stored = localStorage.getItem(RANGE_STORAGE_KEY) as DashboardRange | null;
if (stored === "today" || stored === "7d" || stored === "30d" || stored === "all") {
if (stored === "today" || stored === "yesterday" || stored === "7d" || stored === "30d" || stored === "all") {
return stored;
}
} catch {
+8 -11
View File
@@ -190,6 +190,7 @@ export const enTranslations: Record<TranslationKey, string> = {
statusAll: "All",
selectAll: "Select all",
deselectAll: "Deselect all",
invertSelection: "Invert selection",
selectedCount: "{n} selected",
archive: "Archive",
unarchive: "Unarchive",
@@ -288,6 +289,7 @@ export const enTranslations: Record<TranslationKey, string> = {
// Dashboard — ranges / status
rangeToday: "Today",
rangeYesterday: "Yesterday",
range7d: "7d",
range30d: "30d",
rangeAll: "All",
@@ -309,6 +311,7 @@ export const enTranslations: Record<TranslationKey, string> = {
cardDailyTrendSubCapped: "Last 30 days · {n} days total",
tabModelTrend: "Model usage trend",
tabProviderTrend: "Provider–model usage trend",
tabModelTotals: "Model usage totals",
tabDailyTrend: "Daily usage trend",
providerUnknown: "Unknown provider",
cardDailyTrendSub: "{n} days",
@@ -406,8 +409,10 @@ export const enTranslations: Record<TranslationKey, string> = {
usageTemplate: "Query template",
usageTemplateAuto: "Auto-detect",
usageTemplateNewapi: "NewAPI gateway",
usageTemplateSub2api: "Sub2API gateway",
usageAutoDetectHint: "Query balance / plan with the provider's API Key and Base URL",
usageNewapiHint: "Query a NewAPI / OneAPI gateway balance (requires a web-console Access Token, not the sk- inference key)",
usageSub2apiHint: "Query a Sub2API (Wei-Shaw/sub2api) gateway balance / quota (GET /v1/usage). Reuses the inference sk- API key — no extra credentials; the query URL is optionally overridable",
usageOAuthManagedHint: "Official OAuth managed: queries the subscription with your Kimi Code login credentials",
usageDetectedKinds: "Detected query kinds",
usageNoKinds: "Not detected (switch to the NewAPI template to configure manually)",
@@ -428,6 +433,7 @@ export const enTranslations: Record<TranslationKey, string> = {
usageSwitchToNewapi: "Switch to NewAPI template",
usageUnsupportedProvider: "This provider is not supported by auto query",
usageTier5h: "5-hour",
usageTierDaily: "Daily",
usageTierWeekly: "Weekly",
usageTierMonthly: "Monthly",
usageErrNoKey: "No API key configured",
@@ -461,15 +467,10 @@ export const enTranslations: Record<TranslationKey, string> = {
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "Advanced Settings",
experimentalFlags: "Experimental Features",
otherExperimentalFlags: "Other Experimental Features",
experimentalFlagsHint:
"Toggles are written to the [experimental] section of config.toml; env vars take precedence and locked toggles cannot be changed here",
flagSecondaryModel: "Subagent Secondary Model",
flagSecondaryModelDesc: "Newly spawned subagents bind a second (usually cheaper) model config",
flagToolSelect: "Tool Select",
flagToolSelectDesc: "Enable the experimental tool selection feature",
flagMinidbReadmodel: "MiniDB Read Model",
flagMinidbReadmodelDesc: "Enable the v2 engine MiniDB read-model backend",
flagTower: "Tower Mode",
flagTowerDesc: "Enable tower mode: coordinate multiple agents on a shared objective (/tower command)",
flagSubagentFork: "Subagent Fork Context",
@@ -478,17 +479,13 @@ export const enTranslations: Record<TranslationKey, string> = {
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",
flagRemoteControl: "Remote Control",
flagRemoteControlDesc: "Enable the experimental remote control feature",
flagSearchWorker: "Search Worker",
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
flagNotifyUser: "NotifyUser tool",
flagNotifyUserDesc: "Experimental Updates panel: paginated progress messages from the main agent and subagents (off by default)",
flagDefaultOn: "On by default",
lockedByEnv: "Locked by env var",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
secondaryModelLabel: "Secondary model",
secondaryModelUnset: "Not configured",
secondaryModelInheritedContext: "Inherited context",
+8 -11
View File
@@ -187,6 +187,7 @@ export const zhTranslations = {
statusAll: "全部",
selectAll: "全选",
deselectAll: "取消全选",
invertSelection: "反选",
selectedCount: "已选 {n} 项",
archive: "归档",
unarchive: "取消归档",
@@ -283,6 +284,7 @@ export const zhTranslations = {
// Dashboard — ranges / status
rangeToday: "今天",
rangeYesterday: "昨天",
range7d: "7 天",
range30d: "30 天",
rangeAll: "全部",
@@ -304,6 +306,7 @@ export const zhTranslations = {
cardDailyTrendSubCapped: "近 30 天 · 共 {n} 天",
tabModelTrend: "模型用量趋势",
tabProviderTrend: "供应商模型用量趋势",
tabModelTotals: "模型用量统计",
tabDailyTrend: "每日用量趋势",
providerUnknown: "未知供应商",
cardDailyTrendSub: "共 {n} 天",
@@ -401,8 +404,10 @@ export const zhTranslations = {
usageTemplate: "查询模板",
usageTemplateAuto: "自动检测",
usageTemplateNewapi: "NewAPI 中转站",
usageTemplateSub2api: "Sub2API 中转站",
usageAutoDetectHint: "自动使用供应商的 API Key 和 Base URL 查询余额 / 套餐",
usageNewapiHint: "查询 NewAPI / OneAPI 中转站的账号余额(需网页后台 Access Token,不是 sk- 推理 Key)",
usageSub2apiHint: "查询 Sub2API (Wei-Shaw/sub2api) 面板的余额 / 额度(GET /v1/usage),自动复用推理 sk- API Key,无需额外凭据;可选覆盖查询地址",
usageOAuthManagedHint: "官方 OAuth 托管:自动使用 Kimi Code 登录凭据查询订阅用量",
usageDetectedKinds: "检测到的查询类型",
usageNoKinds: "未识别(可切换到 NewAPI 模板手动配置)",
@@ -423,6 +428,7 @@ export const zhTranslations = {
usageSwitchToNewapi: "切换到 NewAPI 模板",
usageUnsupportedProvider: "此供应商暂不支持自动查询",
usageTier5h: "5小时",
usageTierDaily: "每日",
usageTierWeekly: "7天",
usageTierMonthly: "30天",
usageErrNoKey: "未配置 API Key",
@@ -456,15 +462,10 @@ export const zhTranslations = {
// Advanced settings page (WebUI quick open + experimental flags + secondary model)
subagentSettings: "高级设置",
experimentalFlags: "实验功能开关",
otherExperimentalFlags: "其他实验功能",
experimentalFlagsHint:
"开关写入 config.toml 的 [experimental] 节;环境变量优先级更高,被锁定的开关无法在此修改",
flagSecondaryModel: "子代理次主力模型",
flagSecondaryModelDesc: "子代理派发时绑定第二份(通常更便宜的)模型配置",
flagToolSelect: "工具选择",
flagToolSelectDesc: "启用实验性工具选择功能",
flagMinidbReadmodel: "MiniDB 读模型",
flagMinidbReadmodelDesc: "启用 v2 引擎 MiniDB 读模型后端",
flagTower: "Tower 模式",
flagTowerDesc: "启用 Tower 多智能体协同模式(/tower 命令切换)",
flagSubagentFork: "子代理 Fork 上下文",
@@ -473,17 +474,13 @@ export const zhTranslations = {
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagRemoteControl: "远程控制",
flagRemoteControlDesc: "启用实验性远程控制功能",
flagSearchWorker: "搜索 Worker",
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
flagNotifyUser: "NotifyUser 工具",
flagNotifyUserDesc: "实验性 Updates 面板:分页展示主代理与子代理的进度消息(默认关闭)",
flagDefaultOn: "默认开启",
lockedByEnv: "被环境变量锁定",
masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
secondaryModelLabel: "次主力模型",
secondaryModelUnset: "未配置",
secondaryModelInheritedContext: "继承上下文长度",
+2564 -1648
View File
File diff suppressed because it is too large. Load diff
+2150 -1356
View File
File diff suppressed because it is too large. Load diff
+37 -17
View File
@@ -1,10 +1,12 @@
import { describe, expect, it } from "vitest";
import {
EXPERIMENTAL_FLAGS,
forcedEnvValue,
getExperimentalFlags,
getSecondaryModel,
getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv,
removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly,
@@ -163,13 +165,13 @@ describe("setSecondaryModelOnly", () => {
it("preserves other top-level raw_other sections", () => {
const raw = {
experimental: { "secondary-model": true },
experimental: { wait_for: true },
secondary_model: { model: "kimi-k1" },
};
const next = setSecondaryModelOnly(raw, "kimi-k2") as {
experimental?: unknown;
};
expect(next.experimental).toEqual({ "secondary-model": true });
expect(next.experimental).toEqual({ wait_for: true });
});
});
@@ -455,31 +457,49 @@ describe("validateSubagentPool", () => {
});
// ---------------------------------------------------------------------------
// [experimental] — flag registry (kimi-code 0.41.0) + write semantics
// [experimental] — flag registry (kimi-code 0.42.0) + write semantics
// ---------------------------------------------------------------------------
describe("experimental flag registry", () => {
it("mirrors the 0.41.0 v2 registry (9 flags, file_history removed)", () => {
it("mirrors the 0.42.0 v2 registry (6 flags)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"secondary-model",
"wait_for",
"tool-select",
"persistence_minidb_readmodel",
"notify_user",
"tower",
"subagent_fork",
"wait_for",
"auto_session_title",
]);
// Removed upstream: file_history (0.41.0, turn-level file history is
// always on) plus secondary-model / persistence_minidb_readmodel /
// remote-control / search_worker (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 [
"file_history",
"secondary-model",
"persistence_minidb_readmodel",
"remote-control",
"search_worker",
]);
// Upstream removed the flag in 0.41.0 (turn-level file history is
// always on), so the adapter must not mirror it either.
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === "file_history")).toBe(false);
]) {
expect(EXPERIMENTAL_FLAGS.some((f) => f.id === id)).toBe(false);
}
});
it("secondary-model is on by default since 0.40.1", () => {
expect(
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
).toBe(true);
it("notify_user is opt-in: no upstream default, own env var", () => {
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user");
expect(flag?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_NOTIFY_USER");
expect(flag?.defaultEnabled).toBeUndefined();
});
it("notify_user is locked by its env var and forced to the env value", () => {
const flag = EXPERIMENTAL_FLAGS.find((f) => f.id === "notify_user")!;
expect(isFlagLockedByEnv({}, flag)).toBe(false);
// A set env var locks the toggle even when it is falsy — it can force
// the flag on *or* off.
expect(isFlagLockedByEnv({ [flag.envVar]: "0" }, flag)).toBe(true);
expect(forcedEnvValue({ [flag.envVar]: "0" }, flag)).toBe(false);
expect(forcedEnvValue({ [flag.envVar]: "true" }, flag)).toBe(true);
});
});
@@ -514,13 +534,13 @@ describe("setExperimentalFlag", () => {
it("an explicit false coexists with other flags and keeps the section", () => {
const next = setExperimentalFlag(
rawExp({ "secondary-model": false }),
rawExp({ wait_for: false }),
"tower",
true,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental).toEqual({
"secondary-model": false,
wait_for: false,
tower: true,
});
});
+15 -28
View File
@@ -24,7 +24,7 @@ function isTruthyEnv(value: string | undefined): boolean {
// ---------------------------------------------------------------------------
export interface ExperimentalFlagDef {
/** Flag id as stored in config.toml `[experimental]` (e.g. "secondary-model"). */
/** Flag id as stored in config.toml `[experimental]` (e.g. "wait_for"). */
id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string;
@@ -33,43 +33,30 @@ export interface ExperimentalFlagDef {
defaultEnabled?: boolean;
}
/** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title, remote-control,
* search_worker). `acp-v2` was removed upstream and is dropped here;
* `file_history` was removed in kimi-code 0.41.0 (turn-level file
* history is always on, no flag). Keep the env-var list in sync with
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
/** Known experimental flags — mirrors the kimi-code 0.42.0 v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src):
* wait_for, tool-select, notify_user, tower, subagent_fork,
* auto_session_title. Removed in 0.42.0: secondary-model, remote-control
* and search_worker (promoted or dropped upstream — no flag left), and
* persistence_minidb_readmodel (now controlled by the `[database]` config
* section). `acp-v2` was removed upstream earlier; `file_history` was
* removed in kimi-code 0.41.0 (turn-level file history is always on, no
* flag). Keep the env-var list in sync with `get_experimental_env_status`
* in src-tauri/src/commands.rs. */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{
id: "secondary-model",
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
defaultEnabled: true, // on by default since kimi-code 0.40.1
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{
id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
defaultEnabled: true,
},
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{
id: "wait_for",
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
defaultEnabled: true,
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ id: "notify_user", envVar: "KIMI_CODE_EXPERIMENTAL_NOTIFY_USER" },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{
id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
},
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
{
id: "search_worker",
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
defaultEnabled: true,
},
];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
+2
View File
@@ -19,6 +19,8 @@ export function planLabel(name: string, t: TranslateFn): string {
switch (name) {
case "five_hour":
return t("usageTier5h");
case "daily_limit":
return t("usageTierDaily");
case "weekly_limit":
return t("usageTierWeekly");
case "monthly_limit":
+2
View File
@@ -52,6 +52,8 @@ export interface RangeStats {
totals: TotalsRow;
daily: DailyRow[];
models: ModelRow[];
/** Model totals keyed by bare model name — the same model across providers merges into one row. */
modelsByName: ModelRow[];
recent: RecentRow[];
recentTotal: number;
recentLimit: number;
+3 -2
View File
@@ -75,11 +75,12 @@ export interface Config {
/**
* Usage query configuration (mirrors the Rust `UsageConfig`, camelCase).
* "auto" = query kinds from usageKinds/host detect; "newapi" = query a
* NewAPI/OneAPI gateway with accessToken + userId.
* NewAPI/OneAPI gateway with accessToken + userId; "sub2api" = query a
* Sub2API (Wei-Shaw/sub2api) gateway, reusing the inference API key.
*/
export interface UsageConfig {
enabled: boolean;
templateType: "auto" | "newapi";
templateType: "auto" | "newapi" | "sub2api";
baseUrl?: string;
/** NewAPI web-console access token (NOT the sk- inference key). */
accessToken?: string;