1use 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
14pub 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
25pub 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
40pub 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
54pub 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
68pub 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
78pub 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(layer);
114 Ok(())
115}
116
117pub 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(candidates) = parse_github_tree_candidates(url) {
208 let mut last_err = None;
211 for (owner, repo, branch, path) in candidates {
212 match github_contents(&owner, &repo, &branch, &path) {
213 Ok(entries) => {
214 return parse_github_directory_with_entries(
216 &owner, &repo, &branch, &path,
217 entries, provider_id_hint, url,
218 );
219 }
220 Err(e) => {
221 last_err = Some(e);
222 continue;
223 }
224 }
225 }
226 return Err(last_err.unwrap_or_else(|| {
228 AppError::Import("Could not resolve GitHub tree URL".to_string())
229 }));
230 }
231
232 if let Some((_owner, _repo, _branch, _path, is_tree)) = parse_github_url(url) {
234 if !is_tree {
235 let text = http_get_text(url)?;
237 let stem_hint = url_path_stem(url);
238 let hint = provider_id_hint.or(stem_hint.as_deref());
239 return parse_import_snippet(&text, hint, Some(url));
240 }
241 }
242
243 let text = http_get_text(url)?;
244 let stem_hint = url_path_stem(url);
245 let hint = provider_id_hint.or(stem_hint.as_deref());
246 parse_import_snippet(&text, hint, Some(url))
247}
248
249fn parse_github_tree_candidates(url: &str) -> Option<Vec<(String, String, String, String)>> {
252 let rest = url.strip_prefix("https://github.com/")?;
253 let parts: Vec<&str> = rest.split('/').collect();
254 if parts.len() < 5 {
255 return None;
256 }
257 let owner = parts[0].to_string();
258 let repo = parts[1].to_string();
259 let kind = parts[2];
260 if kind != "tree" {
261 return None;
262 }
263
264 let remaining = &parts[3..];
266 let mut candidates = Vec::new();
267 for depth in 1..remaining.len() {
268 let branch = remaining[..depth].join("/");
269 let path = remaining[depth..].join("/");
270 if !path.is_empty() {
271 candidates.push((owner.clone(), repo.clone(), branch, path));
272 }
273 }
274 candidates.sort_by_key(|c| c.2.len());
276 Some(candidates)
277}
278
279fn parse_github_directory_with_entries(
280 owner: &str,
281 repo: &str,
282 branch: &str,
283 path: &str,
284 entries: Vec<GithubContentEntry>,
285 provider_id_hint: Option<&str>,
286 source_url: &str,
287) -> Result<OpenCodeConfig> {
288 let provider_entry = entries
289 .iter()
290 .find(|entry| entry.name == "provider.toml" && entry.download_url.is_some());
291
292 if let Some(provider_entry) = provider_entry {
293 let provider_id = provider_id_hint
294 .map(str::to_string)
295 .or_else(|| path.rsplit('/').next().map(str::to_string))
296 .ok_or_else(|| {
297 AppError::Import("GitHub provider path has no provider ID".to_string())
298 })?;
299
300 let provider_text = http_get_text(provider_entry.download_url.as_ref().unwrap())
301 .map_err(|e| {
302 AppError::Import(format!(
303 "Failed to download provider.toml from GitHub: {e}"
304 ))
305 })?;
306 let mut provider = models_dev_provider_from_value(parse_toml_value(&provider_text)?)?;
307
308 let models_path = format!("{}/models", path.trim_end_matches('/'));
309 if let Ok(model_entries) = github_contents(owner, repo, branch, &models_path) {
311 for model_entry in model_entries {
312 if model_entry.entry_type == "file" && is_importable_name(&model_entry.name) {
313 let Some(download_url) = model_entry.download_url else {
314 continue;
315 };
316 let model_id = Path::new(&model_entry.name)
317 .file_stem()
318 .and_then(|s| s.to_str())
319 .ok_or_else(|| {
320 AppError::Import("Model URL has no usable file name".to_string())
321 })?
322 .to_string();
323 let model = model_from_value(
324 parse_loose_value(
325 &http_get_text(&download_url).map_err(|e| {
326 AppError::Import(format!(
327 "Failed to download model file {model_id}: {e}"
328 ))
329 })?,
330 )?,
331 )?;
332 provider
333 .models
334 .get_or_insert_with(HashMap::new)
335 .insert(model_id, model);
336 }
337 }
338 }
339
340 return config_from_provider(provider_id, provider, Some(source_url));
341 }
342
343 let mut merged = OpenCodeConfig::default();
344 for entry in entries {
345 if entry.entry_type == "file" && is_importable_name(&entry.name) {
346 if let Some(download_url) = entry.download_url {
347 let text = http_get_text(&download_url).map_err(|e| {
348 AppError::Import(format!(
349 "Failed to download {} from GitHub: {e}",
350 entry.name
351 ))
352 })?;
353 let parsed = parse_import_snippet(
354 &text,
355 provider_id_hint,
356 Some(&download_url),
357 )?;
358 merged = crate::config_core::merge_two(merged, parsed);
359 }
360 }
361 }
362 Ok(merged)
363}
364
365fn normalize_import_value(
366 value: Value,
367 provider_id_hint: Option<&str>,
368 source_label: Option<&str>,
369) -> Result<OpenCodeConfig> {
370 let mut config = if value.get("provider").is_some()
371 || value.get("$schema").is_some()
372 || value.get("model").is_some()
373 || value.get("smallModel").is_some()
374 {
375 serde_json::from_value::<OpenCodeConfig>(value)?
376 } else if looks_like_provider_map(&value) {
377 let providers = serde_json::from_value::<HashMap<String, ProviderConfig>>(value)?;
378 OpenCodeConfig {
379 provider: Some(providers),
380 ..Default::default()
381 }
382 } else if looks_like_provider(&value) {
383 let provider_id = provider_id_hint.ok_or_else(|| {
384 AppError::Import(
385 "Provider fragment needs a provider ID hint; pass --provider-id or import from a named file/directory".to_string(),
386 )
387 })?;
388 let provider = provider_from_value(value)?;
389 config_from_provider(provider_id.to_string(), provider, source_label)?
390 } else if looks_like_model(&value) {
391 let model_id = provider_id_hint.ok_or_else(|| {
392 AppError::Import(
393 "Model fragment needs an ID hint from --provider-id/path; wrap it in a provider.models object for direct import".to_string(),
394 )
395 })?;
396 let mut provider = ProviderConfig::default();
397 provider
398 .models
399 .get_or_insert_with(HashMap::new)
400 .insert(model_id.to_string(), model_from_value(value)?);
401 config_from_provider(model_id.to_string(), provider, source_label)?
402 } else {
403 return Err(AppError::Import(
404 "Snippet is not a full config, provider map, provider fragment, or model fragment"
405 .to_string(),
406 ));
407 };
408
409 attach_import_metadata(&mut config, source_label);
410 Ok(config)
411}
412
413fn config_from_provider(
414 provider_id: String,
415 provider: ProviderConfig,
416 source_label: Option<&str>,
417) -> Result<OpenCodeConfig> {
418 let mut providers = HashMap::new();
419 providers.insert(provider_id, provider);
420 let mut config = OpenCodeConfig {
421 provider: Some(providers),
422 ..Default::default()
423 };
424 attach_import_metadata(&mut config, source_label);
425 Ok(config)
426}
427
428fn provider_from_value(value: Value) -> Result<ProviderConfig> {
429 if is_models_dev_provider_value(&value) {
430 models_dev_provider_from_value(value)
431 } else {
432 Ok(serde_json::from_value(value)?)
433 }
434}
435
436fn models_dev_provider_from_value(value: Value) -> Result<ProviderConfig> {
437 let obj = value.as_object().ok_or_else(|| {
438 AppError::Import("models.dev provider metadata must be an object".to_string())
439 })?;
440 let mut provider = ProviderConfig {
441 name: obj.get("name").and_then(Value::as_str).map(str::to_string),
442 npm: obj.get("npm").and_then(Value::as_str).map(str::to_string),
443 ..Default::default()
444 };
445
446 let mut options = HashMap::new();
447 if let Some(api) = obj.get("api").and_then(Value::as_str) {
448 options.insert("baseURL".to_string(), Value::String(api.to_string()));
449 }
450 if !options.is_empty() {
451 provider.options = Some(options);
452 }
453
454 for (key, val) in obj {
455 if !matches!(key.as_str(), "name" | "npm" | "api" | "env" | "models") {
456 provider.extra.insert(key.clone(), val.clone());
457 }
458 }
459
460 if let Some(models) = obj.get("models").and_then(Value::as_object) {
461 let mut parsed_models = HashMap::new();
462 for (model_id, model_value) in models {
463 parsed_models.insert(model_id.clone(), model_from_value(model_value.clone())?);
464 }
465 provider.models = Some(parsed_models);
466 }
467
468 Ok(provider)
469}
470
471fn model_from_value(value: Value) -> Result<ModelConfig> {
472 let obj = value
473 .as_object()
474 .ok_or_else(|| AppError::Import("Model metadata must be an object".to_string()))?;
475 let mut model = ModelConfig {
476 name: obj.get("name").and_then(Value::as_str).map(str::to_string),
477 id: obj.get("id").and_then(Value::as_str).map(str::to_string),
478 ..Default::default()
479 };
480
481 if let Some(limit) = obj.get("limit").and_then(Value::as_object) {
482 model.limit = Some(ModelLimit {
483 context: limit.get("context").and_then(Value::as_u64),
484 output: limit.get("output").and_then(Value::as_u64),
485 });
486 }
487
488 if let Some(options) = obj.get("options").and_then(Value::as_object) {
489 model.options = Some(options.clone().into_iter().collect());
490 }
491
492 for (key, val) in obj {
493 if !matches!(
494 key.as_str(),
495 "name" | "id" | "limit" | "options" | "variants" | "disabled"
496 ) {
497 model.extra.insert(key.clone(), val.clone());
498 }
499 }
500
501 if let Some(parsed_variants) = obj.get("variants") {
502 model.variants = serde_json::from_value(parsed_variants.clone())?;
503 }
504 model.disabled = obj.get("disabled").and_then(Value::as_bool);
505
506 Ok(model)
507}
508
509fn parse_loose_value(content: &str) -> Result<Value> {
510 let trimmed = content.trim_start();
511 if (trimmed.starts_with('{') || trimmed.starts_with('['))
512 && let Ok(handler) = crate::config_core::jsonc::JsoncHandler::parse(content)
513 {
514 return serde_json::from_str(&handler.to_json_string()?).map_err(AppError::from);
515 }
516
517 if let Ok(value) = toml::from_str::<toml::Value>(content) {
518 return serde_json::to_value(value).map_err(AppError::from);
519 }
520
521 if let Ok(value) = serde_yaml::from_str::<Value>(content) {
522 return Ok(value);
523 }
524
525 parse_toml_value(content)
526}
527
528fn parse_toml_value(content: &str) -> Result<Value> {
529 let value = toml::from_str::<toml::Value>(content)
530 .map_err(|e| AppError::Import(format!("Could not parse as JSON, YAML, or TOML: {e}")))?;
531 serde_json::to_value(value).map_err(AppError::from)
532}
533
534fn looks_like_provider_map(value: &Value) -> bool {
535 value
536 .as_object()
537 .is_some_and(|obj| !obj.is_empty() && obj.values().all(looks_like_provider))
538}
539
540fn looks_like_provider(value: &Value) -> bool {
541 value.as_object().is_some_and(|obj| {
542 obj.contains_key("npm")
543 || obj.contains_key("options")
544 || obj.contains_key("models")
545 || obj.contains_key("api")
546 || obj.contains_key("env")
547 })
548}
549
550fn looks_like_model(value: &Value) -> bool {
551 value.as_object().is_some_and(|obj| {
552 obj.contains_key("limit")
553 || obj.contains_key("modalities")
554 || obj.contains_key("cost")
555 || obj.contains_key("family")
556 })
557}
558
559fn is_models_dev_provider_value(value: &Value) -> bool {
560 value
561 .as_object()
562 .is_some_and(|obj| obj.contains_key("api") || obj.contains_key("env"))
563}
564
565fn attach_import_metadata(config: &mut OpenCodeConfig, source_label: Option<&str>) {
566 let Some(source) = source_label else {
567 return;
568 };
569
570 config.extra.insert(
571 IMPORT_META_KEY.to_string(),
572 serde_json::json!({
573 "source": source,
574 "note": "Imported by opencode-provider-manager. Keep this metadata as provenance for future review."
575 }),
576 );
577}
578
579fn collect_importable_files(dir: &Path) -> Result<Vec<PathBuf>> {
580 let mut files = Vec::new();
581 for entry in std::fs::read_dir(dir)? {
582 let entry = entry?;
583 let path = entry.path();
584 if path.is_dir() {
585 files.extend(collect_importable_files(&path)?);
586 } else if path
587 .extension()
588 .and_then(|ext| ext.to_str())
589 .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
590 {
591 files.push(path);
592 }
593 }
594 Ok(files)
595}
596
597fn is_url(source: &str) -> bool {
598 source.starts_with("https://") || source.starts_with("http://")
599}
600
601fn is_importable_name(name: &str) -> bool {
602 Path::new(name)
603 .extension()
604 .and_then(|ext| ext.to_str())
605 .is_some_and(|ext| IMPORTABLE_EXTENSIONS.contains(&ext))
606}
607
608fn http_get_text(url: &str) -> Result<String> {
609 reqwest::blocking::Client::new()
610 .get(url)
611 .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
612 .send()?
613 .error_for_status()?
614 .text()
615 .map_err(AppError::from)
616}
617
618fn parse_github_url(url: &str) -> Option<(String, String, String, String, bool)> {
619 let rest = url.strip_prefix("https://github.com/")?;
620 let mut parts = rest.split('/');
621 let owner = parts.next()?.to_string();
622 let repo = parts.next()?.to_string();
623 let kind = parts.next()?;
624 let branch = parts.next()?.to_string();
625 let path = parts.collect::<Vec<_>>().join("/");
626 if path.is_empty() {
627 return None;
628 }
629 Some((owner, repo, branch, path, kind == "tree"))
630}
631
632fn url_path_stem(url: &str) -> Option<String> {
633 url.rsplit('/')
634 .next()
635 .and_then(|name| Path::new(name).file_stem())
636 .and_then(|stem| stem.to_str())
637 .map(str::to_string)
638}
639
640fn github_contents(
641 owner: &str,
642 repo: &str,
643 branch: &str,
644 path: &str,
645) -> Result<Vec<GithubContentEntry>> {
646 let api_url =
647 format!("https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}");
648 let response = reqwest::blocking::Client::new()
649 .get(&api_url)
650 .header(reqwest::header::USER_AGENT, "opencode-provider-manager")
651 .send()
652 .map_err(|e| {
653 AppError::Import(format!(
654 "Failed to reach GitHub API ({}): {e}",
655 github_short_path(owner, repo, branch, path)
656 ))
657 })?;
658
659 let status = response.status();
660 if status == reqwest::StatusCode::NOT_FOUND {
661 return Err(AppError::Import(format!(
662 "GitHub path not found: {} (branch: {branch})",
663 github_short_path(owner, repo, branch, path)
664 )));
665 }
666 if status == reqwest::StatusCode::FORBIDDEN {
667 return Err(AppError::Import(format!(
668 "GitHub API rate limit hit. Unauthenticated requests are limited to 60/hour.\n \
669 Set GITHUB_TOKEN env var or wait and retry.\n \
670 Path: {}",
671 github_short_path(owner, repo, branch, path)
672 )));
673 }
674 if !status.is_success() {
675 return Err(AppError::Import(format!(
676 "GitHub API returned {status} for: {}",
677 github_short_path(owner, repo, branch, path)
678 )));
679 }
680
681 let text = response.text().map_err(|e| {
682 AppError::Import(format!(
683 "Failed to read GitHub response: {e}"
684 ))
685 })?;
686 serde_json::from_str::<Vec<GithubContentEntry>>(&text).map_err(|e| {
687 AppError::Import(format!(
688 "Failed to parse GitHub directory listing: {e}\n \
689 The path may point to a file, not a directory. Try a raw file URL instead."
690 ))
691 })
692}
693
694fn github_short_path(owner: &str, repo: &str, branch: &str, path: &str) -> String {
695 format!("{owner}/{repo}/{branch}/{path}")
696}
697
698#[derive(Debug, Deserialize)]
699struct GithubContentEntry {
700 name: String,
701 #[serde(rename = "type")]
702 entry_type: String,
703 download_url: Option<String>,
704}
705
706#[derive(Debug, Clone, PartialEq, Eq)]
708pub struct ImportSummary {
709 pub provider_count: usize,
710 pub model_count: usize,
711 pub provider_ids: Vec<String>,
712}
713
714impl ImportSummary {
715 pub fn from_config(config: &OpenCodeConfig) -> Self {
716 let mut provider_ids = Vec::new();
717 let mut model_count = 0;
718
719 if let Some(providers) = &config.provider {
720 for (provider_id, provider) in providers {
721 provider_ids.push(provider_id.clone());
722 model_count += provider.models.as_ref().map(HashMap::len).unwrap_or(0);
723 }
724 }
725 provider_ids.sort();
726
727 Self {
728 provider_count: provider_ids.len(),
729 model_count,
730 provider_ids,
731 }
732 }
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub enum ImportMergeMode {
738 Replace,
740 Merge,
742}
743
744#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub enum ExportScope {
747 Merged,
749 Global,
751 Project,
753 Custom,
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use tempfile::{NamedTempFile, tempdir};
761
762 #[test]
763 fn test_export_merged_config() {
764 let state = AppState::new().unwrap();
765 let temp_file = NamedTempFile::new().unwrap();
766 export_config(&state, temp_file.path(), ExportScope::Merged).unwrap();
767
768 let content = std::fs::read_to_string(temp_file.path()).unwrap();
769 assert!(content.contains("{"));
770 }
771
772 #[test]
773 fn test_parse_full_json_config_preserves_modalities() {
774 let config = parse_import_snippet(
775 r#"{
776 "provider": {
777 "volcengine-plan": {
778 "npm": "@ai-sdk/openai-compatible",
779 "models": {
780 "glm-5.1": {
781 "name": "glm-5.1",
782 "limit": { "context": 200000, "output": 4096 },
783 "modalities": { "input": ["text"], "output": ["text"] }
784 }
785 }
786 }
787 }
788 }"#,
789 None,
790 Some("test"),
791 )
792 .unwrap();
793
794 let model = config.provider.unwrap()["volcengine-plan"]
795 .models
796 .as_ref()
797 .unwrap()["glm-5.1"]
798 .clone();
799 assert_eq!(model.limit.unwrap().context, Some(200000));
800 assert!(model.extra.contains_key("modalities"));
801 }
802
803 #[test]
804 fn test_parse_provider_fragment_with_hint() {
805 let config = parse_import_snippet(
806 r#"{
807 "npm": "@ai-sdk/openai-compatible",
808 "name": "Volcano Engine",
809 "options": { "baseURL": "https://example.com/v1" }
810 }"#,
811 Some("volcengine-plan"),
812 Some("fragment"),
813 )
814 .unwrap();
815
816 assert!(config.provider.unwrap().contains_key("volcengine-plan"));
817 }
818
819 #[test]
820 fn test_parse_models_dev_directory() {
821 let dir = tempdir().unwrap();
822 std::fs::write(
823 dir.path().join("provider.toml"),
824 r#"
825name = "Xiaomi Token Plan (China)"
826env = ["XIAOMI_API_KEY"]
827npm = "@ai-sdk/openai-compatible"
828api = "https://token-plan-cn.xiaomimimo.com/v1"
829doc = "https://platform.xiaomimimo.com/#/docs"
830"#,
831 )
832 .unwrap();
833 std::fs::create_dir(dir.path().join("models")).unwrap();
834 std::fs::write(
835 dir.path().join("models").join("mimo-v2-pro.toml"),
836 r#"
837name = "MiMo-V2-Pro"
838family = "mimo"
839
840[limit]
841context = 1_000_000
842output = 128_000
843
844[modalities]
845input = ["text"]
846output = ["text"]
847"#,
848 )
849 .unwrap();
850
851 let config = parse_import_path(dir.path(), Some("xiaomi-token-plan-cn")).unwrap();
852 let provider = &config.provider.unwrap()["xiaomi-token-plan-cn"];
853 assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
854 assert_eq!(
855 provider
856 .options
857 .as_ref()
858 .unwrap()
859 .get("baseURL")
860 .and_then(Value::as_str),
861 Some("https://token-plan-cn.xiaomimimo.com/v1")
862 );
863 assert!(!provider.options.as_ref().unwrap().contains_key("apiKey"));
864 assert!(
865 provider
866 .models
867 .as_ref()
868 .unwrap()
869 .contains_key("mimo-v2-pro")
870 );
871 }
872
873 #[test]
874 fn test_parse_github_url_simple_branch() {
875 let result = parse_github_url(
876 "https://github.com/owner/repo/tree/main/providers/my-provider",
877 );
878 let (owner, repo, branch, path, is_tree) = result.unwrap();
879 assert_eq!(owner, "owner");
880 assert_eq!(repo, "repo");
881 assert_eq!(branch, "main");
882 assert_eq!(path, "providers/my-provider");
883 assert!(is_tree);
884 }
885
886 #[test]
887 fn test_parse_github_url_blob_not_tree() {
888 let result = parse_github_url(
889 "https://github.com/owner/repo/blob/main/file.toml",
890 );
891 let (_, _, _, _, is_tree) = result.unwrap();
892 assert!(!is_tree);
893 }
894
895 #[test]
896 fn test_github_tree_candidates_simple_branch() {
897 let candidates = parse_github_tree_candidates(
898 "https://github.com/owner/repo/tree/main/providers/my-provider",
899 ).unwrap();
900
901 assert!(!candidates.is_empty());
903
904 let (owner, repo, branch, path) = &candidates[0];
906 assert_eq!(owner, "owner");
907 assert_eq!(repo, "repo");
908 assert_eq!(branch, "main");
909 assert_eq!(path, "providers/my-provider");
910 }
911
912 #[test]
913 fn test_github_tree_candidates_slash_branch() {
914 let candidates = parse_github_tree_candidates(
917 "https://github.com/shengjian20/models.dev/tree/feat/Volcano_Engine/providers/volcano_engine_cn",
918 ).unwrap();
919
920 assert!(candidates.len() >= 2);
922
923 assert_eq!(candidates[0].2, "feat");
925 assert_eq!(candidates[0].3, "Volcano_Engine/providers/volcano_engine_cn");
926 assert_eq!(candidates[1].2, "feat/Volcano_Engine");
927 assert_eq!(candidates[1].3, "providers/volcano_engine_cn");
928 }
929
930 #[test]
931 fn test_github_tree_candidates_not_tree_url() {
932 let result = parse_github_tree_candidates(
933 "https://github.com/owner/repo/blob/main/file.toml",
934 );
935 assert!(result.is_none());
936 }
937
938 #[test]
939 fn test_github_tree_candidates_too_short() {
940 let result = parse_github_tree_candidates(
941 "https://github.com/owner/repo/tree/main",
942 );
943 assert!(result.is_none());
944 }
945}