fix(config): 保存不再误删 CLI 后加的顶层配置节 + 未知供应商类型原样保留
- 导出 stale-key 清理增加导入基线(imported_section_keys):CLI 在导入后 新增的顶层节(kimi-code 0.38+ 的 [task]/[swarm]/[cron] 等)在保存时 原样保留;用户在 UI 显式删除的节仍被移除;空基线(SQLite 快照恢复) 不删任何键(安全退化) - ProviderType 增加 Unknown(String) 变体,手写 serde 保持纯字符串线格式, 上游新增的 type 值往返不再被改写为 kimi;SQLite 快照恢复路径同步修复; 模型发现对未知类型返回明确错误,不再误用 KIMI_API_KEY - 新增 3 个往返测试(106 个 Rust 测试全绿),前端 tsc/vitest 通过
This commit is contained in:
1 parent
9764929919
commit
67db19eda4
9 files changed
+251
-58
No files matched your search
+21
-11
@@ -244,6 +244,7 @@ fn build_active_config(config: &Config) -> Config {
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: config.raw_other.clone(),
|
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<Vec<DiscoveredMo
|
|||||||
ProviderType::Anthropic => fetch_anthropic_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::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::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<String> {
|
|||||||
return Some(key.clone());
|
return Some(key.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let env_key = expected_api_key_key(&provider.provider_type);
|
if let Some(env_key) = expected_api_key_key(&provider.provider_type) {
|
||||||
provider.env.get(env_key).filter(|s| !s.is_empty()).cloned()
|
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 {
|
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 {
|
match provider_type {
|
||||||
ProviderType::Kimi => "KIMI_API_KEY",
|
ProviderType::Kimi => Some("KIMI_API_KEY"),
|
||||||
ProviderType::Anthropic => "ANTHROPIC_API_KEY",
|
ProviderType::Anthropic => Some("ANTHROPIC_API_KEY"),
|
||||||
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY",
|
ProviderType::Openai | ProviderType::OpenaiResponses => Some("OPENAI_API_KEY"),
|
||||||
ProviderType::GoogleGenai => "GOOGLE_API_KEY",
|
ProviderType::GoogleGenai => Some("GOOGLE_API_KEY"),
|
||||||
ProviderType::Vertexai => "VERTEXAI_API_KEY",
|
ProviderType::Vertexai => Some("VERTEXAI_API_KEY"),
|
||||||
|
ProviderType::Unknown(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,9 +661,8 @@ pub async fn query_provider_usage(
|
|||||||
.clone()
|
.clone()
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
provider
|
expected_api_key_key(&provider.provider_type)
|
||||||
.env
|
.and_then(|env_key| provider.env.get(env_key))
|
||||||
.get(expected_api_key_key(&provider.provider_type))
|
|
||||||
.cloned()
|
.cloned()
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-8
@@ -207,6 +207,9 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
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 {
|
fn provider_type_for_str(s: &str) -> ProviderType {
|
||||||
match s {
|
ProviderType::from_kimi_type(s)
|
||||||
"anthropic" => ProviderType::Anthropic,
|
|
||||||
"openai" => ProviderType::Openai,
|
|
||||||
"openai_responses" => ProviderType::OpenaiResponses,
|
|
||||||
"google-genai" => ProviderType::GoogleGenai,
|
|
||||||
"vertexai" => ProviderType::Vertexai,
|
|
||||||
_ => ProviderType::Kimi,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+147
-23
@@ -148,7 +148,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
|
|||||||
let provider_type = table
|
let provider_type = table
|
||||||
.get("type")
|
.get("type")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(provider_type_for_kimi_type)
|
.map(ProviderType::from_kimi_type)
|
||||||
.unwrap_or(ProviderType::Kimi);
|
.unwrap_or(ProviderType::Kimi);
|
||||||
|
|
||||||
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
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;
|
||||||
let mut rest = root;
|
rest.remove("default_model");
|
||||||
rest.remove("default_model");
|
rest.remove("providers");
|
||||||
rest.remove("providers");
|
rest.remove("models");
|
||||||
rest.remove("models");
|
// Baseline of top-level keys seen at import time (table order). Export
|
||||||
toml_value_to_json(&TomlValue::Table(rest))
|
// 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<String> = rest.keys().cloned().collect();
|
||||||
|
let raw_other = toml_value_to_json(&TomlValue::Table(rest));
|
||||||
|
|
||||||
Config {
|
Config {
|
||||||
default_model,
|
default_model,
|
||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other,
|
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,
|
// Sync UI-managed top-level sections (thinking, experimental,
|
||||||
// secondary_model, ...) from config.raw_other back into the root.
|
// secondary_model, ...) from config.raw_other back into the root.
|
||||||
// At import time every top-level key except default_model/providers/
|
// At import time every top-level key except default_model/providers/
|
||||||
// models was collected into raw_other, so raw_other is the authoritative
|
// models was collected into raw_other, and their keys recorded in
|
||||||
// snapshot of those sections. This both persists UI edits to those
|
// `imported_section_keys` (the import-time baseline). Keys present in
|
||||||
// sections and drops sections the user explicitly removed (absent from
|
// raw_other are written back (persisting UI edits); keys that were in
|
||||||
// raw_other but still present in `existing`).
|
// 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();
|
let mut managed_keys = std::collections::HashSet::new();
|
||||||
if let TomlValue::Table(extra) =
|
if let TomlValue::Table(extra) =
|
||||||
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
|
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 != "providers"
|
||||||
&& *k != "models"
|
&& *k != "models"
|
||||||
&& !managed_keys.contains(*k)
|
&& !managed_keys.contains(*k)
|
||||||
|
&& config.imported_section_keys.iter().any(|b| b == *k)
|
||||||
})
|
})
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
@@ -387,17 +395,6 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
|
|||||||
TomlValue::Table(root)
|
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 {
|
fn toml_value_to_json(value: &TomlValue) -> Value {
|
||||||
match value {
|
match value {
|
||||||
TomlValue::String(s) => Value::String(s.clone()),
|
TomlValue::String(s) => Value::String(s.clone()),
|
||||||
@@ -572,6 +569,7 @@ api_key = ""
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -705,6 +703,7 @@ default_effort = "low"
|
|||||||
providers,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -818,6 +817,7 @@ max_context_size = 1048576
|
|||||||
providers,
|
providers,
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -858,6 +858,7 @@ max_context_size = 1048576
|
|||||||
providers,
|
providers,
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
@@ -908,6 +909,7 @@ max_context_size = 1048576
|
|||||||
)]),
|
)]),
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
let provider = exported
|
let provider = exported
|
||||||
@@ -927,6 +929,7 @@ max_context_size = 1048576
|
|||||||
)]),
|
)]),
|
||||||
models: IndexMap::new(),
|
models: IndexMap::new(),
|
||||||
raw_other: Value::Null,
|
raw_other: Value::Null,
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
};
|
};
|
||||||
let exported = config_to_kimi_code(&config, None);
|
let exported = config_to_kimi_code(&config, None);
|
||||||
let provider = exported
|
let provider = exported
|
||||||
@@ -940,4 +943,125 @@ max_context_size = 1048576
|
|||||||
Some("sk-user-key")
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+54
-13
@@ -1,5 +1,7 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
/// Target agent whose provider/model config is being edited.
|
/// Target agent whose provider/model config is being edited.
|
||||||
@@ -19,18 +21,35 @@ impl Agent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ProviderType {
|
pub enum ProviderType {
|
||||||
Anthropic,
|
Anthropic,
|
||||||
Openai,
|
Openai,
|
||||||
#[serde(rename = "openai_responses")]
|
|
||||||
OpenaiResponses,
|
OpenaiResponses,
|
||||||
#[serde(rename = "google-genai")]
|
|
||||||
GoogleGenai,
|
GoogleGenai,
|
||||||
Vertexai,
|
Vertexai,
|
||||||
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
|
/// Kept for compatibility; mapped to OpenAI-compatible in Pi.
|
||||||
Kimi,
|
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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
serializer.serialize_str(&self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for ProviderType {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
let s = String::deserialize(deserializer)?;
|
||||||
|
Ok(ProviderType::from_kimi_type(&s))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_true() -> bool {
|
const fn default_true() -> bool {
|
||||||
@@ -38,14 +57,29 @@ const fn default_true() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderType {
|
impl ProviderType {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> Cow<'static, str> {
|
||||||
match self {
|
match self {
|
||||||
ProviderType::Anthropic => "anthropic",
|
ProviderType::Anthropic => Cow::Borrowed("anthropic"),
|
||||||
ProviderType::Openai => "openai",
|
ProviderType::Openai => Cow::Borrowed("openai"),
|
||||||
ProviderType::OpenaiResponses => "openai_responses",
|
ProviderType::OpenaiResponses => Cow::Borrowed("openai_responses"),
|
||||||
ProviderType::GoogleGenai => "google-genai",
|
ProviderType::GoogleGenai => Cow::Borrowed("google-genai"),
|
||||||
ProviderType::Vertexai => "vertexai",
|
ProviderType::Vertexai => Cow::Borrowed("vertexai"),
|
||||||
ProviderType::Kimi => "kimi",
|
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")
|
Some("https://api.openai.com/v1")
|
||||||
}
|
}
|
||||||
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
|
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<String, Model>,
|
pub models: IndexMap<String, Model>,
|
||||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||||
pub raw_other: Value,
|
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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartialEq for Config {
|
impl PartialEq for Config {
|
||||||
@@ -211,6 +251,7 @@ impl PartialEq for Config {
|
|||||||
&& self.providers == other.providers
|
&& self.providers == other.providers
|
||||||
&& self.models == other.models
|
&& self.models == other.models
|
||||||
&& self.raw_other == other.raw_other
|
&& self.raw_other == other.raw_other
|
||||||
|
&& self.imported_section_keys == other.imported_section_keys
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -205,8 +205,10 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
|
|||||||
ProviderType::Anthropic => "anthropic-messages",
|
ProviderType::Anthropic => "anthropic-messages",
|
||||||
ProviderType::GoogleGenai => "google-generative-ai",
|
ProviderType::GoogleGenai => "google-generative-ai",
|
||||||
ProviderType::Vertexai => "google-vertex",
|
ProviderType::Vertexai => "google-vertex",
|
||||||
// Treat Kimi as OpenAI-compatible since it is not a native Pi API.
|
// Treat Kimi as OpenAI-compatible since it is not a native Pi API;
|
||||||
ProviderType::Kimi => "openai-completions",
|
// 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,
|
providers,
|
||||||
models,
|
models,
|
||||||
raw_other: file.extra.clone(),
|
raw_other: file.extra.clone(),
|
||||||
|
imported_section_keys: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
|
|||||||
case "anthropic":
|
case "anthropic":
|
||||||
case "vertexai":
|
case "vertexai":
|
||||||
return "";
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pi defaults
|
// Pi defaults
|
||||||
@@ -68,6 +70,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
|
|||||||
case "anthropic":
|
case "anthropic":
|
||||||
case "vertexai":
|
case "vertexai":
|
||||||
return "";
|
return "";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +311,11 @@ export function ProviderEdit({
|
|||||||
{t}
|
{t}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
{!PROVIDER_TYPES.includes(provider.provider_type) && (
|
||||||
|
<option value={provider.provider_type}>
|
||||||
|
{t("apiFormatUnknown", { type: provider.provider_type })}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
|
|||||||
kimiOAuthStart: "Start sign-in",
|
kimiOAuthStart: "Start sign-in",
|
||||||
apiSettings: "API Settings",
|
apiSettings: "API Settings",
|
||||||
apiFormat: "API Format",
|
apiFormat: "API Format",
|
||||||
|
apiFormatUnknown: "Unknown format ({type}, kept verbatim)",
|
||||||
authField: "Auth Field",
|
authField: "Auth Field",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
getApiKeyLink: "Get API Key",
|
getApiKeyLink: "Get API Key",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const zhTranslations = {
|
|||||||
kimiOAuthStart: "开始登录",
|
kimiOAuthStart: "开始登录",
|
||||||
apiSettings: "API 设置",
|
apiSettings: "API 设置",
|
||||||
apiFormat: "API 格式",
|
apiFormat: "API 格式",
|
||||||
|
apiFormatUnknown: "未知格式({type},原样保留)",
|
||||||
authField: "认证字段",
|
authField: "认证字段",
|
||||||
apiKey: "API Key",
|
apiKey: "API Key",
|
||||||
getApiKeyLink: "获取 API Key",
|
getApiKeyLink: "获取 API Key",
|
||||||
|
|||||||
+9
-1
@@ -6,7 +6,9 @@ export type ProviderType =
|
|||||||
| "openai"
|
| "openai"
|
||||||
| "openai_responses"
|
| "openai_responses"
|
||||||
| "google-genai"
|
| "google-genai"
|
||||||
| "vertexai";
|
| "vertexai"
|
||||||
|
/** Any other string the CLI writes — preserved verbatim on round-trip. */
|
||||||
|
| (string & {});
|
||||||
|
|
||||||
export interface Provider {
|
export interface Provider {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -62,6 +64,12 @@ export interface Config {
|
|||||||
providers: Record<string, Provider>;
|
providers: Record<string, Provider>;
|
||||||
models: Record<string, Model>;
|
models: Record<string, Model>;
|
||||||
raw_other?: unknown;
|
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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user