//! 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::config_io::backup_file; use crate::models::{Agent, Config, Model, Provider, ProviderType}; pub type KimiResult = anyhow::Result; pub fn kimi_code_config_dir() -> PathBuf { std::env::var_os("KIMI_CODE_HOME") .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|h| h.join(".kimi-code"))) .expect("failed to resolve Kimi Code config 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 { 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() { backup_file(&path, 7).with_context(|| { format!("failed to back up Kimi Code config {}", 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 env: IndexMap = table .get("env") .and_then(|v| v.as_table()) .map(|t| { t.iter() .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) .collect() }) .unwrap_or_default(); 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"); rest.remove("env"); 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, note: None, official_url: None, managed, enabled, active: true, 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 = 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 { // Only write the active provider to Kimi Code config so the agent // follows Pi Switch's selection. if !provider.active { continue; } 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.env.is_empty() { let mut env_table = Table::new(); for (k, v) in &provider.env { env_table.insert(k.clone(), TomlValue::String(v.clone())); } pt.insert("env".to_string(), TomlValue::Table(env_table)); } 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 and env are handled explicitly above). if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) { extra.remove("oauth"); extra.remove("env"); 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)); // Collect provider names that survived the filter above. let active_provider_names: std::collections::HashSet<&str> = config .providers .values() .filter(|p| p.active) .map(|p| p.name.as_str()) .collect(); let mut models_table = Table::new(); for (alias, model) in &config.models { if !active_provider_names.contains(model.provider.as_str()) { continue; } 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 { 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 { 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, active: 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"))); } }