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