From 4b7650d16558b59b704c797e25e82e1ef85b39e2 Mon Sep 17 00:00:00 2001 From: visitorise <91079853+visitorise@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:20:51 +0900 Subject: [PATCH] feat(config): add custom provider configuration support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CustomModelConfig and CustomProviderConfig structs for user-defined providers - Implement parse_custom_providers() to extract provider config from crabcode.json - Add custom_providers field to MergedConfig and Discovery struct - Implement overlay logic in fetch_providers() with priority: models.dev → extensions → custom - Update App::new_with_model_override() to pass custom providers via new_with_custom() - Fix load_config_value() to use json5 for all .json files (lenient parsing) - Suppress MCP child process stderr output using TokioChildProcess::builder().stderr(Stdio::null()) Custom provider format is compatible with OpenCode's configuration. --- src/app.rs | 3 +- src/config/configuration.rs | 127 ++++++++++++++++++++++++++++++++---- src/config/mod.rs | 2 +- src/mcp.rs | 9 ++- src/model/discovery.rs | 75 ++++++++++++++++++++- 5 files changed, 196 insertions(+), 20 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0689c8b..2844006 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1063,7 +1063,6 @@ impl App { ); let agent_steps = agent_registry.max_steps_map(); let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone(); - let theme_for_colors = themes .get(current_theme_index) .or_else(|| themes.first()) @@ -1082,7 +1081,7 @@ impl App { .with_permission_rules(loaded_config.merged_config.permission_rules.clone()) .with_agent_permission_rules(agent_registry.permission_rules_map()); - let discovery = crate::model::discovery::Discovery::new().ok(); + let discovery = crate::model::discovery::Discovery::new_with_custom(Some(loaded_config.merged_config.custom_providers.clone())).ok(); let now = std::time::Instant::now(); Ok(Self { diff --git a/src/config/configuration.rs b/src/config/configuration.rs index d7aaadf..00cc36f 100644 --- a/src/config/configuration.rs +++ b/src/config/configuration.rs @@ -8,6 +8,9 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fs; use std::path::{Path, PathBuf}; +// Re-export json5 for use in load_config_value +use json5; + pub fn discover_themes( xdg_config_home: &Path, project_root: &Path, @@ -377,12 +380,29 @@ pub struct MergedConfig { pub agent_permission_rules: HashMap, pub agent_steps: HashMap, pub provider_timeouts: HashMap, + pub custom_providers: HashMap, pub notifications: NotificationsConfig, pub images: ImagesConfig, pub websearch: WebsearchConfig, pub mcp: McpConfig, } +#[derive(Debug, Clone)] +pub struct CustomModelConfig { + pub name: String, + pub context_window: Option, + pub max_tokens: Option, + pub launch: bool, +} + +#[derive(Debug, Clone)] +pub struct CustomProviderConfig { + pub name: String, + pub npm: String, + pub base_url: String, + pub models: HashMap, +} + #[derive(Debug, Clone)] pub struct LoadedConfig { pub merged_config: MergedConfig, @@ -824,20 +844,11 @@ fn load_config_value(path: &Path) -> Result { let content = fs::read_to_string(path) .with_context(|| format!("Failed to read config file {}", path.display()))?; - match path.extension().and_then(|s| s.to_str()) { - Some(ext) if ext.eq_ignore_ascii_case("jsonc") => { - let v: Value = json5::from_str(&content) - .with_context(|| format!("Invalid JSONC in {}", path.display()))?; - Ok(v) - } - _ => { - let v: Value = serde_json::from_str(&content) - .with_context(|| format!("Invalid JSON in {}", path.display()))?; - Ok(v) - } - } + // Use json5 for lenient parsing (handles trailing commas, comments, etc.) + let v: Value = json5::from_str(&content) + .with_context(|| format!("Invalid JSON/JSONC in {}", path.display()))?; + Ok(v) } - fn filter_top_level(value: Value, kind: SourceKind) -> Value { let mut map = match value { Value::Object(m) => m, @@ -1155,6 +1166,7 @@ fn parse_merged_config(merged: &Value, diagnostics: &mut ConfigDiagnostics) -> M ); out.sync_agent_derived_fields(); out.provider_timeouts = parse_provider_timeouts(obj.get("provider"), diagnostics); + out.custom_providers = parse_custom_providers(obj.get("provider"), diagnostics); let mut notifications = NotificationsConfig::default(); apply_notifications(obj.get("notifications"), &mut notifications, diagnostics); @@ -1785,6 +1797,95 @@ fn parse_provider_timeouts( out } +fn parse_custom_providers( + value: Option<&Value>, + diagnostics: &mut ConfigDiagnostics, +) -> HashMap { + let mut out = HashMap::new(); + let Some(Value::Object(providers)) = value else { + return out; + }; + + for (provider_id, provider_val) in providers { + let Some(provider_obj) = provider_val.as_object() else { + continue; + }; + + let name = provider_obj + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| provider_id.clone()); + + let npm = provider_obj + .get("npm") + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let base_url = provider_obj + .get("options") + .and_then(Value::as_object) + .and_then(|opts| opts.get("baseURL")) + .and_then(Value::as_str) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let mut models = HashMap::new(); + if let Some(models_val) = provider_obj.get("models") { + if let Value::Object(model_map) = models_val { + for (model_id, model_val) in model_map { + let Some(model_obj) = model_val.as_object() else { + continue; + }; + + let model_name = model_obj + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| model_id.clone()); + + let context_window = model_obj + .get("contextWindow") + .and_then(Value::as_u64) + .map(|v| v as u32); + + let max_tokens = model_obj + .get("maxTokens") + .and_then(Value::as_u64) + .map(|v| v as u32); + + let launch = model_obj + .get("_launch") + .and_then(Value::as_bool) + .unwrap_or(false); + + models.insert( + model_id.clone(), + CustomModelConfig { + name: model_name, + context_window, + max_tokens, + launch, + }, + ); + } + } + } + + out.insert( + provider_id.trim().to_ascii_lowercase(), + CustomProviderConfig { + name, + npm, + base_url, + models, + }, + ); + } + + out +} fn parse_images(value: Option<&Value>, diagnostics: &mut ConfigDiagnostics) -> ImagesConfig { let mut images = ImagesConfig::default(); diff --git a/src/config/mod.rs b/src/config/mod.rs index acef81e..2a8e85b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,7 +1,7 @@ pub mod configuration; pub use configuration::{ - ConfigLoader, ImageOpenCommandConfig, ImageOpenWith, ImagesConfig, McpConfig, McpServerConfig, + ConfigLoader, CustomModelConfig, CustomProviderConfig, ImageOpenCommandConfig, ImageOpenWith, ImagesConfig, McpConfig, McpServerConfig, NotificationEventConfig, NotificationsConfig, ProviderTimeout, TerminalNotificationCondition, TerminalNotificationMode, }; diff --git a/src/mcp.rs b/src/mcp.rs index 0e5f7e6..458b8e9 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -13,6 +13,7 @@ use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use std::process::Stdio; use tokio::sync::Mutex; const DEFAULT_TIMEOUT_MS: u64 = 5_000; @@ -189,13 +190,15 @@ impl McpManager { .map(|cwd| resolve_path(&self.workspace, cwd)) .unwrap_or_else(|| self.workspace.clone()); let env = local.environment.clone(); - let transport = TokioChildProcess::new( + let (transport, _) = TokioChildProcess::builder( tokio::process::Command::new(command).configure(move |cmd| { cmd.args(args); cmd.current_dir(cwd); cmd.envs(env); - }), - )?; + }) + ) + .stderr(Stdio::null()) + .spawn()?; Ok(().serve(transport).await?) } McpServerConfig::Remote(remote) => { diff --git a/src/model/discovery.rs b/src/model/discovery.rs index 6dc7112..65b35e0 100644 --- a/src/model/discovery.rs +++ b/src/model/discovery.rs @@ -150,10 +150,20 @@ struct CacheEntry { pub struct Discovery { client: Client, cache_path: PathBuf, + custom_providers: Option>, } - impl Discovery { pub fn new() -> Result { + // Try to load custom providers from config file + let custom_providers = crate::config::ConfigLoader::load() + .map(|loaded| loaded.merged_config.custom_providers) + .ok(); + Self::new_with_custom(custom_providers) + } + + pub fn new_with_custom( + custom_providers: Option>, + ) -> Result { if cfg!(test) || env::var("CRABCODE_TEST_MODE").is_ok() { let cache_dir = PathBuf::from("/tmp/crabcode_test_cache"); fs::create_dir_all(&cache_dir).context("Failed to create test cache directory")?; @@ -163,6 +173,7 @@ impl Discovery { Ok(Self { client: shared_http_client()?, cache_path, + custom_providers, }) } else { crate::persistence::ensure_cache_dir().context("Failed to create cache directory")?; @@ -173,6 +184,7 @@ impl Discovery { Ok(Self { client: shared_http_client()?, cache_path, + custom_providers, }) } } @@ -360,6 +372,67 @@ impl Discovery { }; crate::model::extensions::ModelExtensions::augment_runtime_catalog(&mut providers); + // Apply custom providers from config as final overlay + if let Some(custom) = &self.custom_providers { + for (id, custom_provider) in custom { + let provider = providers.entry(id.clone()).or_insert_with(|| -> Provider { + Provider { + id: id.clone(), + name: custom_provider.name.clone(), + api: String::new(), + doc: String::new(), + env: Vec::new(), + npm: String::new(), + models: HashMap::new(), + } + }); + + // Override with custom values if present + if !custom_provider.name.is_empty() { + provider.name = custom_provider.name.clone(); + } + if !custom_provider.base_url.is_empty() { + provider.api = custom_provider.base_url.clone(); + } + if !custom_provider.npm.is_empty() { + provider.npm = custom_provider.npm.clone(); + } + + // Add/override models + for (model_id, model_cfg) in &custom_provider.models { + let model = crate::model::discovery::Model { + id: String::from(model_id), + name: String::from(&model_cfg.name), + family: String::new(), + attachment: false, + reasoning: false, + reasoning_options: Vec::new(), + tool_call: false, + structured_output: false, + temperature: false, + knowledge: String::new(), + release_date: String::new(), + last_updated: String::new(), + status: None, + modalities: Some(crate::model::discovery::Modalities { + input: vec!["text".to_string()], + output: vec!["text".to_string()], + }), + open_weights: false, + cost: None, + limit: model_cfg.context_window.map(|cw| crate::model::discovery::Limit { + context: cw, + output: model_cfg.max_tokens.unwrap_or(cw), + }), + provider: Some(crate::model::discovery::ModelProvider { + npm: if custom_provider.npm.is_empty() { None } else { Some(custom_provider.npm.clone()) }, + api: if custom_provider.base_url.is_empty() { None } else { Some(custom_provider.base_url.clone()) }, + }), + }; + provider.models.insert(String::from(model_id), model); + } + } + } Ok(providers) }