Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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 {
Expand Down
127 changes: 114 additions & 13 deletions src/config/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -377,12 +380,29 @@ pub struct MergedConfig {
pub agent_permission_rules: HashMap<String, PermissionRules>,
pub agent_steps: HashMap<String, usize>,
pub provider_timeouts: HashMap<String, ProviderTimeout>,
pub custom_providers: HashMap<String, CustomProviderConfig>,
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<u32>,
pub max_tokens: Option<u32>,
pub launch: bool,
}

#[derive(Debug, Clone)]
pub struct CustomProviderConfig {
pub name: String,
pub npm: String,
pub base_url: String,
pub models: HashMap<String, CustomModelConfig>,
}

#[derive(Debug, Clone)]
pub struct LoadedConfig {
pub merged_config: MergedConfig,
Expand Down Expand Up @@ -824,20 +844,11 @@ fn load_config_value(path: &Path) -> Result<Value> {
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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1785,6 +1797,95 @@ fn parse_provider_timeouts(

out
}
fn parse_custom_providers(
value: Option<&Value>,
diagnostics: &mut ConfigDiagnostics,
) -> HashMap<String, CustomProviderConfig> {
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();
Expand Down
2 changes: 1 addition & 1 deletion src/config/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down
9 changes: 6 additions & 3 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
75 changes: 74 additions & 1 deletion src/model/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,10 +150,20 @@ struct CacheEntry {
pub struct Discovery {
client: Client,
cache_path: PathBuf,
custom_providers: Option<std::collections::HashMap<String, crate::config::CustomProviderConfig>>,
}

impl Discovery {
pub fn new() -> Result<Self> {
// 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<std::collections::HashMap<String, crate::config::CustomProviderConfig>>,
) -> Result<Self> {
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")?;
Expand All @@ -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")?;
Expand All @@ -173,6 +184,7 @@ impl Discovery {
Ok(Self {
client: shared_http_client()?,
cache_path,
custom_providers,
})
}
}
Expand Down Expand Up @@ -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)
}
Expand Down