Files
KimiCodeSwitch/src-tauri/src/models.rs
T
KimiSwitch Dev ace9842556
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
feat: 新增部分套餐账单查询(v0.6.4)
- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天)
- managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态)
- 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询
- 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层)
- 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射)
- 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
2026-08-01 09:44:36 +08:00

280 lines
9.9 KiB
Rust

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Target agent whose provider/model config is being edited.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Agent {
KimiCode,
Pi,
}
impl Agent {
pub fn as_str(&self) -> &'static str {
match self {
Agent::KimiCode => "kimi_code",
Agent::Pi => "pi",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType {
Anthropic,
Openai,
#[serde(rename = "openai_responses")]
OpenaiResponses,
#[serde(rename = "google-genai")]
GoogleGenai,
Vertexai,
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
Kimi,
}
const fn default_true() -> bool {
true
}
impl ProviderType {
pub fn as_str(&self) -> &'static str {
match self {
ProviderType::Anthropic => "anthropic",
ProviderType::Openai => "openai",
ProviderType::OpenaiResponses => "openai_responses",
ProviderType::GoogleGenai => "google-genai",
ProviderType::Vertexai => "vertexai",
ProviderType::Kimi => "kimi",
}
}
pub fn default_base_url(&self) -> Option<&'static str> {
match self {
ProviderType::Openai | ProviderType::OpenaiResponses | ProviderType::Kimi => {
Some("https://api.openai.com/v1")
}
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
ProviderType::Anthropic | ProviderType::Vertexai => None,
}
}
pub fn is_openai_compatible(&self) -> bool {
matches!(
self,
ProviderType::Kimi | ProviderType::Openai | ProviderType::OpenaiResponses
)
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Provider {
pub name: String,
pub provider_type: ProviderType,
pub base_url: Option<String>,
pub api_key: Option<String>,
#[serde(default)]
pub env: IndexMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub official_url: Option<String>,
#[serde(default)]
pub managed: bool,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub active: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_color: Option<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
/// Billing/usage query kinds for this provider (e.g. "balance:deepseek",
/// "plan:kimi_coding"). Persisted in the SQLite settings table under
/// `usage_kinds:<provider_name>`, NOT in the agent's config.toml — both
/// export paths (kimi_code_io manual TOML, pi_io PiProvider struct) are
/// explicit and never serialize this field, while the IPC payload to the
/// frontend does carry it. Renamed to camelCase to match the TS
/// `Provider.usageKinds` field — without the rename the frontend reads
/// `undefined` and no usage footer ever renders.
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageKinds")]
pub usage_kinds: Option<Vec<String>>,
/// Usage query configuration (template, credentials, auto interval),
/// edited via the "配置用量查询" panel. Same persistence strategy as
/// `usage_kinds`: SQLite settings `usage_config:<provider_name>`, never
/// config.toml; IPC carries it as camelCase.
#[serde(default, skip_serializing_if = "Option::is_none", rename = "usageConfig")]
pub usage_config: Option<UsageConfig>,
}
impl PartialEq for Provider {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.provider_type == other.provider_type
&& self.base_url == other.base_url
&& self.api_key == other.api_key
&& self.env == other.env
&& self.note == other.note
&& self.official_url == other.official_url
&& self.managed == other.managed
&& self.enabled == other.enabled
&& self.active == other.active
&& self.icon == other.icon
&& self.icon_color == other.icon_color
&& self.raw_other == other.raw_other
&& self.usage_kinds == other.usage_kinds
&& self.usage_config == other.usage_config
}
}
impl Eq for Provider {}
impl std::fmt::Debug for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Provider")
.field("name", &self.name)
.field("provider_type", &self.provider_type)
.field("base_url", &self.base_url)
.field("api_key", &"<redacted>")
.field("env", &"<redacted>")
.field("managed", &self.managed)
.field("enabled", &self.enabled)
.field("active", &self.active)
.field("icon", &self.icon)
.field("icon_color", &self.icon_color)
.field("raw_other", &"<json>")
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Model {
pub alias: String,
pub provider: String,
pub model: String,
pub max_context_size: u64,
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_1m: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
}
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Model")
.field("alias", &self.alias)
.field("provider", &self.provider)
.field("model", &self.model)
.field("max_context_size", &self.max_context_size)
.field("display_name", &self.display_name)
.field("supports_1m", &self.supports_1m)
.field("capabilities", &self.capabilities)
.field("raw_other", &"<json>")
.finish()
}
}
impl PartialEq for Model {
fn eq(&self, other: &Self) -> bool {
self.alias == other.alias
&& self.provider == other.provider
&& self.model == other.model
&& self.max_context_size == other.max_context_size
&& self.display_name == other.display_name
&& self.supports_1m == other.supports_1m
&& self.capabilities == other.capabilities
&& self.raw_other == other.raw_other
}
}
impl Eq for Model {}
#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
pub default_model: Option<String>,
#[serde(default)]
pub providers: IndexMap<String, Provider>,
#[serde(default)]
pub models: IndexMap<String, Model>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value,
}
impl PartialEq for Config {
fn eq(&self, other: &Self) -> bool {
self.default_model == other.default_model
&& self.providers == other.providers
&& self.models == other.models
&& self.raw_other == other.raw_other
}
}
impl Eq for Config {}
/// Usage query configuration edited via the "配置用量查询" panel.
/// Mirrors cc-switch's `UsageScript` but trimmed to what Kimi Switch supports:
/// auto-detected kinds plus the NewAPI/OneAPI template. Serialized camelCase
/// to match the TS `UsageConfig` interface; persisted in SQLite settings, never
/// in the agent's config.toml.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UsageConfig {
/// Whether usage queries run for this provider at all.
pub enabled: bool,
/// "auto" = query the kinds in `usage_kinds` / host detection;
/// "newapi" = query a NewAPI/OneAPI gateway with accessToken + userId.
pub template_type: String,
/// NewAPI query base URL (falls back to the provider's base_url).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
/// NewAPI web-console access token (NOT the sk- inference key).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_token: Option<String>,
/// NewAPI user id, sent as the `New-Api-User` header.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
/// Auto query interval in minutes; 0/None = manual refresh only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_query_interval_minutes: Option<u32>,
/// Per-request timeout in seconds; 0/None = default (8s).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<u64>,
}
impl UsageConfig {
pub const TEMPLATE_AUTO: &'static str = "auto";
pub const TEMPLATE_NEWAPI: &'static str = "newapi";
}
impl std::fmt::Debug for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Config")
.field("default_model", &self.default_model)
.field(
"providers",
&format!(
"{:?} ({} providers)",
self.providers.keys().collect::<Vec<_>>(),
self.providers.len()
),
)
.field("models", &self.models)
.field("raw_other", &"<json>")
.finish()
}
}
/// A model discovered from a provider's API endpoint.
/// The frontend uses this to populate a `Model` form entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DiscoveredModel {
pub id: String,
pub display_name: Option<String>,
pub max_context_size: Option<u64>,
}