feat: implement Pi Switch i18n UI and agent config switch
This commit is contained in:
+227
-50
@@ -1,66 +1,243 @@
|
||||
use crate::models::{Config, ProfileSummary};
|
||||
use crate::models::{Agent, Config, DiscoveredModel, Provider, ProviderType};
|
||||
use crate::pi_io;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
fn fmt_anyhow(err: anyhow::Error) -> String {
|
||||
err.chain()
|
||||
.map(|e| e.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(": ")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_config_command() -> Config {
|
||||
Config {
|
||||
default_model: None,
|
||||
providers: indexmap::IndexMap::new(),
|
||||
models: indexmap::IndexMap::new(),
|
||||
raw_other: toml_edit::Table::new(),
|
||||
pub fn debug_log(message: String) {
|
||||
eprintln!("[frontend] {}", message);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
|
||||
match agent {
|
||||
Agent::KimiCode => crate::kimi_code_io::load_kimi_code_config_as_config()
|
||||
.map_err(fmt_anyhow),
|
||||
Agent::Pi => {
|
||||
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
|
||||
Ok(pi_io::pi_file_to_config(&file))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_config_command(_config: Config) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_profiles() -> Vec<ProfileSummary> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_profile(_filename: String) -> Config {
|
||||
Config {
|
||||
default_model: None,
|
||||
providers: indexmap::IndexMap::new(),
|
||||
models: indexmap::IndexMap::new(),
|
||||
raw_other: toml_edit::Table::new(),
|
||||
pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
|
||||
match agent {
|
||||
Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow),
|
||||
Agent::Pi => {
|
||||
let file = pi_io::config_to_pi_file(&config);
|
||||
pi_io::save_pi_models(&file).map_err(fmt_anyhow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_profile(_filename: String, _config: Config) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn switch_profile(_filename: String) -> Result<Config, String> {
|
||||
Ok(Config {
|
||||
default_model: None,
|
||||
providers: indexmap::IndexMap::new(),
|
||||
models: indexmap::IndexMap::new(),
|
||||
raw_other: toml_edit::Table::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_profile(_old_filename: String, _new_name: String) -> Result<String, String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(_filename: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_config_dir() -> Result<(), String> {
|
||||
Ok(())
|
||||
pub fn open_agent_config_dir(app: tauri::AppHandle, agent: Agent) -> Result<(), String> {
|
||||
let path = agent.config_dir();
|
||||
std::fs::create_dir_all(&path).map_err(|e| e.to_string())?;
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
app.opener()
|
||||
.open_path(&path_str, None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
/// Fetch available models from a provider's API.
|
||||
#[tauri::command]
|
||||
pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let api_key = resolve_api_key(&provider)
|
||||
.ok_or_else(|| format!("Provider '{}' has no API key configured", provider.name))?;
|
||||
|
||||
let base = resolve_base_url(&provider);
|
||||
|
||||
match provider.provider_type {
|
||||
ProviderType::Kimi | ProviderType::Openai | ProviderType::OpenaiResponses => {
|
||||
fetch_openai_models(&base, &api_key).await
|
||||
}
|
||||
ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await,
|
||||
ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await,
|
||||
ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_api_key(provider: &Provider) -> Option<String> {
|
||||
if provider.managed {
|
||||
return Some("managed".to_string());
|
||||
}
|
||||
if let Some(key) = &provider.api_key {
|
||||
if !key.is_empty() {
|
||||
return Some(key.clone());
|
||||
}
|
||||
}
|
||||
let env_key = expected_api_key_key(&provider.provider_type);
|
||||
provider.env.get(env_key).filter(|s| !s.is_empty()).cloned()
|
||||
}
|
||||
|
||||
fn resolve_base_url(provider: &Provider) -> String {
|
||||
provider
|
||||
.base_url
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
provider
|
||||
.provider_type
|
||||
.default_base_url()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn expected_api_key_key(provider_type: &ProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
ProviderType::Kimi => "KIMI_API_KEY",
|
||||
ProviderType::Anthropic => "ANTHROPIC_API_KEY",
|
||||
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
|
||||
ProviderType::GoogleGenai => "GOOGLE_API_KEY",
|
||||
ProviderType::Vertexai => "VERTEXAI_API_KEY",
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAI-compatible /models endpoint ──────────────────────────────
|
||||
|
||||
async fn fetch_openai_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!("{}/models", base.trim_end_matches('/'));
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OaiModel {
|
||||
id: String,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct OaiList {
|
||||
data: Vec<OaiModel>,
|
||||
}
|
||||
|
||||
let body: OaiList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| DiscoveredModel {
|
||||
id: m.id,
|
||||
display_name: None,
|
||||
max_context_size: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Anthropic /v1/models endpoint ───────────────────────────────────
|
||||
|
||||
async fn fetch_anthropic_models(base: &str, api_key: &str) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!("{}/v1/models", base.trim_end_matches('/'));
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("x-api-key", api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AntModel {
|
||||
id: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct AntList {
|
||||
data: Vec<AntModel>,
|
||||
}
|
||||
|
||||
let body: AntList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
|
||||
Ok(body
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| DiscoveredModel {
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
max_context_size: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
// ── Google GenAI /v1beta/models endpoint ────────────────────────────
|
||||
|
||||
async fn fetch_google_genai_models(
|
||||
base: &str,
|
||||
api_key: &str,
|
||||
) -> Result<Vec<DiscoveredModel>, String> {
|
||||
let url = format!(
|
||||
"{}/v1beta/models?key={}",
|
||||
base.trim_end_matches('/'),
|
||||
api_key
|
||||
);
|
||||
let resp = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request to {} failed: {e}", url))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("{} returned HTTP {}", url, resp.status()));
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GglModel {
|
||||
name: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: Option<String>,
|
||||
#[serde(rename = "outputTokenLimit")]
|
||||
output_token_limit: Option<u64>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GglList {
|
||||
models: Vec<GglModel>,
|
||||
}
|
||||
|
||||
let body: GglList = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("failed to parse response from {}: {e}", url))?;
|
||||
|
||||
Ok(body
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_string();
|
||||
DiscoveredModel {
|
||||
id,
|
||||
display_name: m.display_name,
|
||||
max_context_size: m.output_token_limit,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Kimi Code configuration I/O.
|
||||
//!
|
||||
//! Kimi Code stores its configuration in `~/.kimi-code/config.toml`.
|
||||
//! This module reads/writes that file and converts between Kimi's TOML
|
||||
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
use serde_json::Value;
|
||||
use toml::value::{Table, Value as TomlValue};
|
||||
|
||||
use crate::models::{Agent, Config, Model, Provider, ProviderType};
|
||||
|
||||
pub type KimiResult<T> = anyhow::Result<T>;
|
||||
|
||||
pub fn kimi_code_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".kimi-code"))
|
||||
.expect("failed to resolve home directory")
|
||||
}
|
||||
|
||||
pub fn kimi_code_config_path() -> PathBuf {
|
||||
kimi_code_config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
/// Read the existing Kimi Code `config.toml`, or return an empty table if it
|
||||
/// does not exist yet.
|
||||
pub fn load_kimi_code_config() -> KimiResult<TomlValue> {
|
||||
let path = kimi_code_config_path();
|
||||
if !path.exists() {
|
||||
return Ok(TomlValue::Table(Table::new()));
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let value: TomlValue = content
|
||||
.parse()
|
||||
.with_context(|| format!("failed to parse {}", path.display()))?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Write the given TOML value to Kimi Code's config file, creating the
|
||||
/// directory if needed and backing up the previous file.
|
||||
pub fn save_kimi_code_config(value: &TomlValue) -> KimiResult<()> {
|
||||
let path = kimi_code_config_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create directory {}", parent.display()))?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let timestamp = chrono::Local::now()
|
||||
.format("%Y%m%d_%H%M%S_%.3f")
|
||||
.to_string();
|
||||
let mut backup_path = path.with_extension(format!("toml.bak.{}", timestamp));
|
||||
if backup_path.exists() {
|
||||
for n in 1..1000 {
|
||||
let candidate = path.with_extension(format!("toml.bak.{}.{:03}", timestamp, n));
|
||||
if !candidate.exists() {
|
||||
backup_path = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::copy(&path, &backup_path)
|
||||
.with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?;
|
||||
}
|
||||
|
||||
let content = toml::to_string_pretty(value)
|
||||
.with_context(|| "failed to serialize Kimi Code config.toml")?;
|
||||
std::fs::write(&path, content)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import a Kimi Code TOML config into Pi Switch's internal `Config`.
|
||||
pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
||||
let root = value.as_table().cloned().unwrap_or_default();
|
||||
|
||||
let default_model = root
|
||||
.get("default_model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let mut providers = IndexMap::new();
|
||||
let mut models = IndexMap::new();
|
||||
|
||||
if let Some(providers_table) = root.get("providers").and_then(|v| v.as_table()) {
|
||||
for (name, pv) in providers_table {
|
||||
let table = pv.as_table().cloned().unwrap_or_default();
|
||||
let provider_type = table
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(provider_type_for_kimi_type)
|
||||
.unwrap_or(ProviderType::Kimi);
|
||||
|
||||
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 managed = table.contains_key("oauth")
|
||||
|| 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 raw_other = {
|
||||
let mut rest = table.clone();
|
||||
rest.remove("type");
|
||||
rest.remove("base_url");
|
||||
rest.remove("api_key");
|
||||
rest.remove("managed");
|
||||
rest.remove("enabled");
|
||||
rest.remove("oauth");
|
||||
toml_value_to_json(&TomlValue::Table(rest))
|
||||
};
|
||||
|
||||
providers.insert(
|
||||
name.clone(),
|
||||
Provider {
|
||||
name: name.clone(),
|
||||
provider_type,
|
||||
base_url: base_url.filter(|s| !s.is_empty()),
|
||||
api_key: api_key.filter(|s| !s.is_empty()),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed,
|
||||
enabled,
|
||||
raw_other,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(models_table) = root.get("models").and_then(|v| v.as_table()) {
|
||||
for (alias, mv) in models_table {
|
||||
let table = mv.as_table().cloned().unwrap_or_default();
|
||||
let provider = table
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let model_id = table
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let max_context_size = table
|
||||
.get("max_context_size")
|
||||
.and_then(|v| v.as_integer())
|
||||
.map(|n| n as u64)
|
||||
.unwrap_or(128_000);
|
||||
let display_name = table
|
||||
.get("display_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let capabilities: Vec<String> = table
|
||||
.get("capabilities")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let supports_1m = capabilities.iter().any(|c| c == "thinking")
|
||||
|| max_context_size >= 1_000_000;
|
||||
|
||||
let raw_other = {
|
||||
let mut rest = table.clone();
|
||||
rest.remove("provider");
|
||||
rest.remove("model");
|
||||
rest.remove("max_context_size");
|
||||
rest.remove("display_name");
|
||||
rest.remove("capabilities");
|
||||
toml_value_to_json(&TomlValue::Table(rest))
|
||||
};
|
||||
|
||||
models.insert(
|
||||
alias.clone(),
|
||||
Model {
|
||||
alias: alias.clone(),
|
||||
provider,
|
||||
model: model_id,
|
||||
max_context_size,
|
||||
display_name,
|
||||
role: None,
|
||||
supports_1m,
|
||||
capabilities,
|
||||
raw_other,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let raw_other = {
|
||||
let mut rest = root;
|
||||
rest.remove("default_model");
|
||||
rest.remove("providers");
|
||||
rest.remove("models");
|
||||
toml_value_to_json(&TomlValue::Table(rest))
|
||||
};
|
||||
|
||||
Config {
|
||||
default_model,
|
||||
providers,
|
||||
models,
|
||||
raw_other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Export Pi Switch's internal `Config` to a Kimi Code TOML config value.
|
||||
///
|
||||
/// If `existing` is provided, unknown top-level sections (e.g. `services`) are
|
||||
/// preserved; otherwise a fresh TOML table is used.
|
||||
pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> TomlValue {
|
||||
let mut root = existing
|
||||
.and_then(|v| v.as_table().cloned())
|
||||
.unwrap_or_default();
|
||||
|
||||
root.insert(
|
||||
"default_model".to_string(),
|
||||
config
|
||||
.default_model
|
||||
.clone()
|
||||
.map(TomlValue::String)
|
||||
.unwrap_or(TomlValue::String("".to_string())),
|
||||
);
|
||||
|
||||
let mut providers_table = Table::new();
|
||||
for (name, provider) in &config.providers {
|
||||
let mut pt = Table::new();
|
||||
pt.insert("type".to_string(), TomlValue::String(provider.provider_type.as_str().to_string()));
|
||||
if let Some(base_url) = provider.base_url.clone().filter(|s| !s.is_empty()) {
|
||||
pt.insert("base_url".to_string(), TomlValue::String(base_url));
|
||||
}
|
||||
if let Some(api_key) = provider.api_key.clone().filter(|s| !s.is_empty()) {
|
||||
pt.insert("api_key".to_string(), TomlValue::String(api_key));
|
||||
}
|
||||
pt.insert("enabled".to_string(), TomlValue::Boolean(provider.enabled));
|
||||
if provider.managed {
|
||||
// Preserve existing oauth config if present, otherwise create a default entry.
|
||||
let oauth = provider
|
||||
.raw_other
|
||||
.get("oauth")
|
||||
.and_then(json_to_toml)
|
||||
.unwrap_or_else(|| {
|
||||
let mut t = Table::new();
|
||||
t.insert("storage".to_string(), TomlValue::String("file".to_string()));
|
||||
t.insert("key".to_string(), TomlValue::String(format!("oauth/{}", name.replace(':', "-"))));
|
||||
TomlValue::Table(t)
|
||||
});
|
||||
pt.insert("oauth".to_string(), oauth);
|
||||
}
|
||||
// Merge remaining raw fields (oauth is handled explicitly above).
|
||||
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
|
||||
extra.remove("oauth");
|
||||
for (k, v) in extra {
|
||||
pt.insert(k, v);
|
||||
}
|
||||
}
|
||||
providers_table.insert(name.clone(), TomlValue::Table(pt));
|
||||
}
|
||||
root.insert("providers".to_string(), TomlValue::Table(providers_table));
|
||||
|
||||
let mut models_table = Table::new();
|
||||
for (alias, model) in &config.models {
|
||||
let mut mt = Table::new();
|
||||
mt.insert("provider".to_string(), TomlValue::String(model.provider.clone()));
|
||||
mt.insert("model".to_string(), TomlValue::String(model.model.clone()));
|
||||
mt.insert(
|
||||
"max_context_size".to_string(),
|
||||
TomlValue::Integer(model.max_context_size as i64),
|
||||
);
|
||||
if let Some(display_name) = model.display_name.clone().filter(|s| !s.is_empty()) {
|
||||
mt.insert("display_name".to_string(), TomlValue::String(display_name));
|
||||
}
|
||||
let mut capabilities = model.capabilities.clone();
|
||||
if model.supports_1m && !capabilities.iter().any(|c| c == "thinking") {
|
||||
capabilities.push("thinking".to_string());
|
||||
}
|
||||
if !capabilities.is_empty() {
|
||||
mt.insert(
|
||||
"capabilities".to_string(),
|
||||
TomlValue::Array(capabilities.into_iter().map(TomlValue::String).collect()),
|
||||
);
|
||||
}
|
||||
if let TomlValue::Table(mut extra) = json_to_toml(&model.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
|
||||
extra.remove("provider");
|
||||
extra.remove("model");
|
||||
extra.remove("max_context_size");
|
||||
extra.remove("display_name");
|
||||
extra.remove("capabilities");
|
||||
for (k, v) in extra {
|
||||
mt.insert(k, v);
|
||||
}
|
||||
}
|
||||
models_table.insert(alias.clone(), TomlValue::Table(mt));
|
||||
}
|
||||
root.insert("models".to_string(), TomlValue::Table(models_table));
|
||||
|
||||
TomlValue::Table(root)
|
||||
}
|
||||
|
||||
fn provider_type_for_kimi_type(typ: &str) -> ProviderType {
|
||||
match typ {
|
||||
"anthropic" => ProviderType::Anthropic,
|
||||
"openai" => ProviderType::Openai,
|
||||
"openai_responses" => ProviderType::OpenaiResponses,
|
||||
"google-genai" => ProviderType::GoogleGenai,
|
||||
"vertexai" => ProviderType::Vertexai,
|
||||
_ => ProviderType::Kimi,
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_value_to_json(value: &TomlValue) -> Value {
|
||||
match value {
|
||||
TomlValue::String(s) => Value::String(s.clone()),
|
||||
TomlValue::Integer(n) => Value::Number((*n).into()),
|
||||
TomlValue::Float(n) => Value::Number(serde_json::Number::from_f64(*n).unwrap_or(Value::Null.as_u64().unwrap_or(0).into())),
|
||||
TomlValue::Boolean(b) => Value::Bool(*b),
|
||||
TomlValue::Array(arr) => Value::Array(arr.iter().map(toml_value_to_json).collect()),
|
||||
TomlValue::Table(t) => {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (k, v) in t {
|
||||
map.insert(k.clone(), toml_value_to_json(v));
|
||||
}
|
||||
Value::Object(map)
|
||||
}
|
||||
TomlValue::Datetime(dt) => Value::String(dt.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_toml(value: &Value) -> Option<TomlValue> {
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::Bool(b) => Some(TomlValue::Boolean(*b)),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Some(TomlValue::Integer(i))
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Some(TomlValue::Float(f))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Value::String(s) => Some(TomlValue::String(s.clone())),
|
||||
Value::Array(arr) => Some(TomlValue::Array(
|
||||
arr.iter().filter_map(json_to_toml).collect(),
|
||||
)),
|
||||
Value::Object(obj) => {
|
||||
let mut t = Table::new();
|
||||
for (k, v) in obj {
|
||||
if let Some(tv) = json_to_toml(v) {
|
||||
t.insert(k.clone(), tv);
|
||||
}
|
||||
}
|
||||
Some(TomlValue::Table(t))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience entry point used by commands: load and convert.
|
||||
pub fn load_kimi_code_config_as_config() -> KimiResult<Config> {
|
||||
let value = load_kimi_code_config()?;
|
||||
Ok(kimi_code_to_config(&value))
|
||||
}
|
||||
|
||||
/// Convenience entry point used by commands: convert and save.
|
||||
pub fn save_config_as_kimi_code(config: &Config) -> KimiResult<()> {
|
||||
let existing = load_kimi_code_config().ok();
|
||||
let value = config_to_kimi_code(config, existing.as_ref());
|
||||
save_kimi_code_config(&value)
|
||||
}
|
||||
|
||||
/// Open the Kimi Code config directory in the system file manager.
|
||||
pub fn open_kimi_code_config_dir() -> KimiResult<()> {
|
||||
let path = kimi_code_config_dir();
|
||||
std::fs::create_dir_all(&path)
|
||||
.with_context(|| format!("failed to create directory {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn config_dir(&self) -> PathBuf {
|
||||
match self {
|
||||
Agent::KimiCode => kimi_code_config_dir(),
|
||||
Agent::Pi => crate::pi_io::pi_agent_dir(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kimi_code_import_extracts_providers_and_models() {
|
||||
let toml_str = r#"
|
||||
default_model = "kimi-code/kimi-for-coding"
|
||||
default_thinking = true
|
||||
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
api_key = ""
|
||||
base_url = "https://api.kimi.com/coding/v1"
|
||||
|
||||
[providers."managed:kimi-code".oauth]
|
||||
storage = "file"
|
||||
key = "oauth/kimi-code"
|
||||
|
||||
[models."kimi-code/kimi-for-coding"]
|
||||
provider = "managed:kimi-code"
|
||||
model = "kimi-for-coding"
|
||||
max_context_size = 262144
|
||||
capabilities = ["thinking", "always_thinking", "image_in", "video_in", "tool_use"]
|
||||
display_name = "K2.7 Code"
|
||||
|
||||
[services.moonshot_search]
|
||||
base_url = "https://api.kimi.com/coding/v1/search"
|
||||
api_key = ""
|
||||
"#;
|
||||
let value: TomlValue = toml_str.parse().unwrap();
|
||||
let config = kimi_code_to_config(&value);
|
||||
|
||||
assert_eq!(config.default_model.as_deref(), Some("kimi-code/kimi-for-coding"));
|
||||
assert_eq!(config.providers.len(), 1);
|
||||
assert_eq!(config.models.len(), 1);
|
||||
|
||||
let provider = config.providers.get("managed:kimi-code").unwrap();
|
||||
assert_eq!(provider.provider_type, ProviderType::Kimi);
|
||||
assert!(provider.managed);
|
||||
assert_eq!(provider.base_url.as_deref(), Some("https://api.kimi.com/coding/v1"));
|
||||
|
||||
let model = config.models.get("kimi-code/kimi-for-coding").unwrap();
|
||||
assert_eq!(model.provider, "managed:kimi-code");
|
||||
assert_eq!(model.model, "kimi-for-coding");
|
||||
assert_eq!(model.max_context_size, 262144);
|
||||
assert!(model.capabilities.contains(&"thinking".to_string()));
|
||||
assert!(model.supports_1m);
|
||||
|
||||
// Unknown top-level sections are preserved.
|
||||
let services = config.raw_other.get("services");
|
||||
assert!(services.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_code_export_roundtrip_preserves_model() {
|
||||
let mut providers = IndexMap::new();
|
||||
providers.insert(
|
||||
"glmzhongzhuan".to_string(),
|
||||
Provider {
|
||||
name: "glmzhongzhuan".to_string(),
|
||||
provider_type: ProviderType::Anthropic,
|
||||
base_url: Some("https://fast.cdks.work".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed: false,
|
||||
enabled: true,
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
let mut models = IndexMap::new();
|
||||
models.insert(
|
||||
"glm-5.2".to_string(),
|
||||
Model {
|
||||
alias: "glm-5.2".to_string(),
|
||||
provider: "glmzhongzhuan".to_string(),
|
||||
model: "glm-5.2".to_string(),
|
||||
max_context_size: 900_000,
|
||||
display_name: None,
|
||||
role: None,
|
||||
supports_1m: true,
|
||||
capabilities: vec!["thinking".to_string()],
|
||||
raw_other: Value::Null,
|
||||
},
|
||||
);
|
||||
let config = Config {
|
||||
default_model: Some("glm-5.2".to_string()),
|
||||
providers,
|
||||
models,
|
||||
raw_other: Value::Null,
|
||||
};
|
||||
|
||||
let exported = config_to_kimi_code(&config, None);
|
||||
let root = exported.as_table().unwrap();
|
||||
assert_eq!(
|
||||
root.get("default_model").and_then(|v| v.as_str()),
|
||||
Some("glm-5.2")
|
||||
);
|
||||
|
||||
let providers_table = root.get("providers").unwrap().as_table().unwrap();
|
||||
let provider = providers_table.get("glmzhongzhuan").unwrap().as_table().unwrap();
|
||||
assert_eq!(provider.get("type").and_then(|v| v.as_str()), Some("anthropic"));
|
||||
assert_eq!(provider.get("api_key").and_then(|v| v.as_str()), Some("sk-test"));
|
||||
|
||||
let models_table = root.get("models").unwrap().as_table().unwrap();
|
||||
let model = models_table.get("glm-5.2").unwrap().as_table().unwrap();
|
||||
assert_eq!(model.get("model").and_then(|v| v.as_str()), Some("glm-5.2"));
|
||||
let caps = model.get("capabilities").unwrap().as_array().unwrap();
|
||||
assert!(caps.iter().any(|v| v.as_str() == Some("thinking")));
|
||||
}
|
||||
}
|
||||
+8
-13
@@ -1,23 +1,18 @@
|
||||
pub mod commands;
|
||||
pub mod config_io;
|
||||
pub mod kimi_code_io;
|
||||
pub mod models;
|
||||
pub mod profile_manager;
|
||||
pub mod validators;
|
||||
pub mod pi_io;
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::load_config_command,
|
||||
commands::save_config_command,
|
||||
commands::list_profiles,
|
||||
commands::load_profile,
|
||||
commands::save_profile,
|
||||
commands::switch_profile,
|
||||
commands::rename_profile,
|
||||
commands::delete_profile,
|
||||
commands::open_config_dir,
|
||||
commands::load_agent_config_command,
|
||||
commands::save_agent_config_command,
|
||||
commands::open_agent_config_dir,
|
||||
commands::get_app_version,
|
||||
commands::list_provider_models,
|
||||
commands::debug_log,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+146
-27
@@ -1,11 +1,27 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use toml_edit::Table;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// 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 {
|
||||
Kimi,
|
||||
Anthropic,
|
||||
Openai,
|
||||
#[serde(rename = "openai_responses")]
|
||||
@@ -13,19 +29,42 @@ pub enum ProviderType {
|
||||
#[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::Kimi => "kimi",
|
||||
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)]
|
||||
@@ -34,11 +73,37 @@ pub struct Provider {
|
||||
pub provider_type: ProviderType,
|
||||
pub base_url: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: IndexMap<String, String>,
|
||||
#[serde(skip)]
|
||||
pub raw_other: Table,
|
||||
#[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, skip_serializing_if = "Value::is_null")]
|
||||
pub raw_other: Value,
|
||||
}
|
||||
|
||||
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.raw_other == other.raw_other
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -47,53 +112,107 @@ impl std::fmt::Debug for Provider {
|
||||
.field("base_url", &self.base_url)
|
||||
.field("api_key", &"<redacted>")
|
||||
.field("env", &"<redacted>")
|
||||
.field("raw_other", &"<table>")
|
||||
.field("managed", &self.managed)
|
||||
.field("enabled", &self.enabled)
|
||||
.field("raw_other", &"<json>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[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(skip)]
|
||||
pub raw_other: Table,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: 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("role", &self.role)
|
||||
.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.role == other.role
|
||||
&& 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(skip)]
|
||||
pub raw_other: Table,
|
||||
#[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 {}
|
||||
|
||||
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.len()))
|
||||
.field(
|
||||
"providers",
|
||||
&format!(
|
||||
"{:?} ({} providers)",
|
||||
self.providers.keys().collect::<Vec<_>>(),
|
||||
self.providers.len()
|
||||
),
|
||||
)
|
||||
.field("models", &self.models)
|
||||
.field("raw_other", &"<table>")
|
||||
.field("raw_other", &"<json>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileSummary {
|
||||
pub name: String,
|
||||
pub filename: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
pub name: String,
|
||||
pub filename: String,
|
||||
pub config: Config,
|
||||
pub is_active: bool,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! Pi CLI configuration I/O.
|
||||
//!
|
||||
//! Pi stores custom providers and models in `~/.pi/agent/models.json`.
|
||||
//! This module reads and writes that file and converts between Pi's JSON
|
||||
//! format and Pi Switch's internal `Config`/`Provider`/`Model` types.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::models::{Config, Model, Provider, ProviderType};
|
||||
|
||||
pub type PiResult<T> = anyhow::Result<T>;
|
||||
|
||||
/// Returns the default Pi agent config directory for the current user.
|
||||
pub fn pi_agent_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".pi").join("agent"))
|
||||
.expect("failed to resolve home directory")
|
||||
}
|
||||
|
||||
/// Returns the path to Pi's `models.json` file.
|
||||
pub fn pi_models_path() -> PathBuf {
|
||||
pi_agent_dir().join("models.json")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PiCost {
|
||||
pub input: f64,
|
||||
pub output: f64,
|
||||
#[serde(rename = "cacheRead", default)]
|
||||
pub cache_read: f64,
|
||||
#[serde(rename = "cacheWrite", default)]
|
||||
pub cache_write: f64,
|
||||
}
|
||||
|
||||
/// A single Pi model entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PiModel {
|
||||
pub id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reasoning: bool,
|
||||
#[serde(default = "default_input", skip_serializing_if = "is_default_input")]
|
||||
pub input: Vec<String>,
|
||||
#[serde(rename = "contextWindow", default = "default_context_window")]
|
||||
pub context_window: u64,
|
||||
#[serde(rename = "maxTokens", default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cost: Option<PiCost>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
fn default_input() -> Vec<String> {
|
||||
vec!["text".to_string()]
|
||||
}
|
||||
|
||||
fn is_default_input(input: &[String]) -> bool {
|
||||
input.len() == 1 && input.first().map(String::as_str) == Some("text")
|
||||
}
|
||||
|
||||
fn default_context_window() -> u64 {
|
||||
128_000
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// A single Pi provider entry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PiProvider {
|
||||
#[serde(rename = "baseUrl", default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api: Option<String>,
|
||||
#[serde(rename = "apiKey", default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub headers: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compat: Option<Value>,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub models: Vec<PiModel>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
/// Root Pi `models.json` structure.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PiModelsFile {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub providers: IndexMap<String, PiProvider>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Value,
|
||||
}
|
||||
|
||||
/// Read the existing Pi `models.json` file, or return a default empty struct
|
||||
/// if the file does not exist yet.
|
||||
pub fn load_pi_models() -> PiResult<PiModelsFile> {
|
||||
let path = pi_models_path();
|
||||
if !path.exists() {
|
||||
return Ok(PiModelsFile::default());
|
||||
}
|
||||
let content = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("failed to read {}", path.display()))?;
|
||||
let file: PiModelsFile = serde_json::from_str(&content)
|
||||
.with_context(|| format!("failed to parse {}", path.display()))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Write the given Pi models file, creating the parent directory if needed.
|
||||
/// A backup of the previous file is created when it already exists.
|
||||
pub fn save_pi_models(file: &PiModelsFile) -> PiResult<()> {
|
||||
let path = pi_models_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create directory {}", parent.display()))?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
let timestamp = chrono::Local::now()
|
||||
.format("%Y%m%d_%H%M%S_%.3f")
|
||||
.to_string();
|
||||
let mut backup_path = path.with_extension(format!("json.bak.{}", timestamp));
|
||||
if backup_path.exists() {
|
||||
for n in 1..1000 {
|
||||
let candidate = path.with_extension(format!("json.bak.{}.{:03}", timestamp, n));
|
||||
if !candidate.exists() {
|
||||
backup_path = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::copy(&path, &backup_path)
|
||||
.with_context(|| format!("failed to back up {} to {}", path.display(), backup_path.display()))?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(file)
|
||||
.with_context(|| "failed to serialize Pi models.json")?;
|
||||
std::fs::write(&path, content)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert a Pi Switch provider type to the Pi `api` field value.
|
||||
pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
ProviderType::Openai => "openai-completions",
|
||||
ProviderType::OpenaiResponses => "openai-responses",
|
||||
ProviderType::Anthropic => "anthropic-messages",
|
||||
ProviderType::GoogleGenai => "google-generative-ai",
|
||||
ProviderType::Vertexai => "google-vertex",
|
||||
// Treat Kimi as OpenAI-compatible since it is not a native Pi API.
|
||||
ProviderType::Kimi => "openai-completions",
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Pi `api` field value back to a Pi Switch provider type.
|
||||
pub fn provider_type_for_pi_api(api: &str) -> ProviderType {
|
||||
match api {
|
||||
"openai-responses" => ProviderType::OpenaiResponses,
|
||||
"anthropic-messages" => ProviderType::Anthropic,
|
||||
"google-generative-ai" => ProviderType::GoogleGenai,
|
||||
"google-vertex" => ProviderType::Vertexai,
|
||||
_ => ProviderType::Openai,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a Pi `models.json` into a Pi Switch `Config`.
|
||||
pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
|
||||
let mut providers = IndexMap::new();
|
||||
let mut models = IndexMap::new();
|
||||
|
||||
for (name, pi_provider) in &file.providers {
|
||||
let provider_type = pi_provider
|
||||
.api
|
||||
.as_deref()
|
||||
.map(provider_type_for_pi_api)
|
||||
.unwrap_or(ProviderType::Openai);
|
||||
|
||||
let provider = Provider {
|
||||
name: name.clone(),
|
||||
provider_type,
|
||||
base_url: pi_provider.base_url.clone().filter(|s| !s.is_empty()),
|
||||
api_key: pi_provider.api_key.clone().filter(|s| !s.is_empty()),
|
||||
env: IndexMap::new(),
|
||||
note: None,
|
||||
official_url: None,
|
||||
managed: false,
|
||||
enabled: pi_provider.enabled,
|
||||
raw_other: pi_provider.extra.clone(),
|
||||
};
|
||||
|
||||
for (idx, pi_model) in pi_provider.models.iter().enumerate() {
|
||||
let alias = pi_model
|
||||
.name
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
if pi_model.id.trim().is_empty() {
|
||||
format!("{}-model-{}", name, idx + 1)
|
||||
} else {
|
||||
pi_model.id.clone()
|
||||
}
|
||||
});
|
||||
let display_name = pi_model.name.clone().filter(|s| !s.is_empty());
|
||||
|
||||
models.insert(
|
||||
alias.clone(),
|
||||
Model {
|
||||
alias,
|
||||
provider: name.clone(),
|
||||
model: pi_model.id.clone(),
|
||||
max_context_size: pi_model.context_window,
|
||||
display_name,
|
||||
role: None,
|
||||
supports_1m: pi_model.reasoning || pi_model.context_window >= 1_000_000,
|
||||
capabilities: vec![],
|
||||
raw_other: pi_model.extra.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
providers.insert(name.clone(), provider);
|
||||
}
|
||||
|
||||
Config {
|
||||
default_model: file.default_model.clone(),
|
||||
providers,
|
||||
models,
|
||||
raw_other: file.extra.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Export a Pi Switch `Config` to a Pi `models.json`.
|
||||
pub fn config_to_pi_file(config: &Config) -> PiModelsFile {
|
||||
let mut providers = IndexMap::new();
|
||||
|
||||
for (name, provider) in &config.providers {
|
||||
let mut pi_models = Vec::new();
|
||||
for model in config.models.values() {
|
||||
if model.provider != *name {
|
||||
continue;
|
||||
}
|
||||
let display_name = model.display_name.clone().filter(|s| !s.is_empty());
|
||||
let name_field = display_name
|
||||
.clone()
|
||||
.filter(|s| s != &model.model);
|
||||
pi_models.push(PiModel {
|
||||
id: model.model.clone(),
|
||||
name: name_field,
|
||||
reasoning: model.supports_1m,
|
||||
input: default_input(),
|
||||
context_window: model.max_context_size,
|
||||
max_tokens: None,
|
||||
cost: None,
|
||||
compat: None,
|
||||
extra: model.raw_other.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
providers.insert(
|
||||
name.clone(),
|
||||
PiProvider {
|
||||
base_url: provider.base_url.clone().filter(|s| !s.is_empty()),
|
||||
api: Some(pi_api_for_provider(&provider.provider_type).to_string()),
|
||||
api_key: provider.api_key.clone().filter(|s| !s.is_empty()),
|
||||
headers: None,
|
||||
compat: None,
|
||||
enabled: provider.enabled,
|
||||
models: pi_models,
|
||||
extra: provider.raw_other.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
PiModelsFile {
|
||||
default_model: config.default_model.clone(),
|
||||
providers,
|
||||
extra: config.raw_other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pi_roundtrip_preserves_provider_and_model() {
|
||||
let mut providers = IndexMap::new();
|
||||
providers.insert(
|
||||
"my-openai".to_string(),
|
||||
PiProvider {
|
||||
base_url: Some("https://proxy.example.com/v1".to_string()),
|
||||
api: Some("openai-completions".to_string()),
|
||||
api_key: Some("sk-test".to_string()),
|
||||
headers: None,
|
||||
compat: None,
|
||||
enabled: true,
|
||||
models: vec![PiModel {
|
||||
id: "gpt-test".to_string(),
|
||||
name: Some("GPT Test".to_string()),
|
||||
reasoning: true,
|
||||
input: vec!["text".to_string()],
|
||||
context_window: 200_000,
|
||||
max_tokens: Some(4096),
|
||||
cost: Some(PiCost {
|
||||
input: 1.0,
|
||||
output: 2.0,
|
||||
cache_read: 0.0,
|
||||
cache_write: 0.0,
|
||||
}),
|
||||
compat: None,
|
||||
extra: Value::Null,
|
||||
}],
|
||||
extra: Value::Null,
|
||||
},
|
||||
);
|
||||
let file = PiModelsFile {
|
||||
default_model: None,
|
||||
providers,
|
||||
extra: Value::Null,
|
||||
};
|
||||
|
||||
let config = pi_file_to_config(&file);
|
||||
assert_eq!(config.providers.len(), 1);
|
||||
assert_eq!(config.models.len(), 1);
|
||||
|
||||
let provider = config.providers.get("my-openai").unwrap();
|
||||
assert_eq!(provider.provider_type, ProviderType::Openai);
|
||||
assert_eq!(provider.base_url.as_deref(), Some("https://proxy.example.com/v1"));
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-test"));
|
||||
|
||||
let model = config.models.get("GPT Test").unwrap();
|
||||
assert_eq!(model.model, "gpt-test");
|
||||
assert!(model.supports_1m);
|
||||
assert_eq!(model.max_context_size, 200_000);
|
||||
|
||||
let exported = config_to_pi_file(&config);
|
||||
let exported_provider = exported.providers.get("my-openai").unwrap();
|
||||
assert_eq!(exported_provider.api.as_deref(), Some("openai-completions"));
|
||||
assert_eq!(exported_provider.models[0].id, "gpt-test");
|
||||
assert!(exported_provider.models[0].reasoning);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user