diff --git a/Cargo.lock b/Cargo.lock index 0d03d08ccaa3..5c877f1278bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2687,6 +2687,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", "webbrowser 1.0.4", "winapi", ] @@ -2774,6 +2775,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "utoipa", + "uuid", ] [[package]] diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 89b4b0e54650..0986ec3219e0 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -47,6 +47,7 @@ shlex = "1.3.0" async-trait = "0.1.86" base64 = "0.22.1" regex = "1.11.1" +uuid = { version = "1.11", features = ["v4"] } nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" # Web server dependencies diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 328ed8e4e56c..cf809dbacc39 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1,3 +1,4 @@ +use crate::recipes::github_recipe::GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY; use cliclack::spinner; use console::style; use goose::agents::extension::ToolInfo; @@ -7,6 +8,7 @@ use goose::agents::platform_tools::{ }; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; +use goose::config::custom_providers::CustomProviderConfig; use goose::config::extensions::name_to_key; use goose::config::permission::PermissionLevel; use goose::config::{ @@ -14,6 +16,7 @@ use goose::config::{ PermissionManager, }; use goose::conversation::message::Message; +use goose::model::ModelConfig; use goose::providers::{create, providers}; use rmcp::model::{Tool, ToolAnnotations}; use rmcp::object; @@ -21,8 +24,6 @@ use serde_json::Value; use std::collections::HashMap; use std::error::Error; -use crate::recipes::github_recipe::GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY; - // useful for light themes where there is no dicernible colour contrast between // cursor-selected and cursor-unselected items. const MULTISELECT_VISIBILITY_HINT: &str = "<"; @@ -221,6 +222,11 @@ pub async fn handle_configure() -> Result<(), Box> { "Configure Providers", "Change provider or update credentials", ) + .item( + "custom_providers", + "Custom Providers", + "Add custom provider with compatible API", + ) .item("add", "Add Extension", "Connect to a new extension") .item( "toggle", @@ -241,6 +247,7 @@ pub async fn handle_configure() -> Result<(), Box> { "remove" => remove_extension_dialog(), "settings" => configure_settings_dialog().await.and(Ok(())), "providers" => configure_provider_dialog().await.and(Ok(())), + "custom_providers" => configure_custom_provider_dialog(), _ => unreachable!(), } } @@ -250,10 +257,7 @@ pub async fn handle_configure() -> Result<(), Box> { async fn handle_oauth_configuration( provider_name: &str, key_name: &str, -) -> Result<(), Box> { - use goose::model::ModelConfig; - use goose::providers::create; - +) -> Result<(), Box> { let _ = cliclack::log::info(format!( "Configuring {} using OAuth device code flow...", key_name @@ -279,8 +283,7 @@ async fn handle_oauth_configuration( } } -/// Interactive model search that truncates the list to improve UX -fn interactive_model_search(models: &[String]) -> Result> { +fn interactive_model_search(models: &[String]) -> Result> { const MAX_VISIBLE: usize = 30; let mut query = String::new(); @@ -553,7 +556,7 @@ pub async fn configure_provider_dialog() -> Result> { let spin = spinner(); spin.start("Attempting to fetch supported models..."); let models_res = { - let temp_model_config = goose::model::ModelConfig::new(&provider_meta.default_model)?; + let temp_model_config = ModelConfig::new(&provider_meta.default_model)?; let temp_provider = create(provider_name, temp_model_config)?; temp_provider.fetch_supported_models().await }; @@ -585,7 +588,7 @@ pub async fn configure_provider_dialog() -> Result> { .map(|val| val == "1" || val.to_lowercase() == "true") .unwrap_or(false); - let model_config = goose::model::ModelConfig::new(&model)? + let model_config = ModelConfig::new(&model)? .with_max_tokens(Some(50)) .with_toolshim(toolshim_enabled) .with_toolshim_model(std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok()); @@ -1429,7 +1432,7 @@ pub async fn configure_tool_permissions_dialog() -> Result<(), Box> { let model: String = config .get_param("GOOSE_MODEL") .expect("No model configured. Please set model first"); - let model_config = goose::model::ModelConfig::new(&model)?; + let model_config = ModelConfig::new(&model)?; // Create the agent let agent = Agent::new(); @@ -1569,7 +1572,6 @@ fn configure_recipe_dialog() -> Result<(), Box> { recipe_repo_input = recipe_repo_input.default_input(&recipe_repo); } let input_value: String = recipe_repo_input.interact()?; - // if input is blank, it clears the recipe github repo settings in the config file if input_value.clone().trim().is_empty() { config.delete(key_name)?; } else { @@ -1767,3 +1769,128 @@ pub async fn handle_openrouter_auth() -> Result<(), Box> { Ok(()) } + +fn add_provider() -> Result<(), Box> { + let provider_type = cliclack::select("What type of API is this?") + .item( + "openai_compatible", + "OpenAI Compatible", + "Uses OpenAI API format", + ) + .item( + "anthropic_compatible", + "Anthropic Compatible", + "Uses Anthropic API format", + ) + .item( + "ollama_compatible", + "Ollama Compatible", + "Uses Ollama API format", + ) + .interact()?; + + let display_name: String = cliclack::input("What should we call this provider?") + .placeholder("Your Provider Name") + .validate(|input: &String| { + if input.is_empty() { + Err("Please enter a name") + } else { + Ok(()) + } + }) + .interact()?; + + let api_url: String = cliclack::input("Provider API URL:") + .placeholder("https://api.example.com/v1/messages") + .validate(|input: &String| { + if !input.starts_with("http://") && !input.starts_with("https://") { + Err("URL must start with either http:// or https://") + } else { + Ok(()) + } + }) + .interact()?; + + let api_key: String = cliclack::password("API key:").mask('▪').interact()?; + + let models_input: String = cliclack::input("Available models (seperate with commas):") + .placeholder("model-a, model-b, model-c") + .validate(|input: &String| { + if input.trim().is_empty() { + Err("Please enter at least one model name") + } else { + Ok(()) + } + }) + .interact()?; + + let models: Vec = models_input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + let supports_streaming = cliclack::confirm("Does this provider support streaming responses?") + .initial_value(true) + .interact()?; + + CustomProviderConfig::create_and_save( + provider_type, + display_name.clone(), + api_url, + api_key, + models, + Some(supports_streaming), + )?; + + cliclack::outro(format!("Custom provider added: {}", display_name))?; + Ok(()) +} + +fn remove_provider() -> Result<(), Box> { + let custom_providers_dir = goose::config::custom_providers::custom_providers_dir(); + let custom_providers = if custom_providers_dir.exists() { + goose::config::custom_providers::load_custom_providers(&custom_providers_dir)? + } else { + Vec::new() + }; + + if custom_providers.is_empty() { + cliclack::outro("No custom providers added just yet.")?; + return Ok(()); + } + + let provider_items: Vec<_> = custom_providers + .iter() + .map(|p| (p.name.as_str(), p.display_name.as_str(), "Custom provider")) + .collect(); + + let selected_id = cliclack::select("Which custom provider would you like to remove?") + .items(&provider_items) + .interact()?; + + CustomProviderConfig::remove(selected_id)?; + cliclack::outro(format!("Removed custom provider: {}", selected_id))?; + Ok(()) +} + +pub fn configure_custom_provider_dialog() -> Result<(), Box> { + let action = cliclack::select("What would you like to do?") + .item( + "add", + "Add A Custom Provider", + "Add a new OpenAI/Anthropic/Ollama compatible Provider", + ) + .item( + "remove", + "Remove Custom Provider", + "Remove an existing custom provider", + ) + .interact()?; + + match action { + "add" => add_provider(), + "remove" => remove_provider(), + _ => unreachable!(), + } +} diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 555d64f9a308..7abfdd37f2e8 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -40,6 +40,7 @@ serde_yaml = "0.9.34" utoipa = { version = "4.1", features = ["axum_extras", "chrono"] } reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "multipart"], default-features = false } tokio-util = "0.7.15" +uuid = { version = "1.11", features = ["v4"] } [[bin]] name = "goosed" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 2174d13211bc..540f7762fa84 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -367,6 +367,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::config_management::read_all_config, super::routes::config_management::providers, super::routes::config_management::upsert_permissions, + super::routes::config_management::create_custom_provider, + super::routes::config_management::remove_custom_provider, super::routes::agent::get_tools, super::routes::agent::add_sub_recipes, super::routes::agent::extend_prompt, @@ -402,6 +404,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::config_management::ExtensionQuery, super::routes::config_management::ToolPermission, super::routes::config_management::UpsertPermissionsQuery, + super::routes::config_management::CreateCustomProviderRequest, super::routes::reply::PermissionConfirmationRequest, super::routes::context::ContextManageRequest, super::routes::context::ContextManageResponse, diff --git a/crates/goose-server/src/routes/agent.rs b/crates/goose-server/src/routes/agent.rs index eb674e30b68b..3bd0385fea93 100644 --- a/crates/goose-server/src/routes/agent.rs +++ b/crates/goose-server/src/routes/agent.rs @@ -192,7 +192,7 @@ async fn update_agent_provider( let agent = state .get_agent() .await - .map_err(|_| StatusCode::PRECONDITION_FAILED)?; + .map_err(|_e| StatusCode::PRECONDITION_FAILED)?; let config = Config::global(); let model = match payload @@ -210,7 +210,7 @@ async fn update_agent_provider( agent .update_provider(new_provider) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(StatusCode::OK) } diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 3270e9708b97..79a177943d57 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -8,7 +8,6 @@ use axum::{ }; use etcetera::{choose_app_strategy, AppStrategy}; use goose::config::APP_STRATEGY; -use goose::config::{extensions::name_to_key, PermissionManager}; use goose::config::{Config, ConfigError}; use goose::config::{ExtensionConfigManager, ExtensionEntry}; use goose::model::ModelConfig; @@ -78,6 +77,16 @@ pub struct UpsertPermissionsQuery { pub tool_permissions: Vec, } +#[derive(Deserialize, ToSchema)] +pub struct CreateCustomProviderRequest { + pub provider_type: String, + pub display_name: String, + pub api_url: String, + pub api_key: String, + pub models: Vec, + pub supports_streaming: Option, +} + #[utoipa::path( post, path = "/config/upsert", @@ -227,7 +236,7 @@ pub async fn add_extension( let extensions = ExtensionConfigManager::get_all().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let key = name_to_key(&extension_query.name); + let key = goose::config::extensions::name_to_key(&extension_query.name); let is_update = extensions.iter().any(|e| e.config.key() == key); @@ -262,7 +271,7 @@ pub async fn remove_extension( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let key = name_to_key(&name); + let key = goose::config::extensions::name_to_key(&name); match ExtensionConfigManager::remove(&key) { Ok(_) => Ok(Json(format!("Removed extension {}", name))), Err(_) => Err(StatusCode::NOT_FOUND), @@ -304,7 +313,62 @@ pub async fn providers( ) -> Result>, StatusCode> { verify_secret_key(&headers, &state)?; - let providers_metadata = get_providers(); + let mut providers_metadata = get_providers(); + + let custom_providers_dir = goose::config::custom_providers::custom_providers_dir(); + + if custom_providers_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&custom_providers_dir) { + for entry in entries.flatten() { + if let Some(extension) = entry.path().extension() { + if extension == "json" { + if let Ok(content) = std::fs::read_to_string(entry.path()) { + if let Ok(custom_provider) = serde_json::from_str::< + goose::config::custom_providers::CustomProviderConfig, + >(&content) + { + // CustomProviderConfig => ProviderMetadata + let default_model = custom_provider + .models + .first() + .map(|m| m.name.clone()) + .unwrap_or_default(); + + let metadata = goose::providers::base::ProviderMetadata { + name: custom_provider.name.clone(), + display_name: custom_provider.display_name.clone(), + description: custom_provider + .description + .clone() + .unwrap_or_else(|| { + format!("{} (custom)", custom_provider.display_name) + }), + default_model, + known_models: custom_provider.models.clone(), + model_doc_link: "Custom provider".to_string(), + config_keys: vec![ + goose::providers::base::ConfigKey::new( + &custom_provider.api_key_env, + true, + true, + None, + ), + goose::providers::base::ConfigKey::new( + "CUSTOM_PROVIDER_BASE_URL", + true, + false, + Some(&custom_provider.base_url), + ), + ], + }; + providers_metadata.push(metadata); + } + } + } + } + } + } + } let providers_response: Vec = providers_metadata .into_iter() @@ -491,7 +555,7 @@ pub async fn upsert_permissions( ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let mut permission_manager = PermissionManager::default(); + let mut permission_manager = goose::config::PermissionManager::default(); for tool_permission in &query.tool_permissions { permission_manager.update_user_permission( @@ -637,6 +701,66 @@ pub async fn get_current_model( }))) } +#[utoipa::path( + post, + path = "/config/custom-providers", + request_body = CreateCustomProviderRequest, + responses( + (status = 200, description = "Custom provider created successfully", body = String), + (status = 400, description = "Invalid request"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn create_custom_provider( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + let config = goose::config::custom_providers::CustomProviderConfig::create_and_save( + &request.provider_type, + request.display_name, + request.api_url, + request.api_key, + request.models, + request.supports_streaming, + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Err(e) = goose::providers::refresh_custom_providers() { + tracing::warn!("Failed to refresh custom providers after creation: {}", e); + } + + Ok(Json(format!("Custom provider added - ID: {}", config.id()))) +} + +#[utoipa::path( + delete, + path = "/config/custom-providers/{id}", + responses( + (status = 200, description = "Custom provider removed successfully", body = String), + (status = 404, description = "Provider not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn remove_custom_provider( + State(state): State>, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + goose::config::custom_providers::CustomProviderConfig::remove(&id) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Err(e) = goose::providers::refresh_custom_providers() { + tracing::warn!("Failed to refresh custom providers after deletion: {}", e); + } + + Ok(Json(format!("Removed custom provider: {}", id))) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/config", get(read_all_config)) @@ -654,6 +778,11 @@ pub fn routes(state: Arc) -> Router { .route("/config/validate", get(validate_config)) .route("/config/permissions", post(upsert_permissions)) .route("/config/current-model", get(get_current_model)) + .route("/config/custom-providers", post(create_custom_provider)) + .route( + "/config/custom-providers/{id}", + delete(remove_custom_provider), + ) .with_state(state) } diff --git a/crates/goose/src/config/custom_providers.rs b/crates/goose/src/config/custom_providers.rs new file mode 100644 index 000000000000..3110771a185e --- /dev/null +++ b/crates/goose/src/config/custom_providers.rs @@ -0,0 +1,215 @@ +use crate::config::{Config, APP_STRATEGY}; +use crate::model::ModelConfig; +use crate::providers::anthropic::AnthropicProvider; +use crate::providers::base::ModelInfo; +use crate::providers::ollama::OllamaProvider; +use crate::providers::openai::OpenAiProvider; +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +pub fn custom_providers_dir() -> std::path::PathBuf { + choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("custom_providers") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderEngine { + OpenAI, + Ollama, + Anthropic, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomProviderConfig { + pub name: String, + pub engine: ProviderEngine, + pub display_name: String, + pub description: Option, + pub api_key_env: String, + pub base_url: String, + pub models: Vec, + pub headers: Option>, + pub timeout_seconds: Option, + pub supports_streaming: Option, +} + +impl CustomProviderConfig { + pub fn id(&self) -> &str { + &self.name + } + + pub fn display_name(&self) -> &str { + &self.display_name + } + + pub fn models(&self) -> &[ModelInfo] { + &self.models + } + + pub fn generate_id(display_name: &str) -> String { + format!("custom_{}", display_name.to_lowercase().replace(' ', "_")) + } + + pub fn generate_api_key_name(id: &str) -> String { + format!("{}_API_KEY", id.to_uppercase()) + } + + pub fn create_and_save( + provider_type: &str, + display_name: String, + api_url: String, + api_key: String, + models: Vec, + supports_streaming: Option, + ) -> Result { + let id = Self::generate_id(&display_name); + let api_key_name = Self::generate_api_key_name(&id); + + let config = Config::global(); + config.set_secret(&api_key_name, serde_json::Value::String(api_key))?; + + let model_infos: Vec = models + .into_iter() + .map(|name| ModelInfo::new(name, 128000)) + .collect(); + + let provider_config = CustomProviderConfig { + name: id.clone(), + engine: match provider_type { + "openai_compatible" => ProviderEngine::OpenAI, + "anthropic_compatible" => ProviderEngine::Anthropic, + "ollama_compatible" => ProviderEngine::Ollama, + _ => return Err(anyhow::anyhow!("Invalid provider type: {}", provider_type)), + }, + display_name: display_name.clone(), + description: Some(format!("Custom {} provider", display_name)), + api_key_env: api_key_name, + base_url: api_url, + models: model_infos, + headers: None, + timeout_seconds: None, + supports_streaming, + }; + + // save to JSON file + let custom_providers_dir = custom_providers_dir(); + std::fs::create_dir_all(&custom_providers_dir)?; + + let json_content = serde_json::to_string_pretty(&provider_config)?; + let file_path = custom_providers_dir.join(format!("{}.json", id)); + std::fs::write(file_path, json_content)?; + + Ok(provider_config) + } + + pub fn remove(id: &str) -> Result<()> { + let config = Config::global(); + let api_key_name = Self::generate_api_key_name(id); + let _ = config.delete_secret(&api_key_name); + + let custom_providers_dir = custom_providers_dir(); + let file_path = custom_providers_dir.join(format!("{}.json", id)); + + if file_path.exists() { + std::fs::remove_file(file_path)?; + } + + Ok(()) + } +} + +pub fn load_custom_providers(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + + std::fs::read_dir(dir)? + .filter_map(|entry| { + let path = entry.ok()?.path(); + (path.extension()? == "json").then_some(path) + }) + .map(|path| { + let content = std::fs::read_to_string(&path)?; + serde_json::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e)) + }) + .collect() +} + +pub fn register_custom_providers( + registry: &mut crate::providers::provider_registry::ProviderRegistry, + dir: &Path, +) -> Result<()> { + let configs = load_custom_providers(dir)?; + + for config in configs { + let config_clone = config.clone(); + let description = config + .description + .clone() + .unwrap_or_else(|| format!("Custom {} provider", config.display_name)); + let default_model = config + .models + .first() + .map(|m| m.name.clone()) + .unwrap_or_default(); + let known_models: Vec = config + .models + .iter() + .map(|m| ModelInfo { + name: m.name.clone(), + context_limit: m.context_limit, + input_token_cost: m.input_token_cost, + output_token_cost: m.output_token_cost, + currency: m.currency.clone(), + supports_cache_control: Some(m.supports_cache_control.unwrap_or(false)), + }) + .collect(); + + match config.engine { + ProviderEngine::OpenAI => { + registry.register_with_name::( + config.name.clone(), + config.display_name.clone(), + description, + default_model, + known_models, + move |model: ModelConfig| { + OpenAiProvider::from_custom_config(model, config_clone.clone()) + }, + ); + } + ProviderEngine::Ollama => { + registry.register_with_name::( + config.name.clone(), + config.display_name.clone(), + description, + default_model, + known_models, + move |model: ModelConfig| { + OllamaProvider::from_custom_config(model, config_clone.clone()) + }, + ); + } + ProviderEngine::Anthropic => { + registry.register_with_name::( + config.name.clone(), + config.display_name.clone(), + description, + default_model, + known_models, + move |model: ModelConfig| { + AnthropicProvider::from_custom_config(model, config_clone.clone()) + }, + ); + } + } + } + Ok(()) +} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index eaa40072ea5e..dda2a92d6682 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -1,4 +1,5 @@ pub mod base; +pub mod custom_providers; mod experiments; pub mod extensions; pub mod permission; @@ -6,6 +7,7 @@ pub mod signup_openrouter; pub use crate::agents::ExtensionConfig; pub use base::{Config, ConfigError, APP_STRATEGY}; +pub use custom_providers::CustomProviderConfig; pub use experiments::ExperimentManager; pub use extensions::{ExtensionConfigManager, ExtensionEntry}; pub use permission::PermissionManager; diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index aca6a4ef3896..952006ee25de 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -15,6 +15,7 @@ use super::formats::anthropic::{ create_request, get_usage, response_to_message, response_to_streaming_message, }; use super::utils::{emit_debug_trace, get_model, map_http_error_to_provider_error}; +use crate::config::custom_providers::CustomProviderConfig; use crate::conversation::message::Message; use crate::impl_provider_default; use crate::model::ModelConfig; @@ -42,6 +43,7 @@ pub struct AnthropicProvider { #[serde(skip)] api_client: ApiClient, model: ModelConfig, + supports_streaming: bool, } impl_provider_default!(AnthropicProvider); @@ -62,7 +64,32 @@ impl AnthropicProvider { let api_client = ApiClient::new(host, auth)?.with_header("anthropic-version", ANTHROPIC_API_VERSION)?; - Ok(Self { api_client, model }) + Ok(Self { + api_client, + model, + supports_streaming: true, + }) + } + + pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result { + let global_config = crate::config::Config::global(); + let api_key: String = global_config + .get_secret(&config.api_key_env) + .map_err(|_| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; + + let auth = AuthMethod::ApiKey { + header_name: "x-api-key".to_string(), + key: api_key, + }; + + let api_client = ApiClient::new(config.base_url, auth)? + .with_header("anthropic-version", ANTHROPIC_API_VERSION)?; + + Ok(Self { + api_client, + model, + supports_streaming: config.supports_streaming.unwrap_or(true), + }) } fn get_conditional_headers(&self) -> Vec<(&str, &str)> { @@ -260,6 +287,6 @@ impl Provider for AnthropicProvider { } fn supports_streaming(&self) -> bool { - true + self.supports_streaming } } diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 19101acb680c..fbcab89b8329 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use super::{ anthropic::AnthropicProvider, @@ -17,66 +17,87 @@ use super::{ ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider, + provider_registry::ProviderRegistry, sagemaker_tgi::SageMakerTgiProvider, snowflake::SnowflakeProvider, venice::VeniceProvider, xai::XaiProvider, }; +use crate::config::custom_providers::{custom_providers_dir, register_custom_providers}; use crate::model::ModelConfig; use anyhow::Result; +use once_cell::sync::Lazy; #[cfg(test)] use super::errors::ProviderError; #[cfg(test)] use rmcp::model::Tool; -fn default_lead_turns() -> usize { - 3 -} -fn default_failure_threshold() -> usize { - 2 -} -fn default_fallback_turns() -> usize { - 2 +const DEFAULT_LEAD_TURNS: usize = 3; +const DEFAULT_FAILURE_THRESHOLD: usize = 2; +const DEFAULT_FALLBACK_TURNS: usize = 2; + +static REGISTRY: Lazy> = Lazy::new(|| { + let registry = ProviderRegistry::new().with_providers(|registry| { + registry.register::(AnthropicProvider::from_env); + registry.register::(AzureProvider::from_env); + registry.register::(BedrockProvider::from_env); + registry.register::(ClaudeCodeProvider::from_env); + registry.register::(CursorAgentProvider::from_env); + registry.register::(DatabricksProvider::from_env); + registry.register::(GcpVertexAIProvider::from_env); + registry.register::(GeminiCliProvider::from_env); + registry.register::(GoogleProvider::from_env); + registry.register::(GroqProvider::from_env); + registry.register::(LiteLLMProvider::from_env); + registry.register::(OllamaProvider::from_env); + registry.register::(OpenAiProvider::from_env); + registry.register::(OpenRouterProvider::from_env); + registry.register::(SageMakerTgiProvider::from_env); + registry.register::(SnowflakeProvider::from_env); + registry.register::(VeniceProvider::from_env); + registry.register::(XaiProvider::from_env); + + if let Err(e) = load_custom_providers_into_registry(registry) { + tracing::warn!("Failed to load custom providers: {}", e); + } + }); + RwLock::new(registry) +}); + +fn load_custom_providers_into_registry(registry: &mut ProviderRegistry) -> Result<()> { + let config_dir = custom_providers_dir(); + register_custom_providers(registry, &config_dir) } pub fn providers() -> Vec { - vec![ - AnthropicProvider::metadata(), - AzureProvider::metadata(), - BedrockProvider::metadata(), - ClaudeCodeProvider::metadata(), - CursorAgentProvider::metadata(), - DatabricksProvider::metadata(), - GcpVertexAIProvider::metadata(), - GeminiCliProvider::metadata(), - // GithubCopilotProvider::metadata(), - GoogleProvider::metadata(), - GroqProvider::metadata(), - LiteLLMProvider::metadata(), - OllamaProvider::metadata(), - OpenAiProvider::metadata(), - OpenRouterProvider::metadata(), - SageMakerTgiProvider::metadata(), - VeniceProvider::metadata(), - SnowflakeProvider::metadata(), - XaiProvider::metadata(), - ] + REGISTRY.read().unwrap().all_metadata() +} + +pub fn refresh_custom_providers() -> Result<()> { + let mut registry = REGISTRY.write().unwrap(); + registry.remove_custom_providers(); + + if let Err(e) = load_custom_providers_into_registry(&mut registry) { + tracing::warn!("Failed to refresh custom providers: {}", e); + return Err(e); + } + + tracing::info!("Custom providers refreshed"); + Ok(()) } pub fn create(name: &str, model: ModelConfig) -> Result> { let config = crate::config::Config::global(); - // Check for lead model environment variables if let Ok(lead_model_name) = config.get_param::("GOOSE_LEAD_MODEL") { tracing::info!("Creating lead/worker provider from environment variables"); - return create_lead_worker_from_env(name, &model, &lead_model_name); } - create_provider(name, model) + + REGISTRY.read().unwrap().create(name, model) } -/// Create a lead/worker provider from environment variables fn create_lead_worker_from_env( default_provider_name: &str, default_model: &ModelConfig, @@ -84,61 +105,36 @@ fn create_lead_worker_from_env( ) -> Result> { let config = crate::config::Config::global(); - // Get lead provider (optional, defaults to main provider) let lead_provider_name = config .get_param::("GOOSE_LEAD_PROVIDER") .unwrap_or_else(|_| default_provider_name.to_string()); - // Get configuration parameters with defaults let lead_turns = config .get_param::("GOOSE_LEAD_TURNS") - .unwrap_or(default_lead_turns()); + .unwrap_or(DEFAULT_LEAD_TURNS); let failure_threshold = config .get_param::("GOOSE_LEAD_FAILURE_THRESHOLD") - .unwrap_or(default_failure_threshold()); + .unwrap_or(DEFAULT_FAILURE_THRESHOLD); let fallback_turns = config .get_param::("GOOSE_LEAD_FALLBACK_TURNS") - .unwrap_or(default_fallback_turns()); + .unwrap_or(DEFAULT_FALLBACK_TURNS); let lead_model_config = ModelConfig::new_with_context_env( lead_model_name.to_string(), Some("GOOSE_LEAD_CONTEXT_LIMIT"), )?; - // For worker model, preserve the original context_limit from config (highest precedence) - // while still allowing environment variable overrides - let worker_model_config = { - // Start with a clone of the original model to preserve user-specified settings - let mut worker_config = ModelConfig::new_or_fail(default_model.model_name.as_str()) - .with_context_limit(default_model.context_limit) - .with_temperature(default_model.temperature) - .with_max_tokens(default_model.max_tokens) - .with_toolshim(default_model.toolshim) - .with_toolshim_model(default_model.toolshim_model.clone()); - - // Apply environment variable overrides with proper precedence - let global_config = crate::config::Config::global(); - - // Check for worker-specific context limit - if let Ok(limit_str) = global_config.get_param::("GOOSE_WORKER_CONTEXT_LIMIT") { - if let Ok(limit) = limit_str.parse::() { - worker_config = worker_config.with_context_limit(Some(limit)); - } - } else if let Ok(limit_str) = global_config.get_param::("GOOSE_CONTEXT_LIMIT") { - // Check for general context limit if worker-specific is not set - if let Ok(limit) = limit_str.parse::() { - worker_config = worker_config.with_context_limit(Some(limit)); - } - } - - worker_config - }; + let worker_model_config = create_worker_model_config(default_model)?; - // Create the providers - let lead_provider = create_provider(&lead_provider_name, lead_model_config)?; - let worker_provider = create_provider(default_provider_name, worker_model_config)?; + let lead_provider = REGISTRY + .read() + .unwrap() + .create(&lead_provider_name, lead_model_config)?; + let worker_provider = REGISTRY + .read() + .unwrap() + .create(default_provider_name, worker_model_config)?; - // Create the lead/worker provider with configured settings Ok(Arc::new(LeadWorkerProvider::new_with_settings( lead_provider, worker_provider, @@ -148,30 +144,27 @@ fn create_lead_worker_from_env( ))) } -fn create_provider(name: &str, model: ModelConfig) -> Result> { - // We use Arc instead of Box to be able to clone for multiple async tasks - match name { - "anthropic" => Ok(Arc::new(AnthropicProvider::from_env(model)?)), - "aws_bedrock" => Ok(Arc::new(BedrockProvider::from_env(model)?)), - "azure_openai" => Ok(Arc::new(AzureProvider::from_env(model)?)), - "claude-code" => Ok(Arc::new(ClaudeCodeProvider::from_env(model)?)), - "cursor-agent" => Ok(Arc::new(CursorAgentProvider::from_env(model)?)), - "databricks" => Ok(Arc::new(DatabricksProvider::from_env(model)?)), - "gcp_vertex_ai" => Ok(Arc::new(GcpVertexAIProvider::from_env(model)?)), - "gemini-cli" => Ok(Arc::new(GeminiCliProvider::from_env(model)?)), - // "github_copilot" => Ok(Arc::new(GithubCopilotProvider::from_env(model)?)), - "google" => Ok(Arc::new(GoogleProvider::from_env(model)?)), - "groq" => Ok(Arc::new(GroqProvider::from_env(model)?)), - "litellm" => Ok(Arc::new(LiteLLMProvider::from_env(model)?)), - "ollama" => Ok(Arc::new(OllamaProvider::from_env(model)?)), - "openai" => Ok(Arc::new(OpenAiProvider::from_env(model)?)), - "openrouter" => Ok(Arc::new(OpenRouterProvider::from_env(model)?)), - "sagemaker_tgi" => Ok(Arc::new(SageMakerTgiProvider::from_env(model)?)), - "snowflake" => Ok(Arc::new(SnowflakeProvider::from_env(model)?)), - "venice" => Ok(Arc::new(VeniceProvider::from_env(model)?)), - "xai" => Ok(Arc::new(XaiProvider::from_env(model)?)), - _ => Err(anyhow::anyhow!("Unknown provider: {}", name)), +fn create_worker_model_config(default_model: &ModelConfig) -> Result { + let mut worker_config = ModelConfig::new_or_fail(&default_model.model_name) + .with_context_limit(default_model.context_limit) + .with_temperature(default_model.temperature) + .with_max_tokens(default_model.max_tokens) + .with_toolshim(default_model.toolshim) + .with_toolshim_model(default_model.toolshim_model.clone()); + + let global_config = crate::config::Config::global(); + + if let Ok(limit_str) = global_config.get_param::("GOOSE_WORKER_CONTEXT_LIMIT") { + if let Ok(limit) = limit_str.parse::() { + worker_config = worker_config.with_context_limit(Some(limit)); + } + } else if let Ok(limit_str) = global_config.get_param::("GOOSE_CONTEXT_LIMIT") { + if let Ok(limit) = limit_str.parse::() { + worker_config = worker_config.with_context_limit(Some(limit)); + } } + + Ok(worker_config) } #[cfg(test)] @@ -183,7 +176,6 @@ mod tests { use rmcp::model::{AnnotateAble, RawTextContent, Role}; use std::env; - #[allow(dead_code)] #[derive(Clone)] struct MockTestProvider { name: String, @@ -233,222 +225,141 @@ mod tests { } } + struct EnvVarGuard { + vars: Vec<(String, Option)>, + } + + impl EnvVarGuard { + fn new(vars: &[&str]) -> Self { + let saved_vars = vars + .iter() + .map(|&var| (var.to_string(), env::var(var).ok())) + .collect(); + + for &var in vars { + env::remove_var(var); + } + + Self { vars: saved_vars } + } + + fn set(&self, key: &str, value: &str) { + env::set_var(key, value); + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + for (key, value) in &self.vars { + match value { + Some(val) => env::set_var(key, val), + None => env::remove_var(key), + } + } + } + } + #[test] fn test_create_lead_worker_provider() { - // Save current env vars - let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); - let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); - let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); + let _guard = EnvVarGuard::new(&[ + "GOOSE_LEAD_MODEL", + "GOOSE_LEAD_PROVIDER", + "GOOSE_LEAD_TURNS", + ]); - // Test with basic lead model configuration - env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + _guard.set("GOOSE_LEAD_MODEL", "gpt-4o"); - // This will try to create a lead/worker provider let gpt4mini_config = ModelConfig::new_or_fail("gpt-4o-mini"); let result = create("openai", gpt4mini_config.clone()); - // The creation might succeed or fail depending on API keys, but we can verify the logic path match result { - Ok(_) => { - // If it succeeds, it means we created a lead/worker provider successfully - // This would happen if API keys are available in the test environment - } + Ok(_) => {} Err(error) => { - // If it fails, it should be due to missing API keys, confirming we tried to create providers let error_msg = error.to_string(); assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); } } - // Test with different lead provider - env::set_var("GOOSE_LEAD_PROVIDER", "anthropic"); - env::set_var("GOOSE_LEAD_TURNS", "5"); + _guard.set("GOOSE_LEAD_PROVIDER", "anthropic"); + _guard.set("GOOSE_LEAD_TURNS", "5"); let _result = create("openai", gpt4mini_config); - // Similar validation as above - will fail due to missing API keys but confirms the logic - - // Restore env vars - match saved_lead { - Some(val) => env::set_var("GOOSE_LEAD_MODEL", val), - None => env::remove_var("GOOSE_LEAD_MODEL"), - } - match saved_provider { - Some(val) => env::set_var("GOOSE_LEAD_PROVIDER", val), - None => env::remove_var("GOOSE_LEAD_PROVIDER"), - } - match saved_turns { - Some(val) => env::set_var("GOOSE_LEAD_TURNS", val), - None => env::remove_var("GOOSE_LEAD_TURNS"), - } } #[test] fn test_lead_model_env_vars_with_defaults() { - // Save current env vars - let saved_vars = [ - ("GOOSE_LEAD_MODEL", env::var("GOOSE_LEAD_MODEL").ok()), - ("GOOSE_LEAD_PROVIDER", env::var("GOOSE_LEAD_PROVIDER").ok()), - ("GOOSE_LEAD_TURNS", env::var("GOOSE_LEAD_TURNS").ok()), - ( - "GOOSE_LEAD_FAILURE_THRESHOLD", - env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(), - ), - ( - "GOOSE_LEAD_FALLBACK_TURNS", - env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(), - ), - ]; - - // Clear all lead env vars - for (key, _) in &saved_vars { - env::remove_var(key); - } + let _guard = EnvVarGuard::new(&[ + "GOOSE_LEAD_MODEL", + "GOOSE_LEAD_PROVIDER", + "GOOSE_LEAD_TURNS", + "GOOSE_LEAD_FAILURE_THRESHOLD", + "GOOSE_LEAD_FALLBACK_TURNS", + ]); - // Set only the required lead model - env::set_var("GOOSE_LEAD_MODEL", "grok-3"); + _guard.set("GOOSE_LEAD_MODEL", "grok-3"); - // This should use defaults for all other values let result = create("openai", ModelConfig::new_or_fail("gpt-4o-mini")); - // Should attempt to create lead/worker provider (will fail due to missing API keys but confirms logic) match result { - Ok(_) => { - // Success means we have API keys and created the provider - } + Ok(_) => {} Err(error) => { - // Should fail due to missing API keys, confirming we tried to create providers let error_msg = error.to_string(); assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); } } - // Test with custom values - env::set_var("GOOSE_LEAD_TURNS", "7"); - env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", "4"); - env::set_var("GOOSE_LEAD_FALLBACK_TURNS", "3"); + _guard.set("GOOSE_LEAD_TURNS", "7"); + _guard.set("GOOSE_LEAD_FAILURE_THRESHOLD", "4"); + _guard.set("GOOSE_LEAD_FALLBACK_TURNS", "3"); let _result = create("openai", ModelConfig::new_or_fail("gpt-4o-mini")); - // Should still attempt to create lead/worker provider with custom settings - - // Restore all env vars - for (key, value) in saved_vars { - match value { - Some(val) => env::set_var(key, val), - None => env::remove_var(key), - } - } } #[test] fn test_create_regular_provider_without_lead_config() { - // Save current env vars - let saved_lead = env::var("GOOSE_LEAD_MODEL").ok(); - let saved_provider = env::var("GOOSE_LEAD_PROVIDER").ok(); - let saved_turns = env::var("GOOSE_LEAD_TURNS").ok(); - let saved_threshold = env::var("GOOSE_LEAD_FAILURE_THRESHOLD").ok(); - let saved_fallback = env::var("GOOSE_LEAD_FALLBACK_TURNS").ok(); - - // Ensure all GOOSE_LEAD_* variables are not set - env::remove_var("GOOSE_LEAD_MODEL"); - env::remove_var("GOOSE_LEAD_PROVIDER"); - env::remove_var("GOOSE_LEAD_TURNS"); - env::remove_var("GOOSE_LEAD_FAILURE_THRESHOLD"); - env::remove_var("GOOSE_LEAD_FALLBACK_TURNS"); - - // This should try to create a regular provider + let _guard = EnvVarGuard::new(&[ + "GOOSE_LEAD_MODEL", + "GOOSE_LEAD_PROVIDER", + "GOOSE_LEAD_TURNS", + "GOOSE_LEAD_FAILURE_THRESHOLD", + "GOOSE_LEAD_FALLBACK_TURNS", + ]); + let result = create("openai", ModelConfig::new_or_fail("gpt-4o-mini")); - // The creation might succeed or fail depending on API keys match result { - Ok(_) => { - // If it succeeds, it means we created a regular provider successfully - // This would happen if API keys are available in the test environment - } + Ok(_) => {} Err(error) => { - // If it fails, it should be due to missing API keys let error_msg = error.to_string(); assert!(error_msg.contains("OPENAI_API_KEY") || error_msg.contains("secret")); } } - - if let Some(val) = saved_lead { - env::set_var("GOOSE_LEAD_MODEL", val); - } - if let Some(val) = saved_provider { - env::set_var("GOOSE_LEAD_PROVIDER", val); - } - if let Some(val) = saved_turns { - env::set_var("GOOSE_LEAD_TURNS", val); - } - if let Some(val) = saved_threshold { - env::set_var("GOOSE_LEAD_FAILURE_THRESHOLD", val); - } - if let Some(val) = saved_fallback { - env::set_var("GOOSE_LEAD_FALLBACK_TURNS", val); - } } #[test] fn test_worker_model_preserves_original_context_limit() { - use std::env; - - // Save current env vars - let saved_vars = [ - ("GOOSE_LEAD_MODEL", env::var("GOOSE_LEAD_MODEL").ok()), - ( - "GOOSE_WORKER_CONTEXT_LIMIT", - env::var("GOOSE_WORKER_CONTEXT_LIMIT").ok(), - ), - ("GOOSE_CONTEXT_LIMIT", env::var("GOOSE_CONTEXT_LIMIT").ok()), - ]; - - // Clear env vars to ensure clean test - for (key, _) in &saved_vars { - env::remove_var(key); - } + let _guard = EnvVarGuard::new(&[ + "GOOSE_LEAD_MODEL", + "GOOSE_WORKER_CONTEXT_LIMIT", + "GOOSE_CONTEXT_LIMIT", + ]); - // Set up lead model to trigger lead/worker mode - env::set_var("GOOSE_LEAD_MODEL", "gpt-4o"); + _guard.set("GOOSE_LEAD_MODEL", "gpt-4o"); - // Create a default model with explicit context_limit let default_model = ModelConfig::new_or_fail("gpt-3.5-turbo").with_context_limit(Some(16_000)); - // Test case 1: No environment variables - should preserve original context_limit let result = create_lead_worker_from_env("openai", &default_model, "gpt-4o"); - // Test case 2: With GOOSE_WORKER_CONTEXT_LIMIT - should override original - env::set_var("GOOSE_WORKER_CONTEXT_LIMIT", "32000"); + _guard.set("GOOSE_WORKER_CONTEXT_LIMIT", "32000"); let _result = create_lead_worker_from_env("openai", &default_model, "gpt-4o"); - env::remove_var("GOOSE_WORKER_CONTEXT_LIMIT"); - // Test case 3: With GOOSE_CONTEXT_LIMIT - should override original - env::set_var("GOOSE_CONTEXT_LIMIT", "64000"); + _guard.set("GOOSE_CONTEXT_LIMIT", "64000"); let _result = create_lead_worker_from_env("openai", &default_model, "gpt-4o"); - env::remove_var("GOOSE_CONTEXT_LIMIT"); - // Restore env vars - for (key, value) in saved_vars { - match value { - Some(val) => env::set_var(key, val), - None => env::remove_var(key), - } - } - - // The main verification is that the function doesn't panic and handles - // the context limit preservation logic correctly. More detailed testing - // would require mocking the provider creation. - // The result could be Ok or Err depending on whether API keys are available - // in the test environment - both are acceptable for this test match result { - Ok(_) => { - // Success means API keys are available and lead/worker provider was created - // This confirms our logic path is working - } - Err(_) => { - // Error is expected if API keys are not available - // This also confirms our logic path is working - } + Ok(_) => {} + Err(_) => {} } } } diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 6ddec035b092..5fe80017f705 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -24,6 +24,7 @@ pub mod ollama; pub mod openai; pub mod openrouter; pub mod pricing; +pub mod provider_registry; mod retry; pub mod sagemaker_tgi; pub mod snowflake; @@ -35,4 +36,4 @@ pub mod utils_universal_openai_stream; pub mod venice; pub mod xai; -pub use factory::{create, providers}; +pub use factory::{create, providers, refresh_custom_providers}; diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 4591dbb2145c..84b6a5e5e184 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -3,6 +3,7 @@ use super::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage}; use super::errors::ProviderError; use super::retry::ProviderRetry; use super::utils::{get_model, handle_response_openai_compat}; +use crate::config::custom_providers::CustomProviderConfig; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::impl_provider_default; @@ -30,6 +31,7 @@ pub struct OllamaProvider { #[serde(skip)] api_client: ApiClient, model: ModelConfig, + supports_streaming: bool, } impl_provider_default!(OllamaProvider); @@ -73,7 +75,47 @@ impl OllamaProvider { let auth = AuthMethod::Custom(Box::new(NoAuth)); let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; - Ok(Self { api_client, model }) + Ok(Self { + api_client, + model, + supports_streaming: true, + }) + } + + pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result { + let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); + + // Parse and normalize the custom URL + let base = + if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") { + config.base_url.clone() + } else { + format!("http://{}", config.base_url) + }; + + let mut base_url = Url::parse(&base) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + // Set default port if missing and not using standard ports + let explicit_default_port = + config.base_url.ends_with(":80") || config.base_url.ends_with(":443"); + let is_https = base_url.scheme() == "https"; + + if base_url.port().is_none() && !explicit_default_port && !is_https { + base_url + .set_port(Some(OLLAMA_DEFAULT_PORT)) + .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; + } + + // No authentication for Ollama + let auth = AuthMethod::Custom(Box::new(NoAuth)); + let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; + + Ok(Self { + api_client, + model, + supports_streaming: config.supports_streaming.unwrap_or(true), + }) } async fn post(&self, payload: &Value) -> Result { @@ -181,6 +223,10 @@ impl Provider for OllamaProvider { Ok(safe_truncate(&description, 100)) } + + fn supports_streaming(&self) -> bool { + self.supports_streaming + } } impl OllamaProvider { diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index fa4211f128bc..d49dcfeffbdf 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -20,6 +20,7 @@ use super::utils::{ emit_debug_trace, get_model, handle_response_openai_compat, handle_status_openai_compat, ImageFormat, }; +use crate::config::custom_providers::CustomProviderConfig; use crate::conversation::message::Message; use crate::impl_provider_default; use crate::model::ModelConfig; @@ -51,6 +52,7 @@ pub struct OpenAiProvider { project: Option, model: ModelConfig, custom_headers: Option>, + supports_streaming: bool, } impl_provider_default!(OpenAiProvider); @@ -103,6 +105,51 @@ impl OpenAiProvider { project, model, custom_headers, + supports_streaming: true, + }) + } + + pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result { + let global_config = crate::config::Config::global(); + let api_key: String = global_config + .get_secret(&config.api_key_env) + .map_err(|_e| anyhow::anyhow!("Missing API key: {}", config.api_key_env))?; + + let url = url::Url::parse(&config.base_url) + .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; + + let host = format!("{}://{}", url.scheme(), url.host_str().unwrap_or("")); + let base_path = url.path().trim_start_matches('/').to_string(); + let base_path = if base_path.is_empty() { + "v1/chat/completions".to_string() + } else { + base_path + }; + + let timeout_secs = config.timeout_seconds.unwrap_or(600); + let auth = AuthMethod::BearerToken(api_key); + let mut api_client = + ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; + + // Add custom headers if present + if let Some(headers) = &config.headers { + let mut header_map = reqwest::header::HeaderMap::new(); + for (key, value) in headers { + let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + header_map.insert(header_name, header_value); + } + api_client = api_client.with_headers(header_map)?; + } + + Ok(Self { + api_client, + base_path, + organization: None, + project: None, + model, + custom_headers: config.headers, + supports_streaming: config.supports_streaming.unwrap_or(true), }) } @@ -206,7 +253,7 @@ impl Provider for OpenAiProvider { } fn supports_streaming(&self) -> bool { - true + self.supports_streaming } async fn stream( diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs new file mode 100644 index 000000000000..e20bc98dd106 --- /dev/null +++ b/crates/goose/src/providers/provider_registry.rs @@ -0,0 +1,102 @@ +use super::base::{Provider, ProviderMetadata}; +use crate::model::ModelConfig; +use anyhow::Result; +use std::collections::HashMap; +use std::sync::Arc; + +type ProviderConstructor = Box Result> + Send + Sync>; + +struct ProviderEntry { + metadata: ProviderMetadata, + constructor: ProviderConstructor, +} + +#[derive(Default)] +pub struct ProviderRegistry { + entries: HashMap, +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + pub fn register(&mut self, constructor: F) + where + P: Provider + 'static, + F: Fn(ModelConfig) -> Result

