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();
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((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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum ImportMergeMode {
618 Replace,
620 Merge,
622}
623
624#[derive(Debug, Clone, Copy, PartialEq, Eq)]
626pub enum ExportScope {
627 Merged,
629 Global,
631 Project,
633 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}