mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
f733def452
Add a "Grok Official" preset and seed (grokbuild-official) whose empty config represents the official login state: no custom [model.*] tables are written, so Grok CLI falls back to its built-in xAI OAuth login and cc-switch never touches those credentials. Backend: - Seed entry in OFFICIAL_SEEDS plus ensure_grokbuild_official_provider command for on-demand repair (the one-shot master seeding flag is already set for existing databases). - Split validation into syntax-only (empty allowed) for live reads, writes and official snapshots, keeping the full custom-model shape check for non-official provider writes and imports. Backup/restore can now round-trip an official-state live file. - Manual import (command layer only) recognizes an official-state live config and imports it as the official entry set as current, matching the Codex official-login import outcome. Startup auto-import keeps rejecting official-state live so a deleted official entry is never resurrected on launch: startup import only captures real user data as "default" and never manufactures official entries. - Manual import also ensures the official entry before importing (claude-desktop precedent) and after a successful custom import, so first-time users end up with default + official like other apps. - Proxy takeover guards skip or reject official-state live configs in all three takeover paths, consistent with the official-provider takeover ban. Frontend: - Grok Official preset entry in the GrokBuild form: official category hides connection fields and passes the raw config through untouched. - Filter managed-OAuth presets out of the GrokBuild preset list; they were never wired for this app and produced keyless broken configs. Tests cover seed presence, official round-trip, ensure-after-deletion, and the four import scenarios including startup non-resurrection.
641 lines
23 KiB
Rust
641 lines
23 KiB
Rust
use serde_json::{json, Value};
|
||
use std::fs;
|
||
use std::path::PathBuf;
|
||
|
||
use crate::config::{get_home_dir, write_text_file};
|
||
use crate::error::AppError;
|
||
use crate::provider::Provider;
|
||
|
||
pub const DEFAULT_MODEL: &str = "grok-4.5";
|
||
pub const DEFAULT_API_BACKEND: &str = "responses";
|
||
pub const DEFAULT_CONTEXT_WINDOW: i64 = 500_000;
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct GrokModelConfig {
|
||
pub profile: String,
|
||
pub model: String,
|
||
pub base_url: String,
|
||
pub name: String,
|
||
pub api_key: Option<String>,
|
||
pub env_key: Option<String>,
|
||
pub api_backend: String,
|
||
pub context_window: i64,
|
||
}
|
||
|
||
/// Grok Build configuration directory (`~/.grok`).
|
||
pub fn get_grok_config_dir() -> PathBuf {
|
||
crate::settings::get_grok_override_dir().unwrap_or_else(|| get_home_dir().join(".grok"))
|
||
}
|
||
|
||
/// Grok Build live configuration path (`~/.grok/config.toml`).
|
||
pub fn get_grok_config_path() -> PathBuf {
|
||
get_grok_config_dir().join("config.toml")
|
||
}
|
||
|
||
fn required_non_empty_string<'a>(
|
||
table: &'a toml::value::Table,
|
||
key: &str,
|
||
) -> Result<&'a str, AppError> {
|
||
table
|
||
.get(key)
|
||
.and_then(toml::Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.field.missing",
|
||
format!("Grok Build 配置缺少有效的 {key} 字段"),
|
||
format!("Grok Build configuration is missing a valid {key} field"),
|
||
)
|
||
})
|
||
}
|
||
|
||
fn optional_non_empty_string(table: &toml::value::Table, key: &str) -> Option<String> {
|
||
table
|
||
.get(key)
|
||
.and_then(toml::Value::as_str)
|
||
.map(str::trim)
|
||
.filter(|value| !value.is_empty())
|
||
.map(ToString::to_string)
|
||
}
|
||
|
||
/// Syntax-only validation for a Grok Build config document (empty allowed).
|
||
///
|
||
/// 官方条目走 Grok CLI 自带的 xAI OAuth 登录,config.toml 不需要(通常也没有)
|
||
/// 自定义模型表:空文档合法,非空只要求 TOML 语法合法。live 层的读写与官方
|
||
/// 快照校验都用它;"必须有完整自定义模型表"的强校验见 `validate_config_toml`。
|
||
pub fn validate_config_toml_syntax(config_toml: &str) -> Result<(), AppError> {
|
||
if config_toml.trim().is_empty() {
|
||
return Ok(());
|
||
}
|
||
config_toml
|
||
.parse::<toml::Value>()
|
||
.map(|_| ())
|
||
.map_err(|error| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.invalid_toml",
|
||
format!("Grok Build config.toml 格式错误: {error}"),
|
||
format!("Invalid Grok Build config.toml: {error}"),
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Whether a live config document represents the official login state.
|
||
///
|
||
/// 官方态 = 语法合法且完全没有自定义模型痕迹(无 `[models]` 也无 `[model.*]`,
|
||
/// 允许 `[mcp_servers]` 等其它内容)。只要出现过任一自定义键就返回 false,
|
||
/// 让残缺的自定义配置继续走 `validate_config_toml` 报出真实错误,
|
||
/// 而不是被误判成官方态静默吞掉。语法不合法同样返回 false。
|
||
pub fn is_official_live_config(config_toml: &str) -> bool {
|
||
let Ok(document) = config_toml.parse::<toml::Value>() else {
|
||
return false;
|
||
};
|
||
document
|
||
.as_table()
|
||
.is_some_and(|root| !root.contains_key("models") && !root.contains_key("model"))
|
||
}
|
||
|
||
/// Validate the provider-owned Grok Build TOML document.
|
||
pub fn validate_config_toml(config_toml: &str) -> Result<(), AppError> {
|
||
let document = config_toml.parse::<toml::Value>().map_err(|error| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.invalid_toml",
|
||
format!("Grok Build config.toml 格式错误: {error}"),
|
||
format!("Invalid Grok Build config.toml: {error}"),
|
||
)
|
||
})?;
|
||
|
||
let root = document.as_table().ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.not_table",
|
||
"Grok Build 配置必须是 TOML 表结构",
|
||
"Grok Build configuration must be a TOML table",
|
||
)
|
||
})?;
|
||
let models = root
|
||
.get("models")
|
||
.and_then(toml::Value::as_table)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.models.missing",
|
||
"Grok Build 配置缺少 [models]",
|
||
"Grok Build configuration is missing [models]",
|
||
)
|
||
})?;
|
||
let default_model = required_non_empty_string(models, "default")?;
|
||
let model_entries = root
|
||
.get("model")
|
||
.and_then(toml::Value::as_table)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.model.missing",
|
||
"Grok Build 配置缺少 [model.<name>]",
|
||
"Grok Build configuration is missing [model.<name>]",
|
||
)
|
||
})?;
|
||
let selected_model = model_entries
|
||
.get(default_model)
|
||
.and_then(toml::Value::as_table)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.default_model.missing",
|
||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||
)
|
||
})?;
|
||
|
||
required_non_empty_string(selected_model, "model")?;
|
||
required_non_empty_string(selected_model, "base_url")?;
|
||
required_non_empty_string(selected_model, "name")?;
|
||
if optional_non_empty_string(selected_model, "api_key").is_none()
|
||
&& optional_non_empty_string(selected_model, "env_key").is_none()
|
||
{
|
||
return Err(AppError::localized(
|
||
"provider.grokbuild.credentials.missing",
|
||
"Grok Build 配置缺少有效的 api_key 或 env_key 字段",
|
||
"Grok Build configuration is missing a valid api_key or env_key field",
|
||
));
|
||
}
|
||
required_non_empty_string(selected_model, "api_backend")?;
|
||
|
||
selected_model
|
||
.get("context_window")
|
||
.and_then(toml::Value::as_integer)
|
||
.filter(|value| *value > 0)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.context_window.invalid",
|
||
"Grok Build context_window 必须是正整数",
|
||
"Grok Build context_window must be a positive integer",
|
||
)
|
||
})?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn extract_model_config(config_toml: &str) -> Option<GrokModelConfig> {
|
||
let document = config_toml.parse::<toml::Value>().ok()?;
|
||
let root = document.as_table()?;
|
||
let default_model = root
|
||
.get("models")?
|
||
.as_table()?
|
||
.get("default")?
|
||
.as_str()?
|
||
.trim();
|
||
let selected_model = root
|
||
.get("model")?
|
||
.as_table()?
|
||
.get(default_model)?
|
||
.as_table()?;
|
||
Some(GrokModelConfig {
|
||
profile: default_model.to_string(),
|
||
model: selected_model.get("model")?.as_str()?.trim().to_string(),
|
||
base_url: selected_model
|
||
.get("base_url")?
|
||
.as_str()?
|
||
.trim_end_matches('/')
|
||
.to_string(),
|
||
name: selected_model.get("name")?.as_str()?.trim().to_string(),
|
||
api_key: optional_non_empty_string(selected_model, "api_key"),
|
||
env_key: optional_non_empty_string(selected_model, "env_key"),
|
||
api_backend: selected_model
|
||
.get("api_backend")?
|
||
.as_str()?
|
||
.trim()
|
||
.to_string(),
|
||
context_window: selected_model.get("context_window")?.as_integer()?,
|
||
})
|
||
}
|
||
|
||
pub fn extract_credentials(config_toml: &str) -> Option<(String, String)> {
|
||
let config = extract_model_config(config_toml)?;
|
||
let api_key = config
|
||
.api_key
|
||
.or_else(|| {
|
||
config
|
||
.env_key
|
||
.as_deref()
|
||
.and_then(|key| std::env::var(key).ok())
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
})
|
||
.or_else(|| {
|
||
std::env::var("XAI_API_KEY")
|
||
.ok()
|
||
.map(|value| value.trim().to_string())
|
||
.filter(|value| !value.is_empty())
|
||
})?;
|
||
Some((config.base_url, api_key))
|
||
}
|
||
|
||
pub fn extract_inline_api_key(config_toml: &str) -> Option<String> {
|
||
extract_model_config(config_toml)?.api_key
|
||
}
|
||
|
||
pub fn extract_base_url(config_toml: &str) -> Option<String> {
|
||
Some(extract_model_config(config_toml)?.base_url)
|
||
}
|
||
|
||
fn update_selected_model_string(
|
||
config_toml: &str,
|
||
field: &str,
|
||
value: &str,
|
||
) -> Result<String, AppError> {
|
||
let mut document = config_toml
|
||
.parse::<toml_edit::DocumentMut>()
|
||
.map_err(|error| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.invalid_toml",
|
||
format!("Grok Build config.toml 格式错误: {error}"),
|
||
format!("Invalid Grok Build config.toml: {error}"),
|
||
)
|
||
})?;
|
||
let default_model = document
|
||
.get("models")
|
||
.and_then(|item| item.get("default"))
|
||
.and_then(toml_edit::Item::as_str)
|
||
.map(str::trim)
|
||
.filter(|model| !model.is_empty())
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.default_model.missing",
|
||
"Grok Build 配置缺少 models.default",
|
||
"Grok Build configuration is missing models.default",
|
||
)
|
||
})?
|
||
.to_string();
|
||
|
||
let selected_model = document
|
||
.get_mut("model")
|
||
.and_then(|item| item.get_mut(&default_model))
|
||
.and_then(toml_edit::Item::as_table_like_mut)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.default_model.missing",
|
||
format!("Grok Build 配置缺少 [model.\"{default_model}\"]"),
|
||
format!("Grok Build configuration is missing [model.\"{default_model}\"]"),
|
||
)
|
||
})?;
|
||
selected_model.insert(field, toml_edit::value(value));
|
||
Ok(document.to_string())
|
||
}
|
||
|
||
pub fn apply_proxy_takeover(
|
||
config_toml: &str,
|
||
proxy_base_url: &str,
|
||
token_placeholder: &str,
|
||
) -> Result<String, AppError> {
|
||
let updated = update_selected_model_string(config_toml, "base_url", proxy_base_url)?;
|
||
update_selected_model_string(&updated, "api_key", token_placeholder)
|
||
}
|
||
|
||
pub fn update_api_key(config_toml: &str, api_key: &str) -> Result<String, AppError> {
|
||
update_selected_model_string(config_toml, "api_key", api_key)
|
||
}
|
||
|
||
pub fn has_proxy_placeholder(config_toml: &str, token_placeholder: &str) -> bool {
|
||
extract_model_config(config_toml)
|
||
.and_then(|config| config.api_key)
|
||
.is_some_and(|api_key| api_key == token_placeholder)
|
||
}
|
||
|
||
pub fn base_url_matches(config_toml: &str, predicate: impl FnOnce(&str) -> bool) -> bool {
|
||
extract_model_config(config_toml).is_some_and(|config| predicate(&config.base_url))
|
||
}
|
||
|
||
/// Remove MCP projections from a provider-owned Grok Build settings snapshot.
|
||
/// MCP servers are owned by the database and projected into live config.toml.
|
||
pub fn strip_grok_mcp_servers_from_settings(settings: &mut Value) -> Result<(), AppError> {
|
||
let Some(config_text) = settings
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.map(str::to_string)
|
||
else {
|
||
return Ok(());
|
||
};
|
||
if !config_text.contains("mcp") {
|
||
return Ok(());
|
||
}
|
||
|
||
let mut document = config_text
|
||
.parse::<toml_edit::DocumentMut>()
|
||
.map_err(|error| AppError::Message(format!("Invalid Grok Build config.toml: {error}")))?;
|
||
let mut changed = document.as_table_mut().remove("mcp_servers").is_some();
|
||
if let Some(mcp_table) = document
|
||
.get_mut("mcp")
|
||
.and_then(toml_edit::Item::as_table_like_mut)
|
||
{
|
||
if mcp_table.remove("servers").is_some() {
|
||
changed = true;
|
||
}
|
||
if mcp_table.is_empty() {
|
||
document.as_table_mut().remove("mcp");
|
||
}
|
||
}
|
||
|
||
if changed {
|
||
if let Some(object) = settings.as_object_mut() {
|
||
object.insert("config".to_string(), Value::String(document.to_string()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Read the live `~/.grok/config.toml` as a provider settings snapshot.
|
||
///
|
||
/// 只做 TOML 语法校验:live 处于官方态(无自定义模型表)时同样需要能被
|
||
/// 读取,供切换回填与界面展示使用。需要"完整自定义模型配置"的导入路径
|
||
/// 由调用方自行叠加 `validate_config_toml`。
|
||
pub fn read_grok_live_settings() -> Result<Value, AppError> {
|
||
let path = get_grok_config_path();
|
||
if !path.exists() {
|
||
return Err(AppError::localized(
|
||
"grokbuild.config.missing",
|
||
"Grok Build 配置文件不存在",
|
||
"Grok Build configuration file not found",
|
||
));
|
||
}
|
||
|
||
let config = fs::read_to_string(&path).map_err(|error| AppError::io(&path, error))?;
|
||
validate_config_toml_syntax(&config)?;
|
||
Ok(json!({ "config": config }))
|
||
}
|
||
|
||
pub fn write_grok_provider_live(provider: &Provider) -> Result<(), AppError> {
|
||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.settings.not_object",
|
||
"Grok Build 配置必须是 JSON 对象",
|
||
"Grok Build configuration must be a JSON object",
|
||
)
|
||
})?;
|
||
let config = settings
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.missing",
|
||
"Grok Build 配置缺少 config 字段",
|
||
"Grok Build configuration is missing the config field",
|
||
)
|
||
})?;
|
||
|
||
// 官方条目不注入自定义模型表:按快照原样写回(首次为空文件),
|
||
// Grok CLI 回落到官方内置模型 + 自带 OAuth 登录;MCP 投影随后由
|
||
// 切换流程重新补写。非官方供应商必须携带完整的自定义模型配置。
|
||
if provider.category.as_deref() != Some("official") {
|
||
validate_config_toml(config)?;
|
||
}
|
||
|
||
write_grok_live_settings(&json!({ "config": config }))
|
||
}
|
||
|
||
/// Raw live-file writer, mirroring `read_grok_live_settings` (syntax-only).
|
||
///
|
||
/// 代理接管的备份/恢复也走这里:官方态 live(无自定义模型表)必须可以
|
||
/// 原样写回。完整形状校验由 `write_grok_provider_live` 的非官方分支负责。
|
||
pub fn write_grok_live_settings(settings: &Value) -> Result<(), AppError> {
|
||
let config = settings
|
||
.get("config")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| {
|
||
AppError::localized(
|
||
"provider.grokbuild.config.missing",
|
||
"Grok Build 配置缺少 config 字段",
|
||
"Grok Build configuration is missing the config field",
|
||
)
|
||
})?;
|
||
validate_config_toml_syntax(config)?;
|
||
write_text_file(&get_grok_config_path(), config)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serial_test::serial;
|
||
use tempfile::TempDir;
|
||
|
||
fn valid_config() -> &'static str {
|
||
r#"[models]
|
||
default = "grok-4.5"
|
||
|
||
[model."grok-4.5"]
|
||
model = "grok-4.5"
|
||
base_url = "https://example.com/v1"
|
||
name = "Example"
|
||
api_key = "secret"
|
||
api_backend = "responses"
|
||
context_window = 500000
|
||
"#
|
||
}
|
||
|
||
fn valid_env_key_config() -> &'static str {
|
||
r#"[models]
|
||
default = "grok-env"
|
||
|
||
[model."grok-env"]
|
||
model = "grok-4.5"
|
||
base_url = "https://example.com/v1"
|
||
name = "Example Env"
|
||
env_key = "GROK_TEST_API_KEY"
|
||
api_backend = "responses"
|
||
context_window = 500000
|
||
"#
|
||
}
|
||
|
||
#[test]
|
||
fn validates_expected_config_shape() {
|
||
validate_config_toml(valid_config()).expect("valid Grok Build config");
|
||
validate_config_toml(valid_env_key_config()).expect("valid env_key configuration");
|
||
}
|
||
|
||
#[test]
|
||
fn syntax_validation_accepts_official_snapshots() {
|
||
validate_config_toml_syntax("").expect("empty official snapshot");
|
||
validate_config_toml_syntax("[mcp_servers.echo]\ncommand = \"echo\"\n")
|
||
.expect("official-mode config without model tables");
|
||
assert!(validate_config_toml_syntax("not = [valid").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn official_live_config_detection() {
|
||
// 官方态:完全没有自定义模型痕迹
|
||
assert!(is_official_live_config(""));
|
||
assert!(is_official_live_config(" \n# comment only\n"));
|
||
assert!(is_official_live_config(
|
||
"[mcp_servers.echo]\ncommand = \"echo\"\n"
|
||
));
|
||
|
||
// 出现过任一自定义键(哪怕残缺)都不是官方态,交给强校验报错
|
||
assert!(!is_official_live_config(valid_config()));
|
||
assert!(!is_official_live_config("[models]\ndefault = \"x\"\n"));
|
||
assert!(!is_official_live_config("[model.x]\nmodel = \"x\"\n"));
|
||
|
||
// 语法不合法不是官方态
|
||
assert!(!is_official_live_config("not = [valid"));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_missing_selected_model_table() {
|
||
let error = validate_config_toml("[models]\ndefault = \"grok-4.5\"\n")
|
||
.expect_err("missing model table should fail");
|
||
assert!(error.to_string().contains("model"));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_config_without_api_key_or_env_key() {
|
||
let config = valid_config().replace("api_key = \"secret\"\n", "");
|
||
let error = validate_config_toml(&config).expect_err("credentials should be required");
|
||
assert!(error.to_string().contains("api_key"));
|
||
assert!(error.to_string().contains("env_key"));
|
||
}
|
||
|
||
#[test]
|
||
fn extracts_selected_model_and_updates_takeover_fields() {
|
||
let selected = extract_model_config(valid_config()).expect("selected model");
|
||
assert_eq!(selected.profile, "grok-4.5");
|
||
assert_eq!(selected.model, "grok-4.5");
|
||
assert_eq!(selected.base_url, "https://example.com/v1");
|
||
|
||
let updated = apply_proxy_takeover(
|
||
valid_config(),
|
||
"http://127.0.0.1:15721/grokbuild/v1",
|
||
"PROXY_MANAGED",
|
||
)
|
||
.expect("takeover config");
|
||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||
assert_eq!(selected.base_url, "http://127.0.0.1:15721/grokbuild/v1");
|
||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||
assert!(has_proxy_placeholder(&updated, "PROXY_MANAGED"));
|
||
}
|
||
|
||
#[test]
|
||
fn takeover_preserves_env_key_profile_and_injects_inline_placeholder() {
|
||
let updated = apply_proxy_takeover(
|
||
valid_env_key_config(),
|
||
"http://127.0.0.1:15721/grokbuild/v1",
|
||
"PROXY_MANAGED",
|
||
)
|
||
.expect("takeover config");
|
||
let selected = extract_model_config(&updated).expect("updated selected model");
|
||
|
||
assert_eq!(selected.profile, "grok-env");
|
||
assert_eq!(selected.env_key.as_deref(), Some("GROK_TEST_API_KEY"));
|
||
assert_eq!(selected.api_key.as_deref(), Some("PROXY_MANAGED"));
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn resolves_api_key_from_configured_environment_variable() {
|
||
let original = std::env::var_os("GROK_TEST_API_KEY");
|
||
std::env::set_var("GROK_TEST_API_KEY", "env-secret");
|
||
|
||
let credentials = extract_credentials(valid_env_key_config()).expect("credentials");
|
||
|
||
assert_eq!(credentials.0, "https://example.com/v1");
|
||
assert_eq!(credentials.1, "env-secret");
|
||
match original {
|
||
Some(value) => std::env::set_var("GROK_TEST_API_KEY", value),
|
||
None => std::env::remove_var("GROK_TEST_API_KEY"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn strips_projected_mcp_servers_without_touching_model_config() {
|
||
let mut settings = json!({
|
||
"config": format!(
|
||
"{}\n[mcp_servers.echo]\ncommand = \"echo\"\n",
|
||
valid_config()
|
||
)
|
||
});
|
||
|
||
strip_grok_mcp_servers_from_settings(&mut settings).expect("strip MCP servers");
|
||
|
||
let config = settings.get("config").and_then(Value::as_str).unwrap();
|
||
assert!(!config.contains("mcp_servers"));
|
||
assert!(config.contains("model = \"grok-4.5\""));
|
||
validate_config_toml(config).expect("stripped config remains valid");
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn official_provider_roundtrips_without_custom_model_tables() {
|
||
let temp = TempDir::new().expect("temp dir");
|
||
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||
|
||
// 官方条目:空 config 可写(清掉自定义模型表,交还 Grok CLI 官方登录)
|
||
let mut official = Provider::with_id(
|
||
"grokbuild-official".to_string(),
|
||
"Grok Official".to_string(),
|
||
json!({ "config": "" }),
|
||
None,
|
||
);
|
||
official.category = Some("official".to_string());
|
||
write_grok_provider_live(&official).expect("official empty config is writable");
|
||
assert_eq!(
|
||
fs::read_to_string(get_grok_config_path()).expect("read config"),
|
||
""
|
||
);
|
||
|
||
// 官方态 live(如 MCP 投影补写后)无自定义模型表,读取与原样写回都必须可用
|
||
let official_live = "[mcp_servers.echo]\ncommand = \"echo\"\n";
|
||
write_grok_live_settings(&json!({ "config": official_live }))
|
||
.expect("official-mode live is writable for backup restore");
|
||
let settings = read_grok_live_settings().expect("official-mode live is readable");
|
||
assert_eq!(
|
||
settings.get("config").and_then(Value::as_str),
|
||
Some(official_live)
|
||
);
|
||
|
||
// 非官方供应商仍要求完整的自定义模型配置
|
||
let custom = Provider::with_id(
|
||
"custom".to_string(),
|
||
"Custom".to_string(),
|
||
json!({ "config": "" }),
|
||
None,
|
||
);
|
||
assert!(write_grok_provider_live(&custom).is_err());
|
||
|
||
match original_test_home {
|
||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn writes_and_reads_live_config() {
|
||
let temp = TempDir::new().expect("temp dir");
|
||
let original_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
|
||
|
||
let provider = Provider::with_id(
|
||
"grok".to_string(),
|
||
"Example".to_string(),
|
||
json!({ "config": valid_config() }),
|
||
None,
|
||
);
|
||
write_grok_provider_live(&provider).expect("write live config");
|
||
|
||
let path = get_grok_config_path();
|
||
assert_eq!(path, temp.path().join(".grok").join("config.toml"));
|
||
assert_eq!(
|
||
fs::read_to_string(path).expect("read config"),
|
||
valid_config()
|
||
);
|
||
assert_eq!(
|
||
read_grok_live_settings()
|
||
.expect("read live settings")
|
||
.get("config")
|
||
.and_then(Value::as_str),
|
||
Some(valid_config())
|
||
);
|
||
|
||
match original_test_home {
|
||
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
|
||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
}
|
||
}
|