+ Send + Sync + 'static, + { + let metadata = P::metadata(); + let name = metadata.name.clone(); + + self.entries.insert( + name, + ProviderEntry { + metadata, + constructor: Box::new(move |model| Ok(Arc::new(constructor(model)?))), + }, + ); + } + + /// create provider with custom name + pub fn register_with_name( + &mut self, + custom_name: String, + display_name: String, + description: String, + default_model: String, + known_models: Vec, + constructor: F, + ) where + P: Provider + 'static, + F: Fn(ModelConfig) -> Result

+ Send + Sync + 'static, + { + let base_metadata = P::metadata(); + let custom_metadata = ProviderMetadata { + name: custom_name.clone(), + display_name, + description, + default_model, + known_models, + model_doc_link: base_metadata.model_doc_link, + config_keys: base_metadata.config_keys, + }; + + self.entries.insert( + custom_name, + ProviderEntry { + metadata: custom_metadata, + constructor: Box::new(move |model| Ok(Arc::new(constructor(model)?))), + }, + ); + } + + pub fn with_providers(mut self, setup: F) -> Self + where + F: FnOnce(&mut Self), + { + setup(&mut self); + self + } + + pub fn create(&self, name: &str, model: ModelConfig) -> Result> { + let _available_providers: Vec<_> = self.entries.keys().collect(); + + let entry = self + .entries + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown provider: {}", name))?; + + (entry.constructor)(model) + } + + pub fn all_metadata(&self) -> Vec { + self.entries.values().map(|e| e.metadata.clone()).collect() + } + + pub fn remove_custom_providers(&mut self) { + self.entries.retain(|name, _| !name.starts_with("custom_")); + } +} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 8a603cc5727f..1d16ebe4e18e 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -275,6 +275,78 @@ } } }, + "/config/custom-providers": { + "post": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "create_custom_provider", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCustomProviderRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Custom provider created successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid request" + }, + "500": { + "description": "Internal server error" + } + } + } + }, + "/config/custom-providers/{id}": { + "delete": { + "tags": [ + "super::routes::config_management" + ], + "operationId": "remove_custom_provider", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Custom provider removed successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Provider not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/config/extensions": { "get": { "tags": [ @@ -1469,6 +1541,40 @@ } } }, + "CreateCustomProviderRequest": { + "type": "object", + "required": [ + "provider_type", + "display_name", + "api_url", + "api_key", + "models" + ], + "properties": { + "api_key": { + "type": "string" + }, + "api_url": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "models": { + "type": "array", + "items": { + "type": "string" + } + }, + "provider_type": { + "type": "string" + }, + "supports_streaming": { + "type": "boolean", + "nullable": true + } + } + }, "CreateRecipeRequest": { "type": "object", "required": [ diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 21eded1c6f0f..ae8fe8974598 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -119,13 +119,13 @@ "license": "MIT" }, "node_modules/@ai-sdk/gateway": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.6.tgz", - "integrity": "sha512-JuSj1MtTr4vw2VBBth4wlbciQnQIV0o1YV9qGLFA+r85nR5H+cJp3jaYE0nprqfzC9rYG8w9c6XGHB3SDKgcgA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.8.tgz", + "integrity": "sha512-yiHYz0bAHEvhL+fSUBI2dNmyj0LOI8zw5qrYpa4gp1ojPgZq/7T1WXoIWRmVdjQwvT4PzSmRKLtbMPfz+umgfw==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.3" + "@ai-sdk/provider-utils": "3.0.4" }, "engines": { "node": ">=18" @@ -135,13 +135,13 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.14.tgz", - "integrity": "sha512-u/wi1ixcvcg29wAJySjO803HlXpyCl6mkcOHn+Zn7DA+CtjuQNkKikJ4pZBc7I3Qhi90kA4XnOfKikhBXh4c4Q==", + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-2.0.16.tgz", + "integrity": "sha512-Boe715q4SkSJedFfAtbP0yuo8DmF9iYElAaDH2g4YgqJqqkskIJJx4hlCYGMMk1eMesRrB2NqZvtOeyTZ/u4fA==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.3" + "@ai-sdk/provider-utils": "3.0.4" }, "engines": { "node": ">=18" @@ -163,9 +163,9 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.3.tgz", - "integrity": "sha512-kAxIw1nYmFW1g5TvE54ZB3eNtgZna0RnLjPUp1ltz1+t9xkXJIuDT4atrwfau9IbS0BOef38wqrI8CjFfQrxhw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.4.tgz", + "integrity": "sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==", "license": "Apache-2.0", "dependencies": { "@ai-sdk/provider": "2.0.0", @@ -2367,9 +2367,9 @@ } }, "node_modules/@hey-api/openapi-ts": { - "version": "0.80.10", - "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.10.tgz", - "integrity": "sha512-jFCaQJeZV9B6lbYCU8JFVkC2/FE+31FG36aT4aiOXeGvgJsIa7v3HFXQ3sdcN0uHRuLyf11GUDrVupPBPvgkuA==", + "version": "0.80.15", + "resolved": "https://registry.npmjs.org/@hey-api/openapi-ts/-/openapi-ts-0.80.15.tgz", + "integrity": "sha512-THERBZUJG9lKHBqY7yBWL8icuwLJc/BY0kjuuZKZST/ZZzt+Zl78nMjP1RFaKybyT8yX0YlA/liem51dSSf+9Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2379,6 +2379,7 @@ "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", + "js-yaml": "4.1.0", "open": "10.1.2", "semver": "7.7.2" }, @@ -3108,9 +3109,9 @@ "license": "MIT" }, "node_modules/@preact/signals-core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.11.0.tgz", - "integrity": "sha512-jglbibeWHuFRzEWVFY/TT7wB1PppJxmcSfUHcK+2J9vBRtiooMfw6tAPttojNYrrpdGViqAYCbPpmWYlMm+eMQ==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.12.0.tgz", + "integrity": "sha512-etWpENXm469RHMWIZGblgWrapbIGcRcbccEGGaLkFez3PjlI3XkBrUtSiNFsIfV/DN16PxMOxbWAZUIaLFyJDg==", "license": "MIT", "funding": { "type": "opencollective", @@ -4741,16 +4742,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.30", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz", - "integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==", + "version": "1.0.0-beta.32", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz", + "integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz", - "integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz", + "integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==", "cpu": [ "arm" ], @@ -4762,9 +4763,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz", - "integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz", + "integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==", "cpu": [ "arm64" ], @@ -4776,9 +4777,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", - "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz", + "integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==", "cpu": [ "arm64" ], @@ -4790,9 +4791,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz", - "integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz", + "integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==", "cpu": [ "x64" ], @@ -4804,9 +4805,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz", - "integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz", + "integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==", "cpu": [ "arm64" ], @@ -4818,9 +4819,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz", - "integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz", + "integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==", "cpu": [ "x64" ], @@ -4832,9 +4833,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz", - "integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz", + "integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==", "cpu": [ "arm" ], @@ -4846,9 +4847,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz", - "integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz", + "integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==", "cpu": [ "arm" ], @@ -4860,9 +4861,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz", - "integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz", + "integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==", "cpu": [ "arm64" ], @@ -4874,9 +4875,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz", - "integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz", + "integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==", "cpu": [ "arm64" ], @@ -4888,9 +4889,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz", - "integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz", + "integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==", "cpu": [ "loong64" ], @@ -4902,9 +4903,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz", - "integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz", + "integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==", "cpu": [ "ppc64" ], @@ -4916,9 +4917,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz", - "integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz", + "integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==", "cpu": [ "riscv64" ], @@ -4930,9 +4931,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz", - "integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz", + "integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==", "cpu": [ "riscv64" ], @@ -4944,9 +4945,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz", - "integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz", + "integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==", "cpu": [ "s390x" ], @@ -4958,9 +4959,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", - "integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz", + "integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==", "cpu": [ "x64" ], @@ -4972,9 +4973,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz", - "integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz", + "integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==", "cpu": [ "x64" ], @@ -4986,9 +4987,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz", - "integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz", + "integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==", "cpu": [ "arm64" ], @@ -5000,9 +5001,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz", - "integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz", + "integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==", "cpu": [ "ia32" ], @@ -5014,9 +5015,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz", - "integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz", + "integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==", "cpu": [ "x64" ], @@ -5807,9 +5808,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.2.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", - "integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "dev": true, "license": "MIT", "dependencies": { @@ -5951,17 +5952,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", - "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz", + "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/type-utils": "8.39.1", - "@typescript-eslint/utils": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/type-utils": "8.40.0", + "@typescript-eslint/utils": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5975,22 +5976,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.39.1", + "@typescript-eslint/parser": "^8.40.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", - "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.40.0.tgz", + "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4" }, "engines": { @@ -6006,14 +6007,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", - "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", + "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.1", - "@typescript-eslint/types": "^8.39.1", + "@typescript-eslint/tsconfig-utils": "^8.40.0", + "@typescript-eslint/types": "^8.40.0", "debug": "^4.3.4" }, "engines": { @@ -6028,14 +6029,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", - "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz", + "integrity": "sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1" + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6046,9 +6047,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", - "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", + "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", "dev": true, "license": "MIT", "engines": { @@ -6063,15 +6064,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", - "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz", + "integrity": "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0", + "@typescript-eslint/utils": "8.40.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -6088,9 +6089,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", - "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", + "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", "dev": true, "license": "MIT", "engines": { @@ -6102,16 +6103,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", - "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", + "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.1", - "@typescript-eslint/tsconfig-utils": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", + "@typescript-eslint/project-service": "8.40.0", + "@typescript-eslint/tsconfig-utils": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -6157,16 +6158,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", - "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.40.0.tgz", + "integrity": "sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1" + "@typescript-eslint/scope-manager": "8.40.0", + "@typescript-eslint/types": "8.40.0", + "@typescript-eslint/typescript-estree": "8.40.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6181,13 +6182,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", - "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", + "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/types": "8.40.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -6218,16 +6219,16 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz", - "integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz", + "integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", + "@babel/core": "^7.28.3", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.30", + "@rolldown/pluginutils": "1.0.0-beta.32", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -6410,9 +6411,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "dev": true, "license": "MIT", "engines": { @@ -6500,14 +6501,14 @@ } }, "node_modules/ai": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.14.tgz", - "integrity": "sha512-xiujFa879skB7YxGzbeHAxepsr6AEaWcHPXrc5a9MRM6p4WdVAwn6mGwVZkBnhqGfZtXFr4LUnU2ayvcjWp5ig==", + "version": "5.0.17", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.17.tgz", + "integrity": "sha512-DLZikqZZJdwSkRhFikw6Mt7pUmPZ7Ue38TjdOcw2U6iZtBbuiyWGIhHyJXlUpLcZrtBE5yqPTozyZri1lRjduw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "1.0.6", + "@ai-sdk/gateway": "1.0.8", "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.3", + "@ai-sdk/provider-utils": "3.0.4", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -6990,9 +6991,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", - "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "version": "4.25.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz", + "integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==", "dev": true, "funding": [ { @@ -7010,8 +7011,8 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001733", - "electron-to-chromium": "^1.5.199", + "caniuse-lite": "^1.0.30001735", + "electron-to-chromium": "^1.5.204", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -7376,9 +7377,9 @@ } }, "node_modules/chai": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.1.tgz", - "integrity": "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.1.tgz", + "integrity": "sha512-48af6xm9gQK8rhIcOxWwdGzIervm8BVTin+yRp9HEvU20BtVZ2lBywlIJBzwaDtvo0FvjeL7QdCADoUoqIbV3A==", "dev": true, "license": "MIT", "dependencies": { @@ -8869,9 +8870,9 @@ } }, "node_modules/electron-log": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.2.tgz", - "integrity": "sha512-L55kJzbVVoBY6kyJ3A+cjPchL1aXLdV2/Q8SvIj4sE5VmOcwZa6KXaFICZE+Z+RMXhAIFta79kSaXBqn+0XXMA==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.4.3.tgz", + "integrity": "sha512-sOUsM3LjZdugatazSQ/XTyNcw8dfvH1SYhXWiJyfYodAAKOZdHs0txPiLDXFzOZbhXgAgshQkshH2ccq0feyLQ==", "license": "MIT", "engines": { "node": ">= 14" @@ -8902,9 +8903,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.201", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.201.tgz", - "integrity": "sha512-ZG65vsrLClodGqywuigc+7m0gr4ISoTQttfVh7nfpLv0M7SIwF4WbFNEOywcqTiujs12AUeeXbFyQieDICAIxg==", + "version": "1.5.207", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz", + "integrity": "sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==", "dev": true, "license": "ISC" }, @@ -9009,9 +9010,9 @@ } }, "node_modules/electron/node_modules/@types/node": { - "version": "22.17.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.1.tgz", - "integrity": "sha512-y3tBaz+rjspDTylNjAX37jEC3TETEFGNJL6uQDxwF9/8GLLIjW1rvVHlynyuUKMnMr1Roq8jOv3vkopBjC4/VA==", + "version": "22.17.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.2.tgz", + "integrity": "sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==", "dev": true, "license": "MIT", "dependencies": { @@ -9736,9 +9737,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.3.tgz", - "integrity": "sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", + "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", "license": "MIT", "engines": { "node": ">=20.0.0" @@ -11962,9 +11963,9 @@ } }, "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -12583,9 +12584,9 @@ } }, "node_modules/lint-staged/node_modules/chalk": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.5.0.tgz", - "integrity": "sha512-1tm8DTaJhPBG3bIkVeZt1iZM9GfSX2lzOeDVZH9R9ffRHpmHvxZ/QhgQH/aDTkswQVt+YHdXAdS/In/30OjCbg==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.0.tgz", + "integrity": "sha512-46QrSQFyVSEyYAgQ22hQ+zDa60YHA4fBstHmtSApj1Y5vKtG27fWowW03jCk5KcbXEWPZUIR894aARCA/G1kfQ==", "dev": true, "license": "MIT", "engines": { @@ -15916,9 +15917,9 @@ } }, "node_modules/react-router": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.0.tgz", - "integrity": "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.1.tgz", + "integrity": "sha512-5cy/M8DHcG51/KUIka1nfZ2QeylS4PJRs6TT8I4PF5axVsI5JUxp0hC0NZ/AEEj8Vw7xsEoD7L/6FY+zoYaOGA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -15938,12 +15939,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.0.tgz", - "integrity": "sha512-ntInsnDVnVRdtSu6ODmTQ41cbluak/ENeTif7GBce0L6eztFg6/e1hXAysFQI8X25C8ipKmT9cClbJwxx3Kaqw==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.1.tgz", + "integrity": "sha512-NkgBCF3sVgCiAWIlSt89GR2PLaksMpoo3HDCorpRfnCEfdtRPLiuTf+CNXvqZMI5SJLZCLpVCvcZrTdtGW64xQ==", "license": "MIT", "dependencies": { - "react-router": "7.8.0" + "react-router": "7.8.1" }, "engines": { "node": ">=20.0.0" @@ -16610,9 +16611,9 @@ } }, "node_modules/rollup": { - "version": "4.46.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", - "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz", + "integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==", "dev": true, "license": "MIT", "dependencies": { @@ -16626,26 +16627,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.46.2", - "@rollup/rollup-android-arm64": "4.46.2", - "@rollup/rollup-darwin-arm64": "4.46.2", - "@rollup/rollup-darwin-x64": "4.46.2", - "@rollup/rollup-freebsd-arm64": "4.46.2", - "@rollup/rollup-freebsd-x64": "4.46.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", - "@rollup/rollup-linux-arm-musleabihf": "4.46.2", - "@rollup/rollup-linux-arm64-gnu": "4.46.2", - "@rollup/rollup-linux-arm64-musl": "4.46.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", - "@rollup/rollup-linux-ppc64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-gnu": "4.46.2", - "@rollup/rollup-linux-riscv64-musl": "4.46.2", - "@rollup/rollup-linux-s390x-gnu": "4.46.2", - "@rollup/rollup-linux-x64-gnu": "4.46.2", - "@rollup/rollup-linux-x64-musl": "4.46.2", - "@rollup/rollup-win32-arm64-msvc": "4.46.2", - "@rollup/rollup-win32-ia32-msvc": "4.46.2", - "@rollup/rollup-win32-x64-msvc": "4.46.2", + "@rollup/rollup-android-arm-eabi": "4.46.3", + "@rollup/rollup-android-arm64": "4.46.3", + "@rollup/rollup-darwin-arm64": "4.46.3", + "@rollup/rollup-darwin-x64": "4.46.3", + "@rollup/rollup-freebsd-arm64": "4.46.3", + "@rollup/rollup-freebsd-x64": "4.46.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", + "@rollup/rollup-linux-arm-musleabihf": "4.46.3", + "@rollup/rollup-linux-arm64-gnu": "4.46.3", + "@rollup/rollup-linux-arm64-musl": "4.46.3", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", + "@rollup/rollup-linux-ppc64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-musl": "4.46.3", + "@rollup/rollup-linux-s390x-gnu": "4.46.3", + "@rollup/rollup-linux-x64-gnu": "4.46.3", + "@rollup/rollup-linux-x64-musl": "4.46.3", + "@rollup/rollup-win32-arm64-msvc": "4.46.3", + "@rollup/rollup-win32-ia32-msvc": "4.46.3", + "@rollup/rollup-win32-x64-msvc": "4.46.3", "fsevents": "~2.3.2" } }, @@ -17557,9 +17558,9 @@ } }, "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", "dev": true, "license": "MIT", "engines": { @@ -18171,9 +18172,9 @@ "license": "0BSD" }, "node_modules/tw-animate-css": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.6.tgz", - "integrity": "sha512-9dy0R9UsYEGmgf26L8UcHiLmSFTHa9+D7+dAt/G/sF5dCnPePZbfgDYinc7/UzAM7g/baVrmS6m9yEpU46d+LA==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.7.tgz", + "integrity": "sha512-lvLb3hTIpB5oGsk8JmLoAjeCHV58nKa2zHYn8yWOoG5JJusH3bhJlF2DLAZ/5NmJ+jyH3ssiAx/2KmbhavJy/A==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" @@ -18678,14 +18679,14 @@ } }, "node_modules/vite": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", - "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", + "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.6", + "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", diff --git a/ui/desktop/src/api/client/utils.gen.ts b/ui/desktop/src/api/client/utils.gen.ts index 6d82364ef878..1ee09c6db7e4 100644 --- a/ui/desktop/src/api/client/utils.gen.ts +++ b/ui/desktop/src/api/client/utils.gen.ts @@ -188,6 +188,25 @@ export const getParseAs = ( return; }; +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + export const setAuthParams = async ({ security, ...options @@ -196,6 +215,10 @@ export const setAuthParams = async ({ headers: Headers; }) => { for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + const token = await getAuthToken(auth, options.auth); if (!token) { @@ -219,8 +242,6 @@ export const setAuthParams = async ({ options.headers.set(name, token); break; } - - return; } }; diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 839265ceb272..e5407d98db3d 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -90,6 +90,24 @@ export const backupConfig = (options?: Opt }); }; +export const createCustomProvider = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/config/custom-providers', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +export const removeCustomProvider = (options: Options) => { + return (options.client ?? _heyApiClient).delete({ + url: '/config/custom-providers/{id}', + ...options + }); +}; + export const getExtensions = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/config/extensions', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 83938c589e16..a400fffd14d0 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -98,6 +98,15 @@ export type ContextManageResponse = { tokenCounts: Array; }; +export type CreateCustomProviderRequest = { + api_key: string; + api_url: string; + display_name: string; + models: Array; + provider_type: string; + supports_streaming?: boolean | null; +}; + export type CreateRecipeRequest = { activities?: Array | null; author?: AuthorRequest | null; @@ -1047,6 +1056,62 @@ export type BackupConfigResponses = { export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses]; +export type CreateCustomProviderData = { + body: CreateCustomProviderRequest; + path?: never; + query?: never; + url: '/config/custom-providers'; +}; + +export type CreateCustomProviderErrors = { + /** + * Invalid request + */ + 400: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type CreateCustomProviderResponses = { + /** + * Custom provider created successfully + */ + 200: string; +}; + +export type CreateCustomProviderResponse = CreateCustomProviderResponses[keyof CreateCustomProviderResponses]; + +export type RemoveCustomProviderData = { + body?: never; + path: { + id: string; + }; + query?: never; + url: '/config/custom-providers/{id}'; +}; + +export type RemoveCustomProviderErrors = { + /** + * Provider not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type RemoveCustomProviderResponses = { + /** + * Custom provider removed successfully + */ + 200: string; +}; + +export type RemoveCustomProviderResponse = RemoveCustomProviderResponses[keyof RemoveCustomProviderResponses]; + export type GetExtensionsData = { body?: never; path?: never; diff --git a/ui/desktop/src/components/ConfigContext.tsx b/ui/desktop/src/components/ConfigContext.tsx index fd97bca068ce..f39175f819e5 100644 --- a/ui/desktop/src/components/ConfigContext.tsx +++ b/ui/desktop/src/components/ConfigContext.tsx @@ -170,9 +170,15 @@ export const ConfigProvider: React.FC = ({ children }) => { const getProviders = useCallback( async (forceRefresh = false): Promise => { if (forceRefresh || providersList.length === 0) { - const response = await providers(); - setProvidersList(response.data || []); - return response.data || []; + try { + const response = await providers(); + const providersData = response.data || []; + setProvidersList(providersData); + return providersData; + } catch (error) { + console.error('Failed to fetch providers:', error); + return []; + } } return providersList; }, @@ -189,9 +195,11 @@ export const ConfigProvider: React.FC = ({ children }) => { // Load providers try { const providersResponse = await providers(); - setProvidersList(providersResponse.data || []); + const providersData = providersResponse.data || []; + setProvidersList(providersData); } catch (error) { console.error('Failed to load providers:', error); + setProvidersList([]); } // Load extensions diff --git a/ui/desktop/src/components/MarkdownContent.test.tsx b/ui/desktop/src/components/MarkdownContent.test.tsx index d48713d1edad..163ecb2afd84 100644 --- a/ui/desktop/src/components/MarkdownContent.test.tsx +++ b/ui/desktop/src/components/MarkdownContent.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/dom'; import MarkdownContent from './MarkdownContent'; // Mock the icons to avoid import issues diff --git a/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx b/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx index 35d02c7dd824..ae50156b571e 100644 --- a/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx +++ b/ui/desktop/src/components/settings/models/subcomponents/AddModelModal.tsx @@ -159,11 +159,14 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => { // Add the "Custom model" option to each provider group formattedModelOptions.forEach((group) => { - group.options.push({ - value: 'custom', - label: 'Use custom model', - provider: group.options[0]?.provider, - }); + const providerName = group.options[0]?.provider; + if (providerName && !providerName.startsWith('custom_')) { + group.options.push({ + value: 'custom', + label: 'Use custom model', + provider: providerName, + }); + } }); setModelOptions(formattedModelOptions); diff --git a/ui/desktop/src/components/settings/permission/PermissionModal.tsx b/ui/desktop/src/components/settings/permission/PermissionModal.tsx index 8455fc3f4c83..46c0fef70d59 100644 --- a/ui/desktop/src/components/settings/permission/PermissionModal.tsx +++ b/ui/desktop/src/components/settings/permission/PermissionModal.tsx @@ -45,7 +45,7 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo console.error('Failed to get tools'); } else { const filteredTools = (response.data || []).filter( - (tool) => + (tool: ToolInfo) => tool.name !== 'platform__read_resource' && tool.name !== 'platform__list_resources' ); setTools(filteredTools); diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index c30166106a7e..0f1ce36b7b0c 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -1,8 +1,12 @@ -import React, { memo, useMemo, useCallback } from 'react'; +import React, { memo, useMemo, useCallback, useState } from 'react'; import { ProviderCard } from './subcomponents/ProviderCard'; +import CardContainer from './subcomponents/CardContainer'; import { ProviderModalProvider, useProviderModal } from './modal/ProviderModalProvider'; import ProviderConfigurationModal from './modal/ProviderConfiguationModal'; -import { ProviderDetails } from '../../../api'; +import { ProviderDetails, CreateCustomProviderRequest } from '../../../api'; +import { Plus } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog'; +import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm'; const GridLayout = memo(function GridLayout({ children }: { children: React.ReactNode }) { return ( @@ -18,6 +22,27 @@ const GridLayout = memo(function GridLayout({ children }: { children: React.Reac ); }); +const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onClick: () => void }) { + return ( + + +

