feat: v0.3.0 - fix provider data loss, oauth corruption & switch UX

Bug fixes:
- Fix config.toml wiped to single provider on switch (now writes all providers)
- Fix managed:kimi-code oauth key corruption (oauth/kimi-code was rewritten
  to oauth/managed-kimi-code, breaking the official subscription)
- Fix default_model not switching due to React 18 deferred state update
  (updateConfig now sets configRef synchronously before setConfig)
- Strip KimiSwitch-private default_model from config.toml provider output

Architecture:
- config.toml is now the authoritative source for Kimi Code provider data
- SQLite stores only metadata (notes, official URLs, remembered models)
- Load reconciles config.toml with SQLite for external /provider changes
- Activate is a no-op for KimiCode (save already writes config.toml)

Features:
- Selected provider moves to top of list on switch
- Add models-dev context size lookup
- Add tauri-plugin-single-instance

Tests:
- 3 new regression tests: oauth key round-trip, all-providers write,
  private default_model strip
This commit is contained in:
KimiSwitch Dev committed 2026-07-27 22:36:42 +08:00
1 parent 5806fac056
commit 139af18826
15 files changed
+2398 -97

No files matched your search

+87 -31
View File
@@ -18,33 +18,77 @@ pub fn debug_log(message: String) {
#[tauri::command]
pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
// Load from Kimi Switch's own SQLite database first.
match db::load_config(&agent) {
Ok(config) if !config.providers.is_empty() => Ok(config),
_ => {
// Fallback to the agent's native config on first use.
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)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config.models.values().find(|m| {
m.provider == provider && m.model == model_id
}) {
config.default_model = Some(alias.alias.clone());
// Load Kimi Switch's own SQLite database (metadata + migration fallback).
let db_config = db::load_config(&agent).ok();
match agent {
Agent::KimiCode => {
// config.toml is the authoritative source for provider/model data
// because the user can add or edit providers at any time via the
// CLI's /provider command. SQLite only enriches with Kimi
// Switch-private metadata (note, official_url, remembered default
// model) and fills gaps when config.toml is incomplete.
let mut config = crate::kimi_code_io::load_kimi_code_config_as_config()
.map_err(fmt_anyhow)?;
if let Some(db) = &db_config {
// Enrich config.toml providers with SQLite metadata.
for (name, p) in config.providers.iter_mut() {
if let Some(db_p) = db.providers.get(name) {
p.note = db_p.note.clone();
p.official_url = db_p.official_url.clone();
// Restore the remembered per-provider default model
// (Kimi-Switch-private, stored in raw_other).
if let Some(dm) = db_p.raw_other.get("default_model") {
match &mut p.raw_other {
serde_json::Value::Object(obj) => {
obj.insert("default_model".to_string(), dm.clone());
}
_ => {
let mut obj = serde_json::Map::new();
obj.insert("default_model".to_string(), dm.clone());
p.raw_other = serde_json::Value::Object(obj);
}
}
}
}
Ok(config)
}
// Migration safety: include providers/models that exist in
// SQLite but not in config.toml (e.g. after upgrading from the
// old single-provider-write behaviour).
for (name, p) in &db.providers {
config.providers.entry(name.clone()).or_insert_with(|| p.clone());
}
for (alias, m) in &db.models {
config.models.entry(alias.clone()).or_insert_with(|| m.clone());
}
}
Ok(config)
}
Agent::Pi => {
// Pi: SQLite first, fall back to native config on first use.
if let Some(config) = db_config {
if !config.providers.is_empty() {
return Ok(config);
}
}
let file = pi_io::load_pi_models().map_err(fmt_anyhow)?;
let mut config = pi_io::pi_file_to_config(&file);
if config.default_model.is_none() {
if let Ok(settings) = pi_io::load_pi_settings() {
if let (Some(provider), Some(model_id)) =
(settings.default_provider, settings.default_model)
{
if let Some(alias) = config.models.values().find(|m| {
m.provider == provider && m.model == model_id
}) {
config.default_model = Some(alias.alias.clone());
}
}
}
}
Ok(config)
}
}
}
@@ -52,19 +96,30 @@ pub fn load_agent_config_command(agent: Agent) -> Result<Config, String> {
#[tauri::command]
pub fn save_agent_config_command(agent: Agent, config: Config) -> Result<(), String> {
// Save the full Kimi Switch configuration to local SQLite.
db::save_config(&agent, &config).map_err(fmt_anyhow)
db::save_config(&agent, &config).map_err(fmt_anyhow)?;
// For Kimi Code, config.toml is the authoritative provider store, so
// persist changes there immediately — not only on activation. This
// ensures edits (Ctrl+S) survive a restart even without switching.
if matches!(agent, Agent::KimiCode) {
crate::kimi_code_io::save_config_as_kimi_code(&config).map_err(fmt_anyhow)?;
}
Ok(())
}
#[tauri::command]
pub fn activate_agent_config_command(agent: Agent) -> Result<(), String> {
// Load the full config from SQLite and write only the active provider
// to the agent's native config file.
let config = db::load_config(&agent).map_err(fmt_anyhow)?;
let active_config = build_active_config(&config);
match agent {
Agent::KimiCode => crate::kimi_code_io::save_config_as_kimi_code(&active_config)
.map_err(fmt_anyhow),
Agent::KimiCode => {
// No-op: save_agent_config_command already writes config.toml for
// Kimi Code. Avoiding a second write here prevents a redundant disk
// write + backup on every switch.
Ok(())
}
Agent::Pi => {
// Load the full config from SQLite and write only the active
// provider to Pi's native config files.
let config = db::load_config(&agent).map_err(fmt_anyhow)?;
let active_config = build_active_config(&config);
let file = pi_io::config_to_pi_file(&active_config);
pi_io::save_pi_models(&file).map_err(fmt_anyhow)?;
@@ -87,9 +142,10 @@ fn active_provider_and_model(config: &Config) -> Option<(String, String)> {
}
fn build_active_config(config: &Config) -> Config {
// Only the provider explicitly marked as active is written to the agent's
// native config. This ensures Kimi Code / Pi follow Kimi Switch's choice
// instead of falling back to a managed/native provider.
// Used only by Pi: writes only the provider explicitly marked as active
// to Pi's native config so Pi follows Kimi Switch's selection instead of
// falling back to another provider. Kimi Code does not use this — it
// writes all providers and selects via default_model.
let providers: IndexMap<String, Provider> = config
.providers
.iter()
+152 -17
View File
@@ -174,8 +174,13 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
rest.remove("api_key");
rest.remove("managed");
rest.remove("enabled");
rest.remove("oauth");
rest.remove("env");
// NOTE: `oauth` is intentionally kept in raw_other so that the
// exact `storage`/`key` block round-trips verbatim on export.
// Previously it was stripped here and regenerated on export
// with a key derived from the provider name, which corrupted
// the managed:kimi-code credential reference (e.g.
// "oauth/kimi-code" became "oauth/managed-kimi-code").
toml_value_to_json(&TomlValue::Table(rest))
};
@@ -234,11 +239,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
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 Kimi Switch's selection.
if !provider.active {
continue;
}
// Write ALL providers to Kimi Code config. The active provider is
// selected via `default_model`, so keeping the full list matches the
// CLI's native multi-provider behavior and prevents data loss when
// switching.
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()) {
@@ -273,6 +277,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
if let TomlValue::Table(mut extra) = json_to_toml(&provider.raw_other).unwrap_or(TomlValue::Table(Table::new())) {
extra.remove("oauth");
extra.remove("env");
// Strip Kimi-Switch-private field: the remembered per-provider
// default model is stored in raw_other.default_model and must NOT
// leak into the agent's config.toml.
extra.remove("default_model");
for (k, v) in extra {
pt.insert(k, v);
}
@@ -281,19 +289,10 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
}
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();
// Write all models — each provider's models are kept so the CLI's
// /provider command can list and switch between them.
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()));
@@ -587,4 +586,140 @@ api_key = ""
}
}
}
#[test]
fn kimi_code_oauth_key_roundtrip() {
// Regression: importing then exporting managed:kimi-code must preserve
// the exact oauth key "oauth/kimi-code". Previously the oauth block
// was dropped on import and regenerated on export as
// "oauth/managed-kimi-code", breaking the official subscription.
let toml_str = r#"
default_model = "kimi-code/k3"
[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/k3"]
provider = "managed:kimi-code"
model = "k3"
max_context_size = 1048576
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
// Export back to TOML.
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let providers = root.get("providers").unwrap().as_table().unwrap();
let managed = providers.get("managed:kimi-code").unwrap().as_table().unwrap();
let oauth = managed.get("oauth").unwrap().as_table().unwrap();
assert_eq!(
oauth.get("key").and_then(|v| v.as_str()),
Some("oauth/kimi-code"),
"oauth key must round-trip verbatim, not be regenerated"
);
assert_eq!(
oauth.get("storage").and_then(|v| v.as_str()),
Some("file")
);
}
#[test]
fn kimi_code_export_writes_all_providers() {
// Regression: inactive providers must still be written to config.toml
// so that switching does not wipe the provider list.
let mut providers = IndexMap::new();
providers.insert(
"active-one".to_string(),
Provider {
name: "active-one".to_string(),
provider_type: ProviderType::Anthropic,
base_url: Some("https://a.example.com".to_string()),
api_key: Some("sk-a".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
raw_other: Value::Null,
},
);
providers.insert(
"inactive-one".to_string(),
Provider {
name: "inactive-one".to_string(),
provider_type: ProviderType::Openai,
base_url: Some("https://b.example.com".to_string()),
api_key: Some("sk-b".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: false,
raw_other: Value::Null,
},
);
let config = Config {
default_model: None,
providers,
models: IndexMap::new(),
raw_other: Value::Null,
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let providers_table = root.get("providers").unwrap().as_table().unwrap();
assert_eq!(providers_table.len(), 2, "both providers must be written");
assert!(providers_table.contains_key("active-one"));
assert!(providers_table.contains_key("inactive-one"));
}
#[test]
fn kimi_code_export_strips_private_default_model() {
// The remembered per-provider default model (raw_other.default_model)
// is a Kimi-Switch-private field and must NOT leak into config.toml.
let mut providers = IndexMap::new();
providers.insert(
"p".to_string(),
Provider {
name: "p".to_string(),
provider_type: ProviderType::Anthropic,
base_url: None,
api_key: Some("sk-x".to_string()),
env: IndexMap::new(),
note: None,
official_url: None,
managed: false,
enabled: true,
active: true,
raw_other: serde_json::json!({"default_model": "some-alias"}),
},
);
let config = Config {
default_model: None,
providers,
models: IndexMap::new(),
raw_other: Value::Null,
};
let exported = config_to_kimi_code(&config, None);
let root = exported.as_table().unwrap();
let provider = root
.get("providers").unwrap()
.as_table().unwrap()
.get("p").unwrap()
.as_table().unwrap();
assert!(
!provider.contains_key("default_model"),
"private default_model must not leak into config.toml"
);
}
}
+10
View File
@@ -11,6 +11,16 @@ use tauri::Manager;
pub fn run() {
tauri::Builder::default()
// Single instance: must be registered before other plugins. When a
// second process is launched, this callback runs in the existing
// instance and simply brings its window back (including from tray).
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_opener::init())
.setup(|app| {
println!("[Tauri] Setup started");