diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b9f9447..c38d54c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -244,6 +244,7 @@ fn build_active_config(config: &Config) -> Config { providers, models, raw_other: config.raw_other.clone(), + imported_section_keys: config.imported_section_keys.clone(), } } @@ -277,6 +278,9 @@ pub async fn list_provider_models(provider: Provider) -> Result 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()), + ProviderType::Unknown(s) => Err(format!( + "unsupported provider type for model discovery: {s}" + )), } } @@ -338,8 +342,12 @@ fn resolve_api_key(provider: &Provider) -> Option { 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() + if let Some(env_key) = expected_api_key_key(&provider.provider_type) { + if let Some(key) = provider.env.get(env_key).filter(|s| !s.is_empty()) { + return Some(key.clone()); + } + } + None } fn resolve_base_url(provider: &Provider) -> String { @@ -356,13 +364,16 @@ fn resolve_base_url(provider: &Provider) -> String { }) } -fn expected_api_key_key(provider_type: &ProviderType) -> &'static str { +/// Well-known env var fallback for the API key per provider type; `None` +/// for unknown types (no well-known variable name to look up). +fn expected_api_key_key(provider_type: &ProviderType) -> Option<&'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", + ProviderType::Kimi => Some("KIMI_API_KEY"), + ProviderType::Anthropic => Some("ANTHROPIC_API_KEY"), + ProviderType::Openai | ProviderType::OpenaiResponses => Some("OPENAI_API_KEY"), + ProviderType::GoogleGenai => Some("GOOGLE_API_KEY"), + ProviderType::Vertexai => Some("VERTEXAI_API_KEY"), + ProviderType::Unknown(_) => None, } } @@ -650,9 +661,8 @@ pub async fn query_provider_usage( .clone() .filter(|s| !s.trim().is_empty()) .or_else(|| { - provider - .env - .get(expected_api_key_key(&provider.provider_type)) + expected_api_key_key(&provider.provider_type) + .and_then(|env_key| provider.env.get(env_key)) .cloned() .filter(|s| !s.is_empty()) }); diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index a20f721..445b07b 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -207,6 +207,9 @@ pub fn load_config(agent: &Agent) -> DbResult { providers, models, raw_other: Value::Null, + // The SQLite snapshot stores no import baseline; an empty baseline + // makes export's stale-key cleanup a no-op (safe degradation). + imported_section_keys: Vec::new(), }) } @@ -328,12 +331,5 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> { } fn provider_type_for_str(s: &str) -> ProviderType { - match s { - "anthropic" => ProviderType::Anthropic, - "openai" => ProviderType::Openai, - "openai_responses" => ProviderType::OpenaiResponses, - "google-genai" => ProviderType::GoogleGenai, - "vertexai" => ProviderType::Vertexai, - _ => ProviderType::Kimi, - } + ProviderType::from_kimi_type(s) } diff --git a/src-tauri/src/kimi_code_io.rs b/src-tauri/src/kimi_code_io.rs index d28a7bb..ee01b6c 100644 --- a/src-tauri/src/kimi_code_io.rs +++ b/src-tauri/src/kimi_code_io.rs @@ -148,7 +148,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config { let provider_type = table .get("type") .and_then(|v| v.as_str()) - .map(provider_type_for_kimi_type) + .map(ProviderType::from_kimi_type) .unwrap_or(ProviderType::Kimi); let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); @@ -216,19 +216,23 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config { } } - let raw_other = { - let mut rest = root; - rest.remove("default_model"); - rest.remove("providers"); - rest.remove("models"); - toml_value_to_json(&TomlValue::Table(rest)) - }; + let mut rest = root; + rest.remove("default_model"); + rest.remove("providers"); + rest.remove("models"); + // Baseline of top-level keys seen at import time (table order). Export + // only drops baseline keys that later disappeared from raw_other, i.e. + // sections the user removed in the UI — sections the CLI adds after + // import are not in the baseline and survive the round-trip. + let imported_section_keys: Vec = rest.keys().cloned().collect(); + let raw_other = toml_value_to_json(&TomlValue::Table(rest)); Config { default_model, providers, models, raw_other, + imported_section_keys, } } @@ -354,10 +358,13 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom // Sync UI-managed top-level sections (thinking, experimental, // secondary_model, ...) from config.raw_other back into the root. // At import time every top-level key except default_model/providers/ - // models was collected into raw_other, so raw_other is the authoritative - // snapshot of those sections. This both persists UI edits to those - // sections and drops sections the user explicitly removed (absent from - // raw_other but still present in `existing`). + // models was collected into raw_other, and their keys recorded in + // `imported_section_keys` (the import-time baseline). Keys present in + // raw_other are written back (persisting UI edits); keys that were in + // the baseline but are absent from raw_other were removed by the user + // and are dropped from `existing`. Top-level keys NOT in the baseline + // were added by the CLI after import and are preserved untouched. An + // empty baseline (Config not built via import) deletes nothing. let mut managed_keys = std::collections::HashSet::new(); if let TomlValue::Table(extra) = json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new())) @@ -377,6 +384,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom && *k != "providers" && *k != "models" && !managed_keys.contains(*k) + && config.imported_section_keys.iter().any(|b| b == *k) }) .cloned() .collect(); @@ -387,17 +395,6 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom 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()), @@ -572,6 +569,7 @@ api_key = "" providers, models, raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); @@ -705,6 +703,7 @@ default_effort = "low" providers, models, raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); @@ -818,6 +817,7 @@ max_context_size = 1048576 providers, models: IndexMap::new(), raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); @@ -858,6 +858,7 @@ max_context_size = 1048576 providers, models: IndexMap::new(), raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); @@ -908,6 +909,7 @@ max_context_size = 1048576 )]), models: IndexMap::new(), raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); let provider = exported @@ -927,6 +929,7 @@ max_context_size = 1048576 )]), models: IndexMap::new(), raw_other: Value::Null, + imported_section_keys: Vec::new(), }; let exported = config_to_kimi_code(&config, None); let provider = exported @@ -940,4 +943,125 @@ max_context_size = 1048576 Some("sk-user-key") ); } + + #[test] + fn kimi_code_unknown_provider_type_roundtrip() { + // A provider `type` string the CLI knows but Kimi Switch does not + // must survive import → export verbatim, not be rewritten to "kimi". + let toml_str = r#" +default_model = "p1/custom-model" + +[providers.p1] +type = "custom-xyz" +api_key = "sk-x" + +[models."p1/custom-model"] +provider = "p1" +model = "custom-model" +max_context_size = 128000 +"#; + let value: TomlValue = toml_str.parse().unwrap(); + let config = kimi_code_to_config(&value); + + let provider = config.providers.get("p1").unwrap(); + assert_eq!( + provider.provider_type, + ProviderType::Unknown("custom-xyz".to_string()) + ); + + let exported = config_to_kimi_code(&config, Some(&value)); + let provider = exported + .as_table().unwrap() + .get("providers").unwrap() + .as_table().unwrap() + .get("p1").unwrap() + .as_table().unwrap(); + assert_eq!( + provider.get("type").and_then(|v| v.as_str()), + Some("custom-xyz"), + "unknown provider type must round-trip verbatim" + ); + } + + #[test] + fn kimi_code_export_preserves_sections_added_after_import() { + // Top-level sections the CLI added AFTER we imported (not in the + // import-time baseline) must survive export instead of being wiped + // by the stale-key cleanup. + let imported_str = r#" +default_model = "glm-5.2" + +[providers."glmzhongzhuan"] +type = "anthropic" +api_key = "sk-test" + +[models."glm-5.2"] +provider = "glmzhongzhuan" +model = "glm-5.2" +max_context_size = 900000 + +[thinking] +enabled = true +"#; + let imported: TomlValue = imported_str.parse().unwrap(); + let config = kimi_code_to_config(&imported); + + // The on-disk file now contains an extra [swarm] section the CLI + // wrote after our import. + let existing: TomlValue = format!("{}\n[swarm]\ntimeout_ms = 123\n", imported_str.trim_end()) + .parse() + .unwrap(); + + let exported = config_to_kimi_code(&config, Some(&existing)); + let root = exported.as_table().unwrap(); + let swarm = root.get("swarm").unwrap().as_table().unwrap(); + assert_eq!( + swarm.get("timeout_ms").and_then(|v| v.as_integer()), + Some(123), + "CLI-added [swarm] section must be preserved" + ); + // Baseline sections are still synced as before. + let thinking = root.get("thinking").unwrap().as_table().unwrap(); + assert_eq!(thinking.get("enabled").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn kimi_code_export_drops_user_removed_sections() { + // A section that WAS in the import baseline but was removed from + // raw_other (the user deleted it in the UI) must still be dropped + // from the exported file. + let toml_str = r#" +default_model = "glm-5.2" + +[providers."glmzhongzhuan"] +type = "anthropic" +api_key = "sk-test" + +[models."glm-5.2"] +provider = "glmzhongzhuan" +model = "glm-5.2" +max_context_size = 900000 + +[thinking] +enabled = true +"#; + let value: TomlValue = toml_str.parse().unwrap(); + let mut config = kimi_code_to_config(&value); + assert!( + config.imported_section_keys.contains(&"thinking".to_string()), + "baseline must record the imported [thinking] section" + ); + + // UI removed the thinking section. + let mut raw = config.raw_other.as_object().unwrap().clone(); + raw.remove("thinking"); + config.raw_other = Value::Object(raw); + + let exported = config_to_kimi_code(&config, Some(&value)); + let root = exported.as_table().unwrap(); + assert!( + !root.contains_key("thinking"), + "user-removed section must be dropped on export" + ); + } } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8697da2..0aa2785 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,5 +1,7 @@ +use std::borrow::Cow; + use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Value; /// Target agent whose provider/model config is being edited. @@ -19,18 +21,35 @@ impl Agent { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq, Eq)] 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, + /// A provider type string the CLI knows but Kimi Switch does not. + /// Kept verbatim so new upstream `type` values survive the round-trip + /// instead of being rewritten to "kimi". + Unknown(String), +} + +// Serde is hand-written to keep a pure string wire format: known variants +// map to their CLI string, `Unknown(s)` maps to `s` itself. A derived +// representation would expose the internal variant names. +impl Serialize for ProviderType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ProviderType { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Ok(ProviderType::from_kimi_type(&s)) + } } const fn default_true() -> bool { @@ -38,14 +57,29 @@ const fn default_true() -> bool { } impl ProviderType { - pub fn as_str(&self) -> &'static str { + pub fn as_str(&self) -> Cow<'static, str> { match self { - ProviderType::Anthropic => "anthropic", - ProviderType::Openai => "openai", - ProviderType::OpenaiResponses => "openai_responses", - ProviderType::GoogleGenai => "google-genai", - ProviderType::Vertexai => "vertexai", - ProviderType::Kimi => "kimi", + ProviderType::Anthropic => Cow::Borrowed("anthropic"), + ProviderType::Openai => Cow::Borrowed("openai"), + ProviderType::OpenaiResponses => Cow::Borrowed("openai_responses"), + ProviderType::GoogleGenai => Cow::Borrowed("google-genai"), + ProviderType::Vertexai => Cow::Borrowed("vertexai"), + ProviderType::Kimi => Cow::Borrowed("kimi"), + ProviderType::Unknown(s) => Cow::Owned(s.clone()), + } + } + + /// Map a CLI `type` string to a `ProviderType`; unrecognized values + /// become `Unknown(s)` so the original string round-trips on export. + pub fn from_kimi_type(s: &str) -> ProviderType { + match s { + "anthropic" => ProviderType::Anthropic, + "openai" => ProviderType::Openai, + "openai_responses" => ProviderType::OpenaiResponses, + "google-genai" => ProviderType::GoogleGenai, + "vertexai" => ProviderType::Vertexai, + "kimi" => ProviderType::Kimi, + other => ProviderType::Unknown(other.to_string()), } } @@ -55,7 +89,7 @@ impl ProviderType { Some("https://api.openai.com/v1") } ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"), - ProviderType::Anthropic | ProviderType::Vertexai => None, + ProviderType::Anthropic | ProviderType::Vertexai | ProviderType::Unknown(_) => None, } } @@ -203,6 +237,12 @@ pub struct Config { pub models: IndexMap, #[serde(default, skip_serializing_if = "Value::is_null")] pub raw_other: Value, + /// Top-level section keys captured at import time. Export uses this + /// baseline to distinguish "user removed this section in the UI" from + /// "the CLI added this section after we imported" — only baseline keys + /// absent from the current raw_other are dropped on export. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub imported_section_keys: Vec, } impl PartialEq for Config { @@ -211,6 +251,7 @@ impl PartialEq for Config { && self.providers == other.providers && self.models == other.models && self.raw_other == other.raw_other + && self.imported_section_keys == other.imported_section_keys } } diff --git a/src-tauri/src/pi_io.rs b/src-tauri/src/pi_io.rs index 2433583..4e0086e 100644 --- a/src-tauri/src/pi_io.rs +++ b/src-tauri/src/pi_io.rs @@ -205,8 +205,10 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str { 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", + // Treat Kimi as OpenAI-compatible since it is not a native Pi API; + // unknown upstream types get the same lenient handling (Pi is a + // legacy path). + ProviderType::Kimi | ProviderType::Unknown(_) => "openai-completions", } } @@ -383,6 +385,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config { providers, models, raw_other: file.extra.clone(), + imported_section_keys: Vec::new(), } } diff --git a/src/components/ProviderEdit.tsx b/src/components/ProviderEdit.tsx index dea07c5..6fbc796 100644 --- a/src/components/ProviderEdit.tsx +++ b/src/components/ProviderEdit.tsx @@ -54,6 +54,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string { case "anthropic": case "vertexai": return ""; + default: + return ""; } } // Pi defaults @@ -68,6 +70,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string { case "anthropic": case "vertexai": return ""; + default: + return ""; } } @@ -307,6 +311,11 @@ export function ProviderEdit({ {t} ))} + {!PROVIDER_TYPES.includes(provider.provider_type) && ( + + )} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 39cc438..f4c731b 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -74,6 +74,7 @@ export const enTranslations: Record = { kimiOAuthStart: "Start sign-in", apiSettings: "API Settings", apiFormat: "API Format", + apiFormatUnknown: "Unknown format ({type}, kept verbatim)", authField: "Auth Field", apiKey: "API Key", getApiKeyLink: "Get API Key", diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index aa2d74a..5b9fce7 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -72,6 +72,7 @@ export const zhTranslations = { kimiOAuthStart: "开始登录", apiSettings: "API 设置", apiFormat: "API 格式", + apiFormatUnknown: "未知格式({type},原样保留)", authField: "认证字段", apiKey: "API Key", getApiKeyLink: "获取 API Key", diff --git a/src/types/index.ts b/src/types/index.ts index 330a231..c0b9040 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,7 +6,9 @@ export type ProviderType = | "openai" | "openai_responses" | "google-genai" - | "vertexai"; + | "vertexai" + /** Any other string the CLI writes — preserved verbatim on round-trip. */ + | (string & {}); export interface Provider { name: string; @@ -62,6 +64,12 @@ export interface Config { providers: Record; models: Record; raw_other?: unknown; + /** + * Top-level section keys captured at import time by the Rust side. + * Export drops only baseline keys absent from raw_other (sections the + * user removed in the UI); CLI-added sections are preserved. + */ + imported_section_keys?: string[]; } /**