+
Add
+
Custom Provider
+
+ + } + grayedOut={false} + borderStyle="dashed" + /> + ); +}); + // Memoize the ProviderCards component const ProviderCards = memo(function ProviderCards({ providers, @@ -31,6 +56,7 @@ const ProviderCards = memo(function ProviderCards({ onProviderLaunch: (provider: ProviderDetails) => void; }) { const { openModal } = useProviderModal(); + const [showCustomProviderModal, setShowCustomProviderModal] = useState(false); // Memoize these functions so they don't get recreated on every render const configureProviderViaModal = useCallback( @@ -42,7 +68,7 @@ const ProviderCards = memo(function ProviderCards({ refreshProviders(); } }, - onDelete: () => { + onDelete: (_values: unknown) => { if (refreshProviders) { refreshProviders(); } @@ -56,7 +82,7 @@ const ProviderCards = memo(function ProviderCards({ const deleteProviderConfigViaModal = useCallback( (provider: ProviderDetails) => { openModal(provider, { - onDelete: () => { + onDelete: (_values: unknown) => { // Only refresh if the function is provided if (refreshProviders) { refreshProviders(); @@ -68,12 +94,27 @@ const ProviderCards = memo(function ProviderCards({ [openModal, refreshProviders] ); - // We don't need an intermediate function here - // Just pass the onProviderLaunch directly + const handleCreateCustomProvider = useCallback( + async (data: CreateCustomProviderRequest) => { + try { + const { createCustomProvider } = await import('../../../api'); + await createCustomProvider({ body: data }); + setShowCustomProviderModal(false); + if (refreshProviders) { + refreshProviders(); + } + } catch (error) { + console.error('Failed to create custom provider:', error); + } + }, + [refreshProviders] + ); // Use useMemo to memoize the cards array const providerCards = useMemo(() => { - return providers.map((provider) => ( + // providers needs to be an array + const providersArray = Array.isArray(providers) ? providers : []; + const cards = providersArray.map((provider) => ( )); + + cards.push( + setShowCustomProviderModal(true)} /> + ); + + return cards; }, [ providers, isOnboarding, @@ -91,7 +138,23 @@ const ProviderCards = memo(function ProviderCards({ onProviderLaunch, ]); - return <>{providerCards}; + return ( + <> + {providerCards} + + + + + Add Custom Provider + + setShowCustomProviderModal(false)} + /> + + + + ); }); export default memo(function ProviderGrid({ diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 67070265b4df..58b0df67dc86 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -18,7 +18,7 @@ import OllamaForm from './subcomponents/forms/OllamaForm'; import { useConfig } from '../../../ConfigContext'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AlertTriangle } from 'lucide-react'; -import { ConfigKey } from '../../../../api'; +import { ConfigKey, removeCustomProvider } from '../../../../api'; interface FormValues { [key: string]: string | number | boolean | null; @@ -162,13 +162,21 @@ export default function ProviderConfigurationModal() { } try { - // Remove the provider configuration - // get the keys - const params = currentProvider.metadata.config_keys; - - // go through the keys are remove them - for (const param of params) { - await remove(param.name, param.secret); + const isCustomProvider = currentProvider.name.startsWith('custom_'); + + if (isCustomProvider) { + await removeCustomProvider({ + path: { id: currentProvider.name }, + }); + } else { + // Remove the provider configuration + // get the keys + const params = currentProvider.metadata.config_keys; + + // go through the keys are remove them + for (const param of params) { + await remove(param.name, param.secret); + } } // Call onDelete callback if provided @@ -247,9 +255,9 @@ export default function ProviderConfigurationModal() { setShowDeleteConfirmation(false); setIsActiveProvider(false); }} - canDelete={isConfigured && !isActiveProvider} // Disable delete button for active provider + canDelete={isConfigured && !isActiveProvider} providerName={currentProvider.metadata.display_name} - isActiveProvider={isActiveProvider} // Pass this to actions for button state + isActiveProvider={isActiveProvider} /> diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx new file mode 100644 index 000000000000..5392639b06f5 --- /dev/null +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx @@ -0,0 +1,198 @@ +import React, { useState } from 'react'; +import { Input } from '../../../../../ui/input'; +import { Select } from '../../../../../ui/Select'; +import { Button } from '../../../../../ui/button'; +import { SecureStorageNotice } from '../SecureStorageNotice'; +import { Checkbox } from '@radix-ui/themes'; + +interface CustomProviderFormProps { + onSubmit: (data: { + provider_type: string; + display_name: string; + api_url: string; + api_key: string; + models: string[]; + supports_streaming: boolean; + }) => void; + onCancel: () => void; +} + +export default function CustomProviderForm({ onSubmit, onCancel }: CustomProviderFormProps) { + const [providerType, setProviderType] = useState('openai_compatible'); + const [displayName, setDisplayName] = useState(''); + const [apiUrl, setApiUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [models, setModels] = useState(''); + const [isLocalModel, setIsLocalModel] = useState(false); + const [supportsStreaming, setSupportsStreaming] = useState(true); + const [validationErrors, setValidationErrors] = useState>({}); + + const handleLocalModels = (checked: boolean) => { + setIsLocalModel(checked); + if (checked) { + setApiKey('notrequired'); + } else { + setApiKey(''); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + const errors: Record = {}; + if (!displayName) errors.displayName = 'Display name is required'; + if (!apiUrl) errors.apiUrl = 'API URL is required'; + if (!isLocalModel && !apiKey) errors.apiKey = 'API key is required'; + if (!models) errors.models = 'At least one model is required'; + + if (Object.keys(errors).length > 0) { + setValidationErrors(errors); + return; + } + + const modelList = models + .split(',') + .map((m) => m.trim()) + .filter((m) => m); + + onSubmit({ + provider_type: providerType, + display_name: displayName, + api_url: apiUrl, + api_key: apiKey, + models: modelList, + supports_streaming: supportsStreaming, + }); + }; + + return ( +
+
+ + setDisplayName(e.target.value)} + placeholder="Your Provider Name" + className={validationErrors.displayName ? 'border-red-500' : ''} + /> + {validationErrors.displayName && ( +

{validationErrors.displayName}

+ )} +
+ +
+ + setApiUrl(e.target.value)} + placeholder="https://api.example.com/v1/messages" + className={validationErrors.apiUrl ? 'border-red-500' : ''} + /> + {validationErrors.apiUrl && ( +

{validationErrors.apiUrl}

+ )} +
+ +
+ + setApiKey(e.target.value)} + placeholder="Your API key" + className={validationErrors.apiKey ? 'border-red-500' : ''} + disabled={isLocalModel} + /> + {validationErrors.apiKey && ( +

{validationErrors.apiKey}

+ )} + +
+ + +
+
+ +
+ + setModels(e.target.value)} + placeholder="model-a, model-b, model-c" + className={validationErrors.models ? 'border-red-500' : ''} + /> + {validationErrors.models && ( +

{validationErrors.models}

+ )} +
+ +
+ setSupportsStreaming(checked as boolean)} + /> + +
+ + + +
+ + +
+ + ); +} diff --git a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx index 294f3052de1b..6f8e2dd69476 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/DefaultProviderSetupForm.tsx @@ -3,9 +3,7 @@ import { Input } from '../../../../../ui/input'; import { useConfig } from '../../../../../ConfigContext'; // Adjust this import path as needed import { ProviderDetails, ConfigKey } from '../../../../../../api'; -interface ValidationErrors { - [key: string]: string; -} +type ValidationErrors = Record; interface DefaultProviderSetupFormProps { configValues: Record; @@ -36,33 +34,30 @@ export default function DefaultProviderSetupForm({ // Try to load actual values from config for each parameter that is not secret for (const parameter of parameters) { - if (parameter.required) { - try { - // Check if there's a stored value in the config system - const configKey = `${parameter.name}`; - const configResponse = await read(configKey, parameter.secret || false); - - if (configResponse) { - // Use the value from the config provider - newValues[parameter.name] = String(configResponse); - } else if ( - parameter.default !== undefined && - parameter.default !== null && - !configValues[parameter.name] - ) { - // Fall back to default value if no config value exists - newValues[parameter.name] = String(parameter.default); - } - } catch (error) { - console.error(`Failed to load config for ${parameter.name}:`, error); - // Fall back to default if read operation fails - if ( - parameter.default !== undefined && - parameter.default !== null && - !configValues[parameter.name] - ) { - newValues[parameter.name] = String(parameter.default); - } + try { + // Check if there's a stored value in the config system + const configKey = `${parameter.name}`; + const configResponse = await read(configKey, parameter.secret || false); + + if (configResponse) { + newValues[parameter.name] = parameter.secret ? 'true' : String(configResponse); + } else if ( + parameter.default !== undefined && + parameter.default !== null && + !configValues[parameter.name] + ) { + // Fall back to default value if no config value exists + newValues[parameter.name] = String(parameter.default); + } + } catch (error) { + console.error(`Failed to load config for ${parameter.name}:`, error); + // Fall back to default if read operation fails + if ( + parameter.default !== undefined && + parameter.default !== null && + !configValues[parameter.name] + ) { + newValues[parameter.name] = String(parameter.default); } } } @@ -85,6 +80,11 @@ export default function DefaultProviderSetupForm({ return parameters.filter((param) => param.required === true); }, [parameters]); + // TODO: show all params, not just required ones + // const allParameters = useMemo(() => { + // return parameters; + // }, [parameters]); + // Helper function to generate appropriate placeholder text const getPlaceholder = (parameter: ConfigKey): string => { // If default is defined and not null, show it @@ -92,8 +92,30 @@ export default function DefaultProviderSetupForm({ return `Default: ${parameter.default}`; } - // Otherwise, use the parameter name as a hint - return parameter.name.toUpperCase(); + const name = parameter.name.toLowerCase(); + if (name.includes('api_key')) return 'Your API key'; + if (name.includes('api_url') || name.includes('host')) return 'https://api.example.com'; + if (name.includes('models')) return 'model-a, model-b'; + + return parameter.name + .replace(/_/g, ' ') + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); + }; + + // helper for custom labels + const getFieldLabel = (parameter: ConfigKey): string => { + const name = parameter.name.toLowerCase(); + if (name.includes('api_key')) return 'API Key'; + if (name.includes('api_url') || name.includes('host')) return 'API Host'; + if (name.includes('models')) return 'Models'; + + return parameter.name + .replace(/_/g, ' ') + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); }; if (isLoading) { @@ -111,25 +133,30 @@ export default function DefaultProviderSetupForm({ requiredParameters.map((parameter) => (
+ onChange={(e: React.ChangeEvent) => { + console.log(`Setting ${parameter.name} to:`, e.target.value); setConfigValues((prev) => ({ ...prev, [parameter.name]: e.target.value, - })) - } + })); + }} placeholder={getPlaceholder(parameter)} className={`w-full h-14 px-4 font-regular rounded-lg shadow-none ${ validationErrors[parameter.name] ? 'border-2 border-red-500' : 'border border-borderSubtle hover:border-borderStandard' } bg-background-default text-lg placeholder:text-textSubtle font-regular text-textStandard`} - required={true} + required={parameter.required} /> + {validationErrors[parameter.name] && ( +

{validationErrors[parameter.name]}

+ )}
)) )} diff --git a/ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx b/ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx index 5a6e02e9e496..d789b328dec8 100644 --- a/ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx +++ b/ui/desktop/src/components/settings/providers/subcomponents/CardContainer.tsx @@ -6,6 +6,7 @@ interface CardContainerProps { onClick: () => void; grayedOut: boolean; testId?: string; + borderStyle?: 'solid' | 'dashed'; } function GlowingRing() { @@ -33,6 +34,7 @@ export default function CardContainer({ onClick, grayedOut = false, testId, + borderStyle = 'solid', }: CardContainerProps) { return (
{!grayedOut && }
- {/* Apply opacity only to the header when grayed out */} -
- {header} -
+ {header && ( +
+ {header} +
+ )} - {/* Body always at full opacity */}
{body}
diff --git a/ui/desktop/src/components/ui/button.tsx b/ui/desktop/src/components/ui/button.tsx index 04cc74cb6298..ff2516a83d21 100644 --- a/ui/desktop/src/components/ui/button.tsx +++ b/ui/desktop/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '../../utils'; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[1px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 5f7bd90483d5..ef560a372276 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,5 +1,5 @@ import { Message } from './types/message'; -import { getSessionHistory, listSessions, SessionInfo } from './api'; +import { getSessionHistory, listSessions, SessionInfo, Message as ApiMessage } from './api'; import { convertApiMessageToFrontendMessage } from './components/context_management'; import { getApiUrl } from './config'; @@ -118,7 +118,7 @@ export async function fetchSessionDetails(sessionId: string): Promise + messages: response.data.messages.map((message: ApiMessage) => convertApiMessageToFrontendMessage(message, true, true) ), // slight diffs between backend and frontend Message obj };