feat(usage): v0.7.6 — OpenCode Go 套餐用量查询(5h/7天/30天三窗口)+ 插件市场入口暂时隐藏(功能保留)
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

This commit is contained in:
KimiSwitch Dev committed 2026-08-18 19:09:54 +08:00
1 parent 9a3875d990
commit 6b761251bc
24 files changed
+3353 -77

No files matched your search

+52 -1
View File
@@ -59,6 +59,15 @@ version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -710,6 +719,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1958,7 +1978,7 @@ dependencies = [
[[package]]
name = "kimiswitch"
version = "0.7.5"
version = "0.7.6"
dependencies = [
"anyhow",
"chrono",
@@ -1975,9 +1995,11 @@ dependencies = [
"tauri-build",
"tauri-plugin-opener",
"tauri-plugin-single-instance",
"tempfile",
"tokio",
"toml 0.8.23",
"walkdir",
"zip",
]
[[package]]
@@ -5484,12 +5506,41 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap 2.14.0",
"memchr",
"thiserror 2.0.18",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zvariant"
version = "5.12.0"
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "kimiswitch"
version = "0.7.5"
version = "0.7.6"
description = "Kimi Switch - model config manager"
authors = ["codingplan.site"]
edition = "2021"
@@ -25,7 +25,13 @@ futures-util = "0.3"
dirs = "5.0"
toml = "0.8"
rusqlite = { version = "0.32", features = ["bundled", "chrono"] }
tokio = { version = "1", features = ["sync"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
# Plugin marketplace: zip extraction (deflate-only; no extra compression
# backends needed for plugin archives).
zip = { version = "2", default-features = false, features = ["deflate"] }
[dev-dependencies]
tempfile = "3"
[lib]
name = "kimiswitch_lib"
+4 -1
View File
@@ -345,7 +345,10 @@ pub struct UsageRecord {
// Helpers
// ---------------------------------------------------------------------------
fn resolve_kimi_home(override_path: Option<String>) -> PathBuf {
/// Resolve the Kimi Code home directory: explicit override, then
/// `KIMI_CODE_HOME`, then the default `~/.kimi-code`. Shared with the plugin
/// marketplace module (`crate::plugins`).
pub(crate) fn resolve_kimi_home(override_path: Option<String>) -> PathBuf {
if let Some(p) = override_path {
if !p.trim().is_empty() {
return PathBuf::from(p);
+6
View File
@@ -6,6 +6,7 @@ pub mod kimi_code_io;
pub mod models;
pub mod oauth;
pub mod pi_io;
pub mod plugins;
pub mod services;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
@@ -124,6 +125,11 @@ pub fn run() {
dashboard::delete_session,
dashboard::delete_workspace,
dashboard::get_session_preview,
plugins::get_plugin_marketplace,
plugins::list_installed_plugins,
plugins::install_plugin,
plugins::set_plugin_enabled,
plugins::remove_plugin,
])
.manage(commands::KimiWebState::default())
.build(tauri::generate_context!())
File diff suppressed because it is too large. Load diff
+23 -4
View File
@@ -37,16 +37,35 @@ pub(crate) enum Fetched {
///
/// 先 `bytes()` 再解析:读体失败(超时/连接中断)是瞬时 → Err;拿到完整响应体
/// 后解析失败才是确定性。reqwest 的 `.json()` 把读体错误也包成 decode,无法区分。
///
/// 默认不带 User-Agent;需要自定义 UA 的供应商(如 opencode.ai 有 Cloudflare
/// 1010 拦截,必须带浏览器 UA)走 [`get_json_with_ua`]。
pub(crate) async fn get_json(
url: &str,
api_key: &str,
auth: AuthStyle,
timeout: Duration,
) -> Result<Fetched, String> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
get_json_with_ua(url, api_key, auth, timeout, None).await
}
/// [`get_json`] 的 UA 变体:`user_agent` 为 `Some` 时设到 client 上。
/// 仅新增链路需要,现有调用方不受影响。
pub(crate) async fn get_json_with_ua(
url: &str,
api_key: &str,
auth: AuthStyle,
timeout: Duration,
user_agent: Option<&str>,
) -> Result<Fetched, String> {
let mut builder = reqwest::Client::builder().timeout(timeout);
if let Some(ua) = user_agent {
builder = builder.user_agent(ua);
}
let client = match builder.build() {
Ok(c) => c,
Err(e) => return Err(format!("Failed to build HTTP client: {e}")),
};
// 注意:api_key 只允许进请求头,严禁拼进 URL / 日志 / 错误信息。
let req = client.get(url).header("Accept", "application/json");
+155 -6
View File
@@ -3,23 +3,24 @@
//! Token Plan 套餐额度查询服务
//!
//! 支持 Kimi For Coding、智谱 GLM、MiniMax 的套餐额度查询。
//! 支持 Kimi For Coding、智谱 GLM、MiniMax、OpenCode Go 的套餐额度查询。
//! cc-switch 的 SubscriptionQuota/tiers 结构在此展平为 `Vec<UsageData>`:
//! 每个窗口(tier)一条 UsageData,`plan_name` = tier 名("five_hour" /
//! "weekly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! "weekly_limit" / "monthly_limit"),`used` = 已用百分比(0-100),`total` = 100,
//! `remaining` = 剩余百分比,`resets_at` 为 ISO 8601 字符串。
//!
//! 错误通道语义与 balance.rs 一致(Err = 瞬时,Ok(success:false) = 确定性)。
use super::balance::{get_json, AuthStyle, Fetched};
use super::balance::{get_json, get_json_with_ua, AuthStyle, Fetched};
use super::usage_types::{UsageData, UsageResult};
use std::time::Duration;
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax 及未来新增)
// 都只用这两个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖此约定
// 做本地化映射——新增 tier id 时必须同步加映射。
// 套餐类 tier id 的唯一来源:所有套餐供应商(Kimi/智谱/MiniMax/OpenCode Go 及
// 未来新增)都只用这三个 id。前端 src/lib/usage-display.ts 的 planLabel() 依赖
// 此约定做本地化映射——新增 tier id 时必须同步加映射。
const TIER_FIVE_HOUR: &str = "five_hour";
const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
const TIER_MONTHLY_LIMIT: &str = "monthly_limit";
/// 套餐条目的统一构造:按百分比表示用量。
fn percent_tier(name: &str, used_percent: f64, resets_at: Option<String>) -> UsageData {
@@ -315,6 +316,81 @@ fn parse_minimax(body: &serde_json::Value) -> Result<Vec<UsageData>, UsageResult
Ok(tiers)
}
// ── OpenCode Go ─────────────────────────────────────────────
// GET https://opencode.ai/zen/go/v1/usage
// Response: { usage: { rolling: { status, percent, resetsAt },
// weekly: { status, percent, resetsAt },
// monthly: { status, percent, resetsAt } } }
// percent = 已用百分比(与 percent_tier 语义一致);status 仅作展示参考,缺失容忍;
// resetsAt 为 ISO 8601 字符串。
//
// 坑位:opencode.ai 有 Cloudflare 1010 拦截——reqwest 默认不带 User-Agent 的
// 裸请求会被 403(error code: 1010)。必须显式设置浏览器 UA。
const BROWSER_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
pub async fn query_opencode_go(
api_key: &str,
timeout: Duration,
) -> Result<UsageResult, String> {
match get_json_with_ua(
"https://opencode.ai/zen/go/v1/usage",
api_key,
AuthStyle::Bearer,
timeout,
Some(BROWSER_USER_AGENT),
)
.await?
{
Fetched::Body(body) => {
let tiers = parse_opencode_go(&body);
if tiers.is_empty() {
// 响应里没有可解析的用量窗口(usage 缺失或字段变了)。
// 把原始响应(仅用量数字,无密钥)透出,方便对照接口结构修复。
return Ok(opencode_go_empty_failure(&body));
}
Ok(UsageResult::ok(tiers))
}
Fetched::Failed(err) => Ok(err),
}
}
/// usage 全缺时的确定性失败,附 400 字原始响应预览。抽成纯函数便于离线单测。
fn opencode_go_empty_failure(body: &serde_json::Value) -> UsageResult {
let preview = serde_json::to_string(body)
.unwrap_or_else(|_| "<unserializable body>".into());
let trimmed: String = preview.chars().take(400).collect();
UsageResult::failure(format!(
"opencode go usage 响应无套餐数据,原始返回: {trimmed}"
))
}
/// 三个窗口(5 小时滚动 / 周 / 月)各解析为一条 `percent_tier`;
/// 缺失或 percent 不可解析的窗口跳过(status 字段可缺)。
fn parse_opencode_go(body: &serde_json::Value) -> Vec<UsageData> {
let mut tiers = Vec::new();
let Some(usage) = body.get("usage") else {
return tiers;
};
let windows = [
(TIER_FIVE_HOUR, "rolling"),
(TIER_WEEKLY_LIMIT, "weekly"),
(TIER_MONTHLY_LIMIT, "monthly"),
];
for (name, key) in windows {
let Some(window) = usage.get(key) else {
continue;
};
let Some(percent) = window.get("percent").and_then(parse_f64) else {
continue;
};
let resets_at = window.get("resetsAt").and_then(extract_reset_time);
tiers.push(percent_tier(name, percent, resets_at));
}
tiers
}
#[cfg(test)]
mod tests {
use super::*;
@@ -429,4 +505,77 @@ mod tests {
assert!(!err.success);
assert!(err.error.unwrap().contains("invalid key"));
}
#[test]
fn opencode_go_parses_three_windows() {
// 真实 key 实测样例(2026-08):percent 为已用百分比,直接透传
let body = json!({
"usage": {
"rolling": { "status": "ok", "percent": 9, "resetsAt": "2026-08-18T06:09:19.735Z" },
"weekly": { "status": "ok", "percent": 5, "resetsAt": "2026-08-24T00:00:00.735Z" },
"monthly": { "status": "ok", "percent": 59, "resetsAt": "2026-08-27T03:33:50.735Z" }
}
});
let tiers = parse_opencode_go(&body);
assert_eq!(tiers.len(), 3);
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(9.0));
assert_eq!(tiers[0].total, Some(100.0));
assert_eq!(tiers[0].remaining, Some(91.0));
assert_eq!(
tiers[0].resets_at.as_deref(),
Some("2026-08-18T06:09:19.735Z")
);
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[1].used, Some(5.0));
assert_eq!(tiers[1].remaining, Some(95.0));
assert_eq!(tiers[2].plan_name.as_deref(), Some("monthly_limit"));
assert_eq!(tiers[2].used, Some(59.0));
assert_eq!(tiers[2].remaining, Some(41.0));
}
#[test]
fn opencode_go_tolerates_missing_status_field() {
// status 属展示字段,缺失照常解析(percent/resetsAt 都只要求本身存在)
let body = json!({
"usage": {
"rolling": { "percent": 9, "resetsAt": "2026-08-18T06:09:19.735Z" },
"weekly": { "percent": 5, "resetsAt": "2026-08-24T00:00:00.735Z" },
"monthly": { "percent": 59, "resetsAt": "2026-08-27T03:33:50.735Z" }
}
});
let tiers = parse_opencode_go(&body);
assert_eq!(tiers.len(), 3);
assert_eq!(tiers[1].plan_name.as_deref(), Some("weekly_limit"));
assert_eq!(tiers[2].used, Some(59.0));
}
#[test]
fn opencode_go_partial_windows_only_emit_present_ones() {
// 单窗口缺失只出两条;percent 缺失 / 非数字的窗口整窗跳过
let body = json!({
"usage": {
"rolling": { "status": "ok", "percent": "9", "resetsAt": "2026-08-18T06:09:19.735Z" },
"monthly": { "status": "ok", "resetsAt": "2026-08-27T03:33:50.735Z" }
}
});
let tiers = parse_opencode_go(&body);
assert_eq!(tiers.len(), 1);
// percent 以字符串给出也兼容(parse_f64)
assert_eq!(tiers[0].plan_name.as_deref(), Some("five_hour"));
assert_eq!(tiers[0].used, Some(9.0));
}
#[test]
fn opencode_go_missing_usage_is_deterministic_failure_with_preview() {
// usage 全缺:确定性失败,附原始响应预览(400 字内,无密钥)
let body = json!({ "error": { "message": "boom" } });
assert!(parse_opencode_go(&body).is_empty());
let err = opencode_go_empty_failure(&body);
assert!(!err.success);
assert!(err.data.is_none());
let msg = err.error.unwrap();
assert!(msg.contains("原始返回"), "msg: {msg}");
assert!(msg.contains(r#""message":"boom""#), "msg: {msg}");
}
}
+29 -3
View File
@@ -3,7 +3,7 @@
//! 供应商账单/用量查询统一入口。
//!
//! - [`UsageKind`]:8 种查询类型,字符串形式与前端 / SQLite settings 约定一致
//! - [`UsageKind`]:11 种查询类型,字符串形式与前端 / SQLite settings 约定一致
//! (如 `"balance:deepseek"`、`"plan:kimi_coding"`)。
//! - [`detect_provider`]:按 base_url host 子串匹配,旧用户无显式配置时自动识别。
//! - [`query_kind`]:按 kind 路由到 balance / coding_plan 的具体实现。
@@ -26,6 +26,7 @@ pub enum UsageKind {
PlanKimiCoding,
PlanZhipu,
PlanMinimax,
PlanOpencodeGo,
}
impl UsageKind {
@@ -42,10 +43,11 @@ impl UsageKind {
UsageKind::PlanKimiCoding => "plan:kimi_coding",
UsageKind::PlanZhipu => "plan:zhipu",
UsageKind::PlanMinimax => "plan:minimax",
UsageKind::PlanOpencodeGo => "plan:opencode_go",
}
}
pub const ALL: [UsageKind; 10] = [
pub const ALL: [UsageKind; 11] = [
UsageKind::BalanceDeepseek,
UsageKind::BalanceSiliconflow,
UsageKind::BalanceOpenrouter,
@@ -56,6 +58,7 @@ impl UsageKind {
UsageKind::PlanKimiCoding,
UsageKind::PlanZhipu,
UsageKind::PlanMinimax,
UsageKind::PlanOpencodeGo,
];
}
@@ -74,6 +77,7 @@ impl std::str::FromStr for UsageKind {
"plan:kimi_coding" => UsageKind::PlanKimiCoding,
"plan:zhipu" => UsageKind::PlanZhipu,
"plan:minimax" => UsageKind::PlanMinimax,
"plan:opencode_go" => UsageKind::PlanOpencodeGo,
_ => return Err(()),
})
}
@@ -113,6 +117,10 @@ pub fn detect_provider(base_url: &str) -> Vec<UsageKind> {
if url.contains("api.minimaxi.com") {
kinds.push(UsageKind::PlanMinimax);
}
// OpenCode Go 套餐:只认 /zen/go 路径;/zen/v1(OpenCode Zen 按量付费)不得命中。
if url.contains("/zen/go") {
kinds.push(UsageKind::PlanOpencodeGo);
}
kinds
}
@@ -166,6 +174,7 @@ pub async fn query_kind(
UsageKind::PlanMinimax => {
coding_plan::query_minimax(api_key, !lower.contains("minimax.io"), timeout).await
}
UsageKind::PlanOpencodeGo => coding_plan::query_opencode_go(api_key, timeout).await,
}
}
@@ -175,7 +184,7 @@ mod tests {
#[test]
fn detect_provider_maps_known_hosts() {
let cases: [(&str, UsageKind); 9] = [
let cases: [(&str, UsageKind); 10] = [
("https://api.deepseek.com/v1", UsageKind::BalanceDeepseek),
("https://api.siliconflow.cn/v1", UsageKind::BalanceSiliconflow),
("https://openrouter.ai/api/v1", UsageKind::BalanceOpenrouter),
@@ -188,6 +197,7 @@ mod tests {
UsageKind::PlanZhipu,
),
("https://api.minimaxi.com/v1", UsageKind::PlanMinimax),
("https://opencode.ai/zen/go/v1", UsageKind::PlanOpencodeGo),
];
for (url, expected) in cases {
assert_eq!(
@@ -212,6 +222,22 @@ mod tests {
assert!(detect_provider("https://api.kimi.com/v1").is_empty());
}
#[test]
fn detect_provider_opencode_go_excludes_payg_zen() {
// /zen/go 命中套餐查询
assert_eq!(
detect_provider("https://opencode.ai/zen/go/v1"),
vec![UsageKind::PlanOpencodeGo]
);
// /zen/v1(OpenCode Zen 按量付费)不得命中
assert!(detect_provider("https://opencode.ai/zen/v1").is_empty());
// 裸域名也命中(go 套餐 base 无 v1 后缀时)
assert_eq!(
detect_provider("https://opencode.ai/zen/go"),
vec![UsageKind::PlanOpencodeGo]
);
}
#[test]
fn usage_kind_string_roundtrip() {
use std::str::FromStr;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"productName": "Kimi Switch",
"version": "0.7.5",
"version": "0.7.6",
"identifier": "com.kimiswitch.app",
"build": {
"beforeDevCommand": "npm run dev",