Skip to main content

cc_switch_lib/
codex_config.rs

1// unused imports removed
2use std::path::PathBuf;
3
4use crate::config::{
5    atomic_write, delete_file, home_dir, sanitize_provider_name, write_json_file, write_text_file,
6};
7use crate::error::AppError;
8use serde_json::Value;
9use std::fs;
10use std::path::Path;
11use toml_edit::DocumentMut;
12
13pub const CC_SWITCH_CODEX_MODEL_PROVIDER_ID: &str = "ccswitch";
14
15/// Reserved built-in provider IDs from OpenAI Codex's config/model-provider
16/// catalog. Keep in sync with Codex `RESERVED_MODEL_PROVIDER_IDS` and legacy
17/// removed provider aliases.
18const CODEX_RESERVED_MODEL_PROVIDER_IDS: &[&str] = &[
19    "amazon-bedrock",
20    "openai",
21    "ollama",
22    "lmstudio",
23    "oss",
24    "ollama-chat",
25];
26
27/// 获取 Codex 配置目录路径
28pub fn get_codex_config_dir() -> PathBuf {
29    if let Some(custom) = crate::settings::get_codex_override_dir() {
30        return custom;
31    }
32
33    home_dir().expect("无法获取用户主目录").join(".codex")
34}
35
36/// 获取 Codex auth.json 路径
37pub fn get_codex_auth_path() -> PathBuf {
38    get_codex_config_dir().join("auth.json")
39}
40
41/// 获取 Codex config.toml 路径
42pub fn get_codex_config_path() -> PathBuf {
43    get_codex_config_dir().join("config.toml")
44}
45
46/// 获取 Codex 供应商配置文件路径
47pub fn get_codex_provider_paths(
48    provider_id: &str,
49    provider_name: Option<&str>,
50) -> (PathBuf, PathBuf) {
51    let base_name = provider_name
52        .map(sanitize_provider_name)
53        .unwrap_or_else(|| sanitize_provider_name(provider_id));
54
55    let auth_path = get_codex_config_dir().join(format!("auth-{base_name}.json"));
56    let config_path = get_codex_config_dir().join(format!("config-{base_name}.toml"));
57
58    (auth_path, config_path)
59}
60
61/// 删除 Codex 供应商配置文件
62pub fn delete_codex_provider_config(
63    provider_id: &str,
64    provider_name: &str,
65) -> Result<(), AppError> {
66    let (auth_path, config_path) = get_codex_provider_paths(provider_id, Some(provider_name));
67
68    delete_file(&auth_path).ok();
69    delete_file(&config_path).ok();
70
71    Ok(())
72}
73
74/// 原子写 Codex 的 `auth.json` 与 `config.toml`,在第二步失败时回滚第一步
75pub fn write_codex_live_atomic(
76    auth: &Value,
77    config_text_opt: Option<&str>,
78) -> Result<(), AppError> {
79    write_codex_live_atomic_optional_auth(Some(auth), config_text_opt)
80}
81
82pub fn write_codex_live_atomic_optional_auth(
83    auth: Option<&Value>,
84    config_text_opt: Option<&str>,
85) -> Result<(), AppError> {
86    let auth_path = get_codex_auth_path();
87    let config_path = get_codex_config_path();
88
89    if let Some(parent) = auth_path.parent() {
90        std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
91    }
92
93    // 读取旧内容用于回滚
94    let old_auth = if auth_path.exists() {
95        Some(fs::read(&auth_path).map_err(|e| AppError::io(&auth_path, e))?)
96    } else {
97        None
98    };
99    let _old_config = if config_path.exists() {
100        Some(fs::read(&config_path).map_err(|e| AppError::io(&config_path, e))?)
101    } else {
102        None
103    };
104
105    // 准备写入内容
106    let cfg_text = match config_text_opt {
107        Some(s) => s.to_string(),
108        None => String::new(),
109    };
110    if !cfg_text.trim().is_empty() {
111        toml::from_str::<toml::Table>(&cfg_text).map_err(|e| AppError::toml(&config_path, e))?;
112    }
113
114    // 第一步:写 auth.json
115    if let Some(auth) = auth {
116        write_json_file(&auth_path, auth)?;
117    } else {
118        delete_file(&auth_path)?;
119    }
120
121    // 第二步:写 config.toml(失败则回滚 auth.json)
122    if let Err(e) = write_text_file(&config_path, &cfg_text) {
123        // 回滚 auth.json
124        if let Some(bytes) = old_auth {
125            let _ = atomic_write(&auth_path, &bytes);
126        } else {
127            let _ = delete_file(&auth_path);
128        }
129        return Err(e);
130    }
131
132    Ok(())
133}
134
135/// 读取 `~/.codex/config.toml`,若不存在返回空字符串
136pub fn read_codex_config_text() -> Result<String, AppError> {
137    let path = get_codex_config_path();
138    if path.exists() {
139        std::fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))
140    } else {
141        Ok(String::new())
142    }
143}
144
145/// 对非空的 TOML 文本进行语法校验
146pub fn validate_config_toml(text: &str) -> Result<(), AppError> {
147    if text.trim().is_empty() {
148        return Ok(());
149    }
150    toml::from_str::<toml::Table>(text)
151        .map(|_| ())
152        .map_err(|e| AppError::toml(Path::new("config.toml"), e))
153}
154
155/// Remove provider-specific Codex TOML keys and keep only shared/global settings.
156///
157/// This matches upstream "OpenAI Official" snapshot semantics where the official
158/// provider does not persist a provider-local `base_url` / `model_provider`
159/// section, but may still carry root-level shared settings.
160pub fn strip_codex_provider_config_text(config_toml: &str) -> Result<String, AppError> {
161    let config_toml = config_toml.trim();
162    if config_toml.is_empty() {
163        return Ok(String::new());
164    }
165
166    let mut doc = config_toml
167        .parse::<toml_edit::DocumentMut>()
168        .map_err(|e| AppError::Config(format!("TOML parse error: {e}")))?;
169    let root = doc.as_table_mut();
170    root.remove("model");
171    root.remove("model_provider");
172    root.remove("base_url");
173    root.remove("model_providers");
174
175    let mut cleaned = String::new();
176    let mut blank_run = 0usize;
177    for line in doc.to_string().lines() {
178        if line.trim().is_empty() {
179            blank_run += 1;
180            if blank_run <= 1 {
181                cleaned.push('\n');
182            }
183            continue;
184        }
185        blank_run = 0;
186        cleaned.push_str(line);
187        cleaned.push('\n');
188    }
189
190    Ok(cleaned.trim().to_string())
191}
192
193/// 读取并校验 `~/.codex/config.toml`,返回文本(可能为空)
194pub fn read_and_validate_codex_config_text() -> Result<String, AppError> {
195    let s = read_codex_config_text()?;
196    validate_config_toml(&s)?;
197    Ok(s)
198}
199
200fn active_codex_model_provider_id(doc: &DocumentMut) -> Option<String> {
201    doc.get("model_provider")
202        .and_then(|item| item.as_str())
203        .map(str::trim)
204        .filter(|id| !id.is_empty())
205        .map(str::to_string)
206}
207
208fn is_custom_codex_model_provider_id(id: &str) -> bool {
209    let id = id.trim();
210    !id.is_empty()
211        && !CODEX_RESERVED_MODEL_PROVIDER_IDS
212            .iter()
213            .any(|reserved| reserved.eq_ignore_ascii_case(id))
214}
215
216fn stable_codex_model_provider_id_from_config(config_text: &str) -> Option<String> {
217    let doc = config_text.parse::<DocumentMut>().ok()?;
218    let provider_id = active_codex_model_provider_id(&doc)?;
219
220    if is_custom_codex_model_provider_id(&provider_id) {
221        Some(provider_id)
222    } else {
223        None
224    }
225}
226
227fn codex_model_provider_id_with_table_from_config(
228    config_text: &str,
229) -> Result<Option<String>, AppError> {
230    if config_text.trim().is_empty() {
231        return Ok(None);
232    }
233
234    let doc = config_text
235        .parse::<DocumentMut>()
236        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
237    let Some(provider_id) = active_codex_model_provider_id(&doc) else {
238        return Ok(None);
239    };
240
241    let has_provider_table = doc
242        .get("model_providers")
243        .and_then(|item| item.as_table())
244        .and_then(|table| table.get(provider_id.as_str()))
245        .is_some();
246
247    Ok(has_provider_table.then_some(provider_id))
248}
249
250fn normalize_codex_live_config_model_provider_with_anchors<'a>(
251    config_text: &str,
252    anchor_config_texts: impl IntoIterator<Item = &'a str>,
253) -> Result<String, AppError> {
254    if config_text.trim().is_empty() {
255        return Ok(config_text.to_string());
256    }
257
258    let mut doc = config_text
259        .parse::<DocumentMut>()
260        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
261
262    let Some(source_provider_id) = active_codex_model_provider_id(&doc) else {
263        return Ok(config_text.to_string());
264    };
265
266    let has_source_provider_table = doc
267        .get("model_providers")
268        .and_then(|item| item.as_table())
269        .and_then(|table| table.get(source_provider_id.as_str()))
270        .is_some();
271    if !has_source_provider_table {
272        return Ok(config_text.to_string());
273    }
274
275    let stable_provider_id = anchor_config_texts
276        .into_iter()
277        .find_map(stable_codex_model_provider_id_from_config)
278        .or_else(|| {
279            is_custom_codex_model_provider_id(&source_provider_id)
280                .then(|| source_provider_id.clone())
281        })
282        .unwrap_or_else(|| CC_SWITCH_CODEX_MODEL_PROVIDER_ID.to_string());
283
284    if stable_provider_id == source_provider_id {
285        return Ok(config_text.to_string());
286    }
287
288    if let Some(model_providers) = doc
289        .get_mut("model_providers")
290        .and_then(|item| item.as_table_mut())
291    {
292        let Some(provider_table) = model_providers.remove(source_provider_id.as_str()) else {
293            return Ok(config_text.to_string());
294        };
295        model_providers[stable_provider_id.as_str()] = provider_table;
296    }
297
298    rewrite_codex_profile_model_provider_refs(&mut doc, &source_provider_id, &stable_provider_id);
299    doc["model_provider"] = toml_edit::value(stable_provider_id.as_str());
300
301    Ok(doc.to_string())
302}
303
304fn rewrite_codex_profile_model_provider_refs(
305    doc: &mut DocumentMut,
306    source_provider_id: &str,
307    stable_provider_id: &str,
308) {
309    let Some(profiles) = doc
310        .get_mut("profiles")
311        .and_then(|item| item.as_table_like_mut())
312    else {
313        return;
314    };
315
316    let profile_keys: Vec<String> = profiles.iter().map(|(key, _)| key.to_string()).collect();
317    for profile_key in profile_keys {
318        let Some(profile_table) = profiles
319            .get_mut(&profile_key)
320            .and_then(|item| item.as_table_like_mut())
321        else {
322            continue;
323        };
324
325        let references_source = profile_table
326            .get("model_provider")
327            .and_then(|item| item.as_str())
328            == Some(source_provider_id);
329        if references_source {
330            profile_table.insert("model_provider", toml_edit::value(stable_provider_id));
331        }
332    }
333}
334
335/// Keep Codex's active `model_provider` stable across CC Switch provider changes.
336///
337/// Codex stores and filters resume history by `model_provider`, so switching between
338/// provider-specific ids like `rightcode` and `aihubmix` makes history appear to move.
339/// We preserve an existing custom provider id when possible and only rewrite the
340/// live config text that Codex sees at provider-driven write boundaries.
341pub fn normalize_codex_settings_config_model_provider(
342    settings: &mut Value,
343    anchor_config_text: Option<&str>,
344) -> Result<(), AppError> {
345    let Some(config_text) = settings
346        .get("config")
347        .and_then(|value| value.as_str())
348        .map(str::to_string)
349    else {
350        return Ok(());
351    };
352
353    let current_config_text = read_codex_config_text().ok();
354    let anchors = anchor_config_text
355        .into_iter()
356        .chain(current_config_text.as_deref());
357    let normalized =
358        normalize_codex_live_config_model_provider_with_anchors(&config_text, anchors)?;
359
360    if let Some(obj) = settings.as_object_mut() {
361        obj.insert("config".to_string(), Value::String(normalized));
362    }
363
364    Ok(())
365}
366
367fn restore_codex_backfill_model_provider_id(
368    config_text: &str,
369    template_config_text: &str,
370) -> Result<String, AppError> {
371    let Some(template_provider_id) =
372        codex_model_provider_id_with_table_from_config(template_config_text)?
373    else {
374        return Ok(config_text.to_string());
375    };
376
377    if config_text.trim().is_empty() {
378        return Ok(config_text.to_string());
379    }
380
381    let mut doc = config_text
382        .parse::<DocumentMut>()
383        .map_err(|e| AppError::Message(format!("Invalid Codex config.toml: {e}")))?;
384    let Some(live_provider_id) = active_codex_model_provider_id(&doc) else {
385        return Ok(config_text.to_string());
386    };
387
388    if live_provider_id == template_provider_id {
389        return Ok(config_text.to_string());
390    }
391
392    if let Some(model_providers) = doc
393        .get_mut("model_providers")
394        .and_then(|item| item.as_table_mut())
395    {
396        let Some(provider_table) = model_providers.remove(live_provider_id.as_str()) else {
397            return Ok(config_text.to_string());
398        };
399        model_providers[template_provider_id.as_str()] = provider_table;
400    } else {
401        return Ok(config_text.to_string());
402    }
403
404    rewrite_codex_profile_model_provider_refs(&mut doc, &live_provider_id, &template_provider_id);
405    doc["model_provider"] = toml_edit::value(template_provider_id.as_str());
406
407    Ok(doc.to_string())
408}
409
410/// Convert a Codex live config that was normalized for history stability back
411/// to the provider-specific id used by the stored provider template.
412pub fn restore_codex_settings_config_model_provider_for_backfill(
413    settings: &mut Value,
414    template_settings: &Value,
415) -> Result<(), AppError> {
416    let Some(config_text) = settings
417        .get("config")
418        .and_then(|value| value.as_str())
419        .map(str::to_string)
420    else {
421        return Ok(());
422    };
423    let Some(template_config_text) = template_settings
424        .get("config")
425        .and_then(|value| value.as_str())
426    else {
427        return Ok(());
428    };
429
430    let restored = restore_codex_backfill_model_provider_id(&config_text, template_config_text)?;
431    if let Some(obj) = settings.as_object_mut() {
432        obj.insert("config".to_string(), Value::String(restored));
433    }
434
435    Ok(())
436}
437
438/// Atomically write Codex live config after normalizing provider-specific ids.
439///
440/// Use this for provider-driven live writes. Keep `write_codex_live_atomic` available
441/// for exact restore/backup paths that must preserve the config text semantically as saved.
442pub fn write_codex_live_atomic_with_stable_provider(
443    auth: &Value,
444    config_text_opt: Option<&str>,
445) -> Result<(), AppError> {
446    write_codex_live_atomic_optional_auth_with_stable_provider(Some(auth), config_text_opt)
447}
448
449pub fn write_codex_live_atomic_optional_auth_with_stable_provider(
450    auth: Option<&Value>,
451    config_text_opt: Option<&str>,
452) -> Result<(), AppError> {
453    match config_text_opt {
454        Some(config_text) => {
455            let mut settings = serde_json::Map::new();
456            settings.insert("config".to_string(), Value::String(config_text.to_string()));
457            let mut settings = Value::Object(settings);
458            normalize_codex_settings_config_model_provider(&mut settings, None)?;
459            let config_text = settings
460                .get("config")
461                .and_then(|value| value.as_str())
462                .unwrap_or(config_text);
463            write_codex_live_atomic_optional_auth(auth, Some(config_text))
464        }
465        None => write_codex_live_atomic_optional_auth(auth, None),
466    }
467}
468
469/// Generate a clean TOML key from a raw string for use as `model_provider` and `[model_providers.<key>]`.
470///
471/// Lowercases ASCII alphanumerics, replaces everything else with `_`, trims leading/trailing `_`.
472/// Falls back to `"custom"` if the result is empty.
473pub fn clean_codex_provider_key(raw: &str) -> String {
474    let mut key: String = raw
475        .chars()
476        .map(|c| {
477            if c.is_ascii_alphanumeric() {
478                c.to_ascii_lowercase()
479            } else {
480                '_'
481            }
482        })
483        .collect();
484
485    while key.starts_with('_') {
486        key.remove(0);
487    }
488    while key.ends_with('_') {
489        key.pop();
490    }
491
492    if key.is_empty() {
493        "custom".to_string()
494    } else {
495        key
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn normalize_live_config_preserves_current_custom_model_provider_id() {
505        let current = r#"model_provider = "rightcode"
506
507[model_providers.rightcode]
508name = "RightCode"
509base_url = "https://rightcode.example/v1"
510wire_api = "responses"
511"#;
512        let target = r#"model_provider = "aihubmix"
513model = "gpt-5.4"
514
515[model_providers.aihubmix]
516name = "AiHubMix"
517base_url = "https://aihubmix.example/v1"
518wire_api = "responses"
519requires_openai_auth = true
520
521[mcp_servers.context7]
522command = "npx"
523"#;
524
525        let result =
526            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
527        let parsed: toml::Value = toml::from_str(&result).unwrap();
528
529        assert_eq!(
530            parsed.get("model_provider").and_then(|v| v.as_str()),
531            Some("rightcode")
532        );
533
534        let model_providers = parsed
535            .get("model_providers")
536            .and_then(|v| v.as_table())
537            .expect("model_providers should exist");
538        assert!(
539            model_providers.get("aihubmix").is_none(),
540            "source provider id should not remain in live config"
541        );
542
543        let stable_provider = model_providers
544            .get("rightcode")
545            .expect("stable provider table should exist");
546        assert_eq!(
547            stable_provider.get("base_url").and_then(|v| v.as_str()),
548            Some("https://aihubmix.example/v1")
549        );
550        assert!(
551            parsed.get("mcp_servers").is_some(),
552            "unrelated config should be preserved"
553        );
554    }
555
556    #[test]
557    fn normalize_live_config_uses_target_custom_provider_when_current_is_reserved() {
558        let current = r#"model_provider = "openai""#;
559        let target = r#"model_provider = "aihubmix"
560
561[model_providers.aihubmix]
562name = "AiHubMix"
563base_url = "https://aihubmix.example/v1"
564wire_api = "responses"
565"#;
566
567        let result =
568            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
569        let parsed: toml::Value = toml::from_str(&result).unwrap();
570
571        assert_eq!(
572            parsed.get("model_provider").and_then(|v| v.as_str()),
573            Some("aihubmix")
574        );
575        assert!(
576            parsed
577                .get("model_providers")
578                .and_then(|v| v.get("aihubmix"))
579                .is_some(),
580            "target provider id should be kept when there is no reusable live custom id"
581        );
582    }
583
584    #[test]
585    fn normalize_live_config_leaves_official_empty_config_unchanged() {
586        let current = r#"model_provider = "rightcode"
587
588[model_providers.rightcode]
589base_url = "https://rightcode.example/v1"
590"#;
591
592        let result =
593            normalize_codex_live_config_model_provider_with_anchors("", Some(current)).unwrap();
594
595        assert_eq!(result, "");
596    }
597
598    #[test]
599    fn normalize_live_config_rewrites_matching_profile_model_provider_refs() {
600        let current = r#"model_provider = "session_anchor"
601
602[model_providers.session_anchor]
603name = "Session Anchor"
604base_url = "https://anchor.example/v1"
605wire_api = "responses"
606"#;
607        let target = r#"model_provider = "vendor_alpha"
608model = "gpt-5.4"
609profile = "work"
610
611[model_providers.vendor_alpha]
612name = "Vendor Alpha"
613base_url = "https://alpha.example/v1"
614wire_api = "responses"
615
616[profiles.work]
617model_provider = "vendor_alpha"
618model = "gpt-5.4"
619"#;
620
621        let result =
622            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
623        let parsed: toml::Value = toml::from_str(&result).unwrap();
624
625        assert_eq!(
626            parsed.get("model_provider").and_then(|v| v.as_str()),
627            Some("session_anchor")
628        );
629        assert_eq!(
630            parsed
631                .get("profiles")
632                .and_then(|v| v.get("work"))
633                .and_then(|v| v.get("model_provider"))
634                .and_then(|v| v.as_str()),
635            Some("session_anchor"),
636            "profile override matching the rewritten provider should stay valid"
637        );
638    }
639
640    #[test]
641    fn normalize_live_config_keeps_unrelated_profile_model_provider_refs() {
642        let current = r#"model_provider = "session_anchor"
643
644[model_providers.session_anchor]
645name = "Session Anchor"
646base_url = "https://anchor.example/v1"
647wire_api = "responses"
648"#;
649        let target = r#"model_provider = "vendor_alpha"
650model = "gpt-5.4"
651
652[model_providers.vendor_alpha]
653name = "Vendor Alpha"
654base_url = "https://alpha.example/v1"
655wire_api = "responses"
656
657[model_providers.local_profile]
658name = "Local Profile"
659base_url = "http://localhost:11434/v1"
660wire_api = "responses"
661
662[profiles.local]
663model_provider = "local_profile"
664model = "local-model"
665"#;
666
667        let result =
668            normalize_codex_live_config_model_provider_with_anchors(target, Some(current)).unwrap();
669        let parsed: toml::Value = toml::from_str(&result).unwrap();
670
671        assert_eq!(
672            parsed
673                .get("profiles")
674                .and_then(|v| v.get("local"))
675                .and_then(|v| v.get("model_provider"))
676                .and_then(|v| v.as_str()),
677            Some("local_profile"),
678            "unrelated profile provider references should be preserved"
679        );
680        assert!(
681            parsed
682                .get("model_providers")
683                .and_then(|v| v.get("local_profile"))
684                .is_some(),
685            "unrelated provider tables should also remain available"
686        );
687    }
688
689    #[test]
690    fn normalize_live_config_keeps_stable_provider_across_repeated_switches() {
691        let anchor = r#"model_provider = "session_anchor"
692
693[model_providers.session_anchor]
694name = "Session Anchor"
695base_url = "https://anchor.example/v1"
696wire_api = "responses"
697"#;
698        let first_target = r#"model_provider = "vendor_alpha"
699
700[model_providers.vendor_alpha]
701name = "Vendor Alpha"
702base_url = "https://alpha.example/v1"
703wire_api = "responses"
704"#;
705        let second_target = r#"model_provider = "vendor_beta"
706
707[model_providers.vendor_beta]
708name = "Vendor Beta"
709base_url = "https://beta.example/v1"
710wire_api = "responses"
711"#;
712
713        let first =
714            normalize_codex_live_config_model_provider_with_anchors(first_target, Some(anchor))
715                .unwrap();
716        let second = normalize_codex_live_config_model_provider_with_anchors(
717            second_target,
718            Some(first.as_str()),
719        )
720        .unwrap();
721        let parsed: toml::Value = toml::from_str(&second).unwrap();
722
723        assert_eq!(
724            parsed.get("model_provider").and_then(|v| v.as_str()),
725            Some("session_anchor"),
726            "stable provider id should not drift across repeated switches"
727        );
728        assert_eq!(
729            parsed
730                .get("model_providers")
731                .and_then(|v| v.get("session_anchor"))
732                .and_then(|v| v.get("base_url"))
733                .and_then(|v| v.as_str()),
734            Some("https://beta.example/v1")
735        );
736    }
737
738    #[test]
739    fn restore_backfill_config_rewrites_live_id_to_template_provider_id() {
740        let mut settings = serde_json::json!({
741            "config": r#"model_provider = "session_anchor"
742model = "gpt-5.4"
743profile = "work"
744
745[model_providers.session_anchor]
746name = "AiHubMix"
747base_url = "https://aihubmix.example/v1"
748wire_api = "responses"
749
750[profiles.work]
751model_provider = "session_anchor"
752model = "gpt-5.4"
753"#
754        });
755        let template = serde_json::json!({
756            "config": r#"model_provider = "aihubmix"
757
758[model_providers.aihubmix]
759name = "AiHubMix"
760base_url = "https://aihubmix.example/v1"
761"#
762        });
763
764        restore_codex_settings_config_model_provider_for_backfill(&mut settings, &template)
765            .unwrap();
766
767        let config = settings.get("config").and_then(Value::as_str).unwrap();
768        let parsed: toml::Value = toml::from_str(config).unwrap();
769        assert_eq!(
770            parsed.get("model_provider").and_then(|v| v.as_str()),
771            Some("aihubmix")
772        );
773        assert!(parsed
774            .get("model_providers")
775            .and_then(|v| v.get("aihubmix"))
776            .is_some());
777        assert_eq!(
778            parsed
779                .get("profiles")
780                .and_then(|v| v.get("work"))
781                .and_then(|v| v.get("model_provider"))
782                .and_then(|v| v.as_str()),
783            Some("aihubmix")
784        );
785    }
786}