Skip to main content

opencode_provider_manager/app/
import.rs

1//! Config import and export functionality.
2
3use super::error::{AppError, Result};
4use super::state::AppState;
5use crate::config_core::{ConfigLayer, ModelConfig, ModelLimit, OpenCodeConfig, ProviderConfig};
6use serde::Deserialize;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11const IMPORT_META_KEY: &str = "_opmImport";
12const IMPORTABLE_EXTENSIONS: &[&str] = &["json", "jsonc", "toml", "yaml", "yml"];
13
14/// Import configuration from an external opencode.json file.
15pub fn import_config(
16    state: &mut AppState,
17    path: &Path,
18    layer: ConfigLayer,
19    merge_mode: ImportMergeMode,
20) -> Result<()> {
21    let external_config = parse_import_path(path, None)?;
22    apply_import_config(state, external_config, layer, merge_mode)
23}
24
25/// Import a JSON/JSONC/TOML/YAML snippet into an app state layer.
26pub fn import_snippet(
27    state: &mut AppState,
28    snippet: &str,
29    provider_id_hint: Option<&str>,
30    source_label: Option<&str>,
31    layer: ConfigLayer,
32    merge_mode: ImportMergeMode,
33) -> Result<ImportSummary> {
34    let config = parse_import_snippet(snippet, provider_id_hint, source_label)?;
35    let summary = ImportSummary::from_config(&config);
36    apply_import_config(state, config, layer, merge_mode)?;
37    Ok(summary)
38}
39
40/// Import from a local path or supported URL.
41pub fn import_source(
42    state: &mut AppState,
43    source: &str,
44    provider_id_hint: Option<&str>,
45    layer: ConfigLayer,
46    merge_mode: ImportMergeMode,
47) -> Result<ImportSummary> {
48    let config = parse_import_source(source, provider_id_hint)?;
49    let summary = ImportSummary::from_config(&config);
50    apply_import_config(state, config, layer, merge_mode)?;
51    Ok(summary)
52}
53
54/// Parse an import source without mutating state.
55pub fn parse_import_source(source: &str, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
56    if is_url(source) {
57        parse_import_url(source, provider_id_hint)
58    } else {
59        let path = Path::new(source);
60        if path.exists() {
61            parse_import_path(path, provider_id_hint)
62        } else {
63            parse_import_snippet(source, provider_id_hint, Some("inline snippet"))
64        }
65    }
66}
67
68/// Parse an inline JSON/JSONC/TOML/YAML import snippet.
69pub fn parse_import_snippet(
70    snippet: &str,
71    provider_id_hint: Option<&str>,
72    source_label: Option<&str>,
73) -> Result<OpenCodeConfig> {
74    let value = parse_loose_value(snippet)?;
75    normalize_import_value(value, provider_id_hint, source_label)
76}
77
78/// Apply an already-normalized config to a layer.
79pub fn apply_import_config(
80    state: &mut AppState,
81    external_config: OpenCodeConfig,
82    layer: ConfigLayer,
83    merge_mode: ImportMergeMode,
84) -> Result<()> {
85    match merge_mode {
86        ImportMergeMode::Replace => match layer {
87            ConfigLayer::Global => state.global_config = Some(external_config),
88            ConfigLayer::Project => state.project_config = Some(external_config),
89            ConfigLayer::Custom => state.custom_config = Some(external_config),
90        },
91        ImportMergeMode::Merge => {
92            let target = match layer {
93                ConfigLayer::Global => &mut state.global_config,
94                ConfigLayer::Project => &mut state.project_config,
95                ConfigLayer::Custom => &mut state.custom_config,
96            };
97
98            match target {
99                Some(existing) => {
100                    *target = Some(crate::config_core::merge_two(
101                        existing.clone(),
102                        external_config,
103                    ));
104                }
105                None => {
106                    *target = Some(external_config);
107                }
108            }
109        }
110    }
111
112    state.recompute_merged();
113    state.mark_dirty();
114    Ok(())
115}
116
117/// Export current merged config to a file.
118pub fn export_config(state: &AppState, path: &Path, export_scope: ExportScope) -> Result<()> {
119    let config = match export_scope {
120        ExportScope::Merged => &state.merged_config,
121        ExportScope::Global => state
122            .global_config
123            .as_ref()
124            .ok_or_else(|| AppError::State("No global config".to_string()))?,
125        ExportScope::Project => state
126            .project_config
127            .as_ref()
128            .ok_or_else(|| AppError::State("No project config".to_string()))?,
129        ExportScope::Custom => state
130            .custom_config
131            .as_ref()
132            .ok_or_else(|| AppError::State("No custom config".to_string()))?,
133    };
134
135    crate::config_core::jsonc::write_config(config, path)?;
136    Ok(())
137}
138
139fn parse_import_path(path: &Path, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
140    if path.is_dir() {
141        return parse_import_directory(path, provider_id_hint);
142    }
143
144    let content = std::fs::read_to_string(path)?;
145    let hint = provider_id_hint.or_else(|| path.file_stem().and_then(|s| s.to_str()));
146    parse_import_snippet(&content, hint, Some(&path.display().to_string()))
147}
148
149fn parse_import_directory(dir: &Path, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
150    let provider_toml = dir.join("provider.toml");
151    if provider_toml.exists() {
152        return parse_models_dev_directory(dir, provider_id_hint);
153    }
154
155    let mut merged = OpenCodeConfig::default();
156    for path in collect_importable_files(dir)? {
157        let parsed = parse_import_path(&path, provider_id_hint)?;
158        merged = crate::config_core::merge_two(merged, parsed);
159    }
160    if merged == OpenCodeConfig::default() {
161        return Err(AppError::Import(format!(
162            "No importable JSON/TOML/YAML files found in {}",
163            dir.display()
164        )));
165    }
166    Ok(merged)
167}
168
169fn parse_models_dev_directory(
170    dir: &Path,
171    provider_id_hint: Option<&str>,
172) -> Result<OpenCodeConfig> {
173    let provider_id = provider_id_hint
174        .map(str::to_string)
175        .or_else(|| dir.file_name().and_then(|s| s.to_str()).map(str::to_string))
176        .ok_or_else(|| AppError::Import("Provider directory has no usable name".to_string()))?;
177
178    let provider_content = std::fs::read_to_string(dir.join("provider.toml"))?;
179    let provider_value = parse_toml_value(&provider_content)?;
180    let mut provider = models_dev_provider_from_value(provider_value)?;
181
182    let models_dir = dir.join("models");
183    if models_dir.exists() {
184        for model_path in collect_importable_files(&models_dir)? {
185            let model_id = model_path
186                .file_stem()
187                .and_then(|s| s.to_str())
188                .ok_or_else(|| AppError::Import("Model file has no usable name".to_string()))?
189                .to_string();
190            let model_content = std::fs::read_to_string(&model_path)?;
191            let model_value = parse_loose_value(&model_content)?;
192            let model = model_from_value(model_value)?;
193            provider
194                .models
195                .get_or_insert_with(HashMap::new)
196                .insert(model_id, model);
197        }
198    }
199
200    config_from_provider(provider_id, provider, Some(&dir.display().to_string()))
201}
202
203fn parse_import_url(url: &str, provider_id_hint: Option<&str>) -> Result<OpenCodeConfig> {
204    if let Some((owner, repo, branch, path, is_tree)) = parse_github_url(url) {
205        if is_tree {
206            return parse_github_directory(&owner, &repo, &branch, &path, provider_id_hint, url);
207        }
208    }
209
210    let text = http_get_text(url)?;
211    let stem_hint = url_path_stem(url);
212    let hint = provider_id_hint.or(stem_hint.as_deref());
213    parse_import_snippet(&text, hint, Some(url))
214}
215
216fn parse_github_directory(
217    owner: &str,
218    repo: &str,
219    branch: &str,
220    path: &str,
221    provider_id_hint: Option<&str>,
222    source_url: &str,
223) -> Result<OpenCodeConfig> {
224    let entries = github_contents(owner, repo, branch, path)?;
225    let provider_entry = entries
226        .iter()
227        .find(|entry| entry.name == "provider.toml" && entry.download_url.is_some());
228
229    if let Some(provider_entry) = provider_entry {
230        let provider_id = provider_id_hint
231            .map(str::to_string)
232            .or_else(|| path.rsplit('/').next().map(str::to_string))
233            .ok_or_else(|| {
234                AppError::Import("GitHub provider path has no provider ID".to_string())
235            })?;
236
237        let provider_text = http_get_text(provider_entry.download_url.as_ref().unwrap())?;
238        let mut provider = models_dev_provider_from_value(parse_toml_value(&provider_text)?)?;
239
240        let models_path = format!("{}/models", path.trim_end_matches('/'));
241        for model_entry in github_contents(owner, repo, branch, &models_path)? {
242            if model_entry.entry_type == "file" && is_importable_name(&model_entry.name) {
243                let Some(download_url) = model_entry.download_url else {
244                    continue;
245                };
246                let model_id = Path::new(&model_entry.name)
247                    .file_stem()
248                    .and_then(|s| s.to_str())
249                    .ok_or_else(|| {
250                        AppError::Import("Model URL has no usable file name".to_string())
251                    })?
252                    .to_string();
253                let model = model_from_value(parse_loose_value(&http_get_text(&download_url)?)?)?;
254                provider
255                    .models
256                    .get_or_insert_with(HashMap::new)
257                    .insert(model_id, model);
258            }
259        }
260
261        return config_from_provider(provider_id, provider, Some(source_url));
262    }
263
264    let mut merged = OpenCodeConfig::default();
265    for entry in entries {
266        if entry.entry_type == "file" && is_importable_name(&entry.name) {
267            if let Some(download_url) = entry.download_url {
268                let parsed = parse_import_snippet(
269                    &http_get_text(&download_url)?,
270                    provider_id_hint,
271                    Some(&download_url),
272                )?;
273                merged = crate::config_core::merge_two(merged, parsed);
274            }
275        }
276    }
277    Ok(merged)
278}
279
280fn normalize_import_value(
281    value: Value,
282    provider_id_hint: Option<&str>,
283    source_label: Option<&str>,
284) -> Result<OpenCodeConfig> {
285    let mut config = if value.get("provider").is_some()
286        || value.get("$schema").is_some()
287        || value.get("model").is_some()
288        || value.get("smallModel").is_some()
289    {
290        serde_json::from_value::<OpenCodeConfig>(value)?
291    } else if looks_like_provider_map(&value) {
292        let providers = serde_json::from_value::<HashMap<String, ProviderConfig>>(value)?;
293        OpenCodeConfig {
294            provider: Some(providers),
295            ..Default::default()
296        }
297    } else if looks_like_provider(&value) {
298        let provider_id = provider_id_hint.ok_or_else(|| {
299            AppError::Import(
300                "Provider fragment needs a provider ID hint; pass --provider-id or import from a named file/directory".to_string(),
301            )
302        })?;
303        let provider = provider_from_value(value)?;
304        config_from_provider(provider_id.to_string(), provider, source_label)?
305    } else if looks_like_model(&value) {
306        let model_id = provider_id_hint.ok_or_else(|| {
307            AppError::Import(
308                "Model fragment needs an ID hint from --provider-id/path; wrap it in a provider.models object for direct import".to_string(),
309            )
310        })?;
311        let mut provider = ProviderConfig::default();
312        provider
313            .models
314            .get_or_insert_with(HashMap::new)
315            .insert(model_id.to_string(), model_from_value(value)?);
316        config_from_provider(model_id.to_string(), provider, source_label)?
317    } else {
318        return Err(AppError::Import(
319            "Snippet is not a full config, provider map, provider fragment, or model fragment"
320                .to_string(),
321        ));
322    };
323
324    attach_import_metadata(&mut config, source_label);
325    Ok(config)
326}
327
328fn config_from_provider(
329    provider_id: String,
330    provider: ProviderConfig,
331    source_label: Option<&str>,
332) -> Result<OpenCodeConfig> {
333    let mut providers = HashMap::new();
334    providers.insert(provider_id, provider);
335    let mut config = OpenCodeConfig {
336        provider: Some(providers),
337        ..Default::default()
338    };
339    attach_import_metadata(&mut config, source_label);
340    Ok(config)
341}
342
343fn provider_from_value(value: Value) -> Result<ProviderConfig> {
344    if is_models_dev_provider_value(&value) {
345        models_dev_provider_from_value(value)
346    } else {
347        Ok(serde_json::from_value(value)?)
348    }
349}
350
351fn models_dev_provider_from_value(value: Value) -> Result<ProviderConfig> {
352    let obj = value.as_object().ok_or_else(|| {
353        AppError::Import("models.dev provider metadata must be an object".to_string())
354    })?;
355    let mut provider = ProviderConfig {
356        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
357        npm: obj.get("npm").and_then(Value::as_str).map(str::to_string),
358        ..Default::default()
359    };
360
361    let mut options = HashMap::new();
362    if let Some(api) = obj.get("api").and_then(Value::as_str) {
363        options.insert("baseURL".to_string(), Value::String(api.to_string()));
364    }
365    if let Some(env_name) = obj
366        .get("env")
367        .and_then(Value::as_array)
368        .and_then(|items| items.first())
369        .and_then(Value::as_str)
370    {
371        options.insert(
372            "apiKey".to_string(),
373            Value::String(format!("{{env:{env_name}}}")),
374        );
375    }
376    if !options.is_empty() {
377        provider.options = Some(options);
378    }
379
380    for (key, val) in obj {
381        if !matches!(key.as_str(), "name" | "npm" | "api" | "env" | "models") {
382            provider.extra.insert(key.clone(), val.clone());
383        }
384    }
385
386    if let Some(models) = obj.get("models").and_then(Value::as_object) {
387        let mut parsed_models = HashMap::new();
388        for (model_id, model_value) in models {
389            parsed_models.insert(model_id.clone(), model_from_value(model_value.clone())?);
390        }
391        provider.models = Some(parsed_models);
392    }
393
394    Ok(provider)
395}
396
397fn model_from_value(value: Value) -> Result<ModelConfig> {
398    let obj = value
399        .as_object()
400        .ok_or_else(|| AppError::Import("Model metadata must be an object".to_string()))?;
401    let mut model = ModelConfig {
402        name: obj.get("name").and_then(Value::as_str).map(str::to_string),
403        id: obj.get("id").and_then(Value::as_str).map(str::to_string),
404        ..Default::default()
405    };
406
407    if let Some(limit) = obj.get("limit").and_then(Value::as_object) {
408        model.limit = Some(ModelLimit {
409            context: limit.get("context").and_then(Value::as_u64),
410            output: limit.get("output").and_then(Value::as_u64),
411        });
412    }
413
414    if let Some(options) = obj.get("options").and_then(Value::as_object) {
415        model.options = Some(options.clone().into_iter().collect());
416    }
417
418    for (key, val) in obj {
419        if !matches!(
420            key.as_str(),
421            "name" | "id" | "limit" | "options" | "variants" | "disabled"
422        ) {
423            model.extra.insert(key.clone(), val.clone());
424        }
425    }
426
427    if let Some(parsed_variants) = obj.get("variants") {
428        model.variants = serde_json::from_value(parsed_variants.clone())?;
429    }
430    model.disabled = obj.get("disabled").and_then(Value::as_bool);
431
432    Ok(model)
433}
434
435fn parse_loose_value(content: &str) -> Result<Value> {
436    let trimmed = content.trim_start();
437    if (trimmed.starts_with('{') || trimmed.starts_with('['))
438        && let Ok(handler) = crate::config_core::jsonc::JsoncHandler::parse(content)
439    {
440        return serde_json::from_str(&handler.to_json_string()?).map_err(AppError::from);
441    }
442
443    if let Ok(value) = toml::from_str::<toml::Value>(content) {
444        return serde_json::to_value(value).map_err(AppError::from);
445    }
446
447    if let Ok(value) = serde_yaml::from_str::<Value>(content) {
448        return Ok(value);
449    }
450
451    parse_toml_value(content)
452}
453
454fn parse_toml_value(content: &str) -> Result<Value> {
455    let value = toml::from_str::<toml::Value>(content)
456        .map_err(|e| AppError::Import(format!("Could not parse as JSON, YAML, or TOML: {e}")))?;
457    serde_json::to_value(value).map_err(AppError::from)
458}
459
460fn looks_like_provider_map(value: &Value) -> bool {
461    value
462        .as_object()
463        .is_some_and(|obj| !obj.is_empty() && obj.values().all(looks_like_provider))
464}
465
466fn looks_like_provider(value: &Value) -> bool {
467    value.as_object().is_some_and(|obj| {
468        obj.contains_key("npm")
469            || obj.contains_key("options")
470            || obj.contains_key("models")
471            || obj.contains_key("api")
472            || obj.contains_key("env")
473    })
474}
475
476fn looks_like_model(value: &Value) -> bool {
477    value.as_object().is_some_and(|obj| {
478        obj.contains_key("limit")
479            || obj.contains_key("modalities")
480            || obj.contains_key("cost")
481            || obj.contains_key("family")
482    })
483}
484
485fn is_models_dev_provider_value(value: &Value) -> bool {
486    value
487        .as_object()
488        .is_some_and(|obj| obj.contains_key("api") || obj.contains_key("env"))
489}
490
491fn attach_import_metadata(config: &mut OpenCodeConfig, source_label: Option<&str>) {
492    let Some(source) = source_label else {
493        return;
494    };
495
496    config.extra.insert(
497        IMPORT_META_KEY.to_string(),
498        serde_json::json!({
499            "source": source,
500            "note": "Imported by opencode-provider-manager. Keep this metadata as provenance for future review."
501        }),
502    );
503}
504
505fn collect_importable_files(dir: &Path) -> Result<Vec<PathBuf>> {
506    let mut files = Vec::new();
507    for entry in std::fs::read_dir(dir)? {
508        let entry = entry?;
509        let path = entry.path();
510        if path.is_dir() {
511            files.extend(collect_importable_files(&path)?);
512        } else if path
513            .extension()
514            .and_then(|ext| ext.to_str())
515            .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
516        {
517            files.push(path);
518        }
519    }
520    Ok(files)
521}
522
523fn is_url(source: &str) -> bool {
524    source.starts_with("https://") || source.starts_with("http://")
525}
526
527fn is_importable_name(name: &str) -> bool {
528    Path::new(name)
529        .extension()
530        .and_then(|ext| ext.to_str())
531        .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
532}
533
534fn http_get_text(url: &str) -> Result<String> {
535    reqwest::blocking::Client::new()
536        .get(url)
537        .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
538        .send()?
539        .error_for_status()?
540        .text()
541        .map_err(AppError::from)
542}
543
544fn parse_github_url(url: &str) -> Option<(String, String, String, String, bool)> {
545    let rest = url.strip_prefix("https://github.com/")?;
546    let mut parts = rest.split('/');
547    let owner = parts.next()?.to_string();
548    let repo = parts.next()?.to_string();
549    let kind = parts.next()?;
550    let branch = parts.next()?.to_string();
551    let path = parts.collect::<Vec<_>>().join("/");
552    if path.is_empty() {
553        return None;
554    }
555    Some((owner, repo, branch, path, kind == "tree"))
556}
557
558fn url_path_stem(url: &str) -> Option<String> {
559    url.rsplit('/')
560        .next()
561        .and_then(|name| Path::new(name).file_stem())
562        .and_then(|stem| stem.to_str())
563        .map(str::to_string)
564}
565
566fn github_contents(
567    owner: &str,
568    repo: &str,
569    branch: &str,
570    path: &str,
571) -> Result<Vec<GithubContentEntry>> {
572    let api_url =
573        format!("https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}");
574    let text = http_get_text(&api_url)?;
575    serde_json::from_str::<Vec<GithubContentEntry>>(&text).map_err(AppError::from)
576}
577
578#[derive(Debug, Deserialize)]
579struct GithubContentEntry {
580    name: String,
581    #[serde(rename = "type")]
582    entry_type: String,
583    download_url: Option<String>,
584}
585
586/// Summary of an import payload.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct ImportSummary {
589    pub provider_count: usize,
590    pub model_count: usize,
591    pub provider_ids: Vec<String>,
592}
593
594impl ImportSummary {
595    pub fn from_config(config: &OpenCodeConfig) -> Self {
596        let mut provider_ids = Vec::new();
597        let mut model_count = 0;
598
599        if let Some(providers) = &config.provider {
600            for (provider_id, provider) in providers {
601                provider_ids.push(provider_id.clone());
602                model_count += provider.models.as_ref().map(HashMap::len).unwrap_or(0);
603            }
604        }
605        provider_ids.sort();
606
607        Self {
608            provider_count: provider_ids.len(),
609            model_count,
610            provider_ids,
611        }
612    }
613}
614
615/// How to handle conflicts during import.
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum ImportMergeMode {
618    /// Replace the entire config at the target layer.
619    Replace,
620    /// Deep merge the imported config into the existing config.
621    Merge,
622}
623
624/// What scope to export.
625#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub enum ExportScope {
627    /// Export the merged result.
628    Merged,
629    /// Export only the global config.
630    Global,
631    /// Export only the project config.
632    Project,
633    /// Export only the custom config (from --config / OPENCODE_CONFIG).
634    Custom,
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use tempfile::{NamedTempFile, tempdir};
641
642    #[test]
643    fn test_export_merged_config() {
644        let state = AppState::new().unwrap();
645        let temp_file = NamedTempFile::new().unwrap();
646        export_config(&state, temp_file.path(), ExportScope::Merged).unwrap();
647
648        let content = std::fs::read_to_string(temp_file.path()).unwrap();
649        assert!(content.contains("{"));
650    }
651
652    #[test]
653    fn test_parse_full_json_config_preserves_modalities() {
654        let config = parse_import_snippet(
655            r#"{
656              "provider": {
657                "volcengine-plan": {
658                  "npm": "@ai-sdk/openai-compatible",
659                  "models": {
660                    "glm-5.1": {
661                      "name": "glm-5.1",
662                      "limit": { "context": 200000, "output": 4096 },
663                      "modalities": { "input": ["text"], "output": ["text"] }
664                    }
665                  }
666                }
667              }
668            }"#,
669            None,
670            Some("test"),
671        )
672        .unwrap();
673
674        let model = config.provider.unwrap()["volcengine-plan"]
675            .models
676            .as_ref()
677            .unwrap()["glm-5.1"]
678            .clone();
679        assert_eq!(model.limit.unwrap().context, Some(200000));
680        assert!(model.extra.contains_key("modalities"));
681    }
682
683    #[test]
684    fn test_parse_provider_fragment_with_hint() {
685        let config = parse_import_snippet(
686            r#"{
687              "npm": "@ai-sdk/openai-compatible",
688              "name": "Volcano Engine",
689              "options": { "baseURL": "https://example.com/v1" }
690            }"#,
691            Some("volcengine-plan"),
692            Some("fragment"),
693        )
694        .unwrap();
695
696        assert!(config.provider.unwrap().contains_key("volcengine-plan"));
697    }
698
699    #[test]
700    fn test_parse_models_dev_directory() {
701        let dir = tempdir().unwrap();
702        std::fs::write(
703            dir.path().join("provider.toml"),
704            r#"
705name = "Xiaomi Token Plan (China)"
706env = ["XIAOMI_API_KEY"]
707npm = "@ai-sdk/openai-compatible"
708api = "https://token-plan-cn.xiaomimimo.com/v1"
709doc = "https://platform.xiaomimimo.com/#/docs"
710"#,
711        )
712        .unwrap();
713        std::fs::create_dir(dir.path().join("models")).unwrap();
714        std::fs::write(
715            dir.path().join("models").join("mimo-v2-pro.toml"),
716            r#"
717name = "MiMo-V2-Pro"
718family = "mimo"
719
720[limit]
721context = 1_000_000
722output = 128_000
723
724[modalities]
725input = ["text"]
726output = ["text"]
727"#,
728        )
729        .unwrap();
730
731        let config = parse_import_path(dir.path(), Some("xiaomi-token-plan-cn")).unwrap();
732        let provider = &config.provider.unwrap()["xiaomi-token-plan-cn"];
733        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
734        assert_eq!(
735            provider
736                .options
737                .as_ref()
738                .unwrap()
739                .get("apiKey")
740                .and_then(Value::as_str),
741            Some("{env:XIAOMI_API_KEY}")
742        );
743        assert!(
744            provider
745                .models
746                .as_ref()
747                .unwrap()
748                .contains_key("mimo-v2-pro")
749        );
750    }
751}