diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 5c5b8bbb2f47..d67efa131ef6 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -3,7 +3,7 @@ use crate::routes::utils::check_provider_configured; use crate::state::AppState; use axum::{ extract::{Path, State}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Json, Router, }; use etcetera::{choose_app_strategy, AppStrategy}; @@ -87,6 +87,15 @@ pub struct CreateCustomProviderRequest { pub supports_streaming: Option, } +#[derive(Deserialize, Serialize, ToSchema)] +pub struct UpdateCustomProviderRequest { + pub display_name: Option, + pub api_url: Option, + pub api_key: Option, + pub models: Option>, + pub supports_streaming: Option, +} + #[utoipa::path( post, path = "/config/upsert", @@ -104,6 +113,18 @@ pub async fn upsert_config( verify_secret_key(&headers, &state)?; let config = Config::global(); + + // Defensive guard: if this is a secret check and the client sent a boolean 'true' to + // indicate presence, do not write that boolean into the secret storage. Treat as a no-op. + if query.is_secret { + if let Value::Bool(b) = &query.value { + if *b { + tracing::info!(key = %query.key, "Skipping upsert for secret key with boolean true (presence-only)"); + return Ok(Json(Value::String(format!("Skipped secret upsert for {} (presence-only)", query.key)))); + } + } + } + let result = config.set(&query.key, query.value, query.is_secret); match result { @@ -316,64 +337,7 @@ pub async fn providers( ) -> Result>, StatusCode> { verify_secret_key(&headers, &state)?; - 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 + let providers_response: Vec = get_providers() .into_iter() .map(|metadata| { let is_configured = check_provider_configured(&metadata); @@ -766,6 +730,8 @@ pub async fn get_current_model( #[utoipa::path( post, + + path = "/config/custom-providers", request_body = CreateCustomProviderRequest, responses( @@ -798,6 +764,81 @@ pub async fn create_custom_provider( Ok(Json(format!("Custom provider added - ID: {}", config.id()))) } +#[utoipa::path( + put, + path = "/config/custom-providers/{id}", + request_body = UpdateCustomProviderRequest, + responses( + (status = 200, description = "Custom provider updated successfully", body = String), + (status = 400, description = "Invalid request"), + (status = 404, description = "Provider not found"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn update_custom_provider( + State(state): State>, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, + Json(request): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + + // Log incoming update for debugging + let payload_value = serde_json::to_value(&request).unwrap_or(serde_json::Value::Null); + tracing::info!(id = %id, payload = ?payload_value, "update_custom_provider called"); + + let custom_providers_dir = goose::config::custom_providers::custom_providers_dir(); + let file_path = custom_providers_dir.join(format!("{}.json", id)); + + if !file_path.exists() { + return Err(StatusCode::NOT_FOUND); + } + + // Read existing config + let content = std::fs::read_to_string(&file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mut config: goose::config::custom_providers::CustomProviderConfig = + serde_json::from_str(&content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Update fields if provided + if let Some(display_name) = request.display_name { + config.display_name = display_name; + } + if let Some(api_url) = request.api_url { + config.base_url = api_url; + } + if let Some(models) = request.models { + config.models = models + .into_iter() + .map(|m| goose::providers::base::ModelInfo::new(m, 128000)) + .collect(); + } + if let Some(s) = request.supports_streaming { + config.supports_streaming = Some(s); + } + + // Persist API key into secrets if provided + if let Some(api_key) = request.api_key { + let cfg = goose::config::Config::global(); + if let Err(e) = cfg.set_secret(&config.api_key_env, serde_json::Value::String(api_key)) { + tracing::error!("Failed to set secret for {}: {}", config.api_key_env, e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + // Save updated JSON atomically + let tmp = file_path.with_extension("json.tmp"); + let json_content = serde_json::to_string_pretty(&config).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + std::fs::write(&tmp, &json_content).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + std::fs::rename(&tmp, &file_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + // Refresh in-memory providers + if let Err(e) = goose::providers::refresh_custom_providers() { + tracing::warn!("Failed to refresh custom providers after update: {}", e); + } + + Ok(Json(format!("Updated custom provider: {}", id))) +} + #[utoipa::path( delete, path = "/config/custom-providers/{id}", @@ -845,7 +886,7 @@ pub fn routes(state: Arc) -> Router { .route("/config/custom-providers", post(create_custom_provider)) .route( "/config/custom-providers/{id}", - delete(remove_custom_provider), + delete(remove_custom_provider).put(update_custom_provider), ) .with_state(state) } diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index a11ce3134501..0af8a727b859 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -265,14 +265,17 @@ async fn reply_handler( Ok(stream) => stream, Err(e) => { tracing::error!("Failed to start reply stream: {:?}", e); - stream_event( - MessageEvent::Error { - error: e.to_string(), - }, + let err_text = e.to_string(); + // send Error event (for telemetry / UI error handling) + let _ = stream_event( + MessageEvent::Error { error: err_text.clone() }, &task_tx, &cancel_token, ) .await; + // also send a visible assistant message so the UI shows it inline + let assistant_msg = Message::assistant().with_text(format!("Provider error: {}", err_text)); + let _ = stream_event(MessageEvent::Message { message: assistant_msg }, &task_tx, &cancel_token).await; return; } }; @@ -312,8 +315,32 @@ async fn reply_handler( track_tool_telemetry(content, all_messages.messages()); } + // Push and send the message event all_messages.push(message.clone()); - stream_event(MessageEvent::Message { message }, &tx, &cancel_token).await; + stream_event(MessageEvent::Message { message: message.clone() }, &tx, &cancel_token).await; + + // If this message appears to be a provider streaming error produced by + // the OpenAI-format streaming handler, surface an Error event as + // well so the frontend's error handling path runs and the UI + // visibly shows the failure. We detect this by looking for the + // distinctive prefix we emit when streaming errors are encountered. + if let Some(first_text) = message + .content + .iter() + .find_map(|c| match c { + goose::conversation::message::MessageContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .filter(|s| s.starts_with("LLM streaming error encountered")) + { + // Send a short error string (avoid flooding the SSE with huge payloads) + let short = if first_text.len() > 1024 { + format!("{}...", &first_text[..1024]) + } else { + first_text + }; + let _ = stream_event(MessageEvent::Error { error: short }, &tx, &cancel_token).await; + } } Ok(Some(Ok(AgentEvent::HistoryReplaced(new_messages)))) => { // Replace the message history with the compacted messages diff --git a/crates/goose/src/config/custom_providers.rs b/crates/goose/src/config/custom_providers.rs index 3110771a185e..250a78b3c7e0 100644 --- a/crates/goose/src/config/custom_providers.rs +++ b/crates/goose/src/config/custom_providers.rs @@ -148,8 +148,37 @@ pub fn register_custom_providers( ) -> Result<()> { let configs = load_custom_providers(dir)?; + // Detect legacy shared key usage in keyring/config that could override + // per-provider base URLs. Historically a single key name + // "CUSTOM_PROVIDER_BASE_URL" was used for all providers which could + // result in cross-provider collisions if stored in the keyring. If that + // legacy key exists, log a warning so operators can remove/migrate it. + let global_config = crate::config::Config::global(); + if let Ok(val) = global_config.get_secret::("CUSTOM_PROVIDER_BASE_URL") { + tracing::warn!("Detected legacy shared key 'CUSTOM_PROVIDER_BASE_URL' in secret storage. This can cause custom providers to use the wrong base_url. Value: {}.", val); + // Attempt to remove the legacy shared key from secret storage. This is + // a best-effort cleanup to prevent custom providers from picking up a + // wrong global base_url. If deletion fails, log the error and continue + // without panicking. + match global_config.delete_secret("CUSTOM_PROVIDER_BASE_URL") { + Ok(_) => tracing::info!( + "Removed legacy secret key 'CUSTOM_PROVIDER_BASE_URL' from secret storage." + ), + Err(e) => tracing::error!( + "Failed to remove legacy secret key 'CUSTOM_PROVIDER_BASE_URL': {}", + e + ), + } + } + for config in configs { let config_clone = config.clone(); + // Use a unique base URL key per custom provider to avoid collisions in the + // global config/keyring. Previously this used the constant + // "CUSTOM_PROVIDER_BASE_URL" for every provider which caused different + // providers to read/write the same key and mix up values stored in the + // keyring or config file. + let base_url_key = format!("{}_BASE_URL", config.name.to_uppercase()); let description = config .description .clone() @@ -174,36 +203,66 @@ pub fn register_custom_providers( match config.engine { ProviderEngine::OpenAI => { + let config_keys = vec![ + crate::providers::base::ConfigKey::new(&config.api_key_env, true, true, None), + crate::providers::base::ConfigKey::new( + &base_url_key, + true, + false, + Some(&config.base_url), + ), + ]; registry.register_with_name::( config.name.clone(), config.display_name.clone(), description, default_model, known_models, + config_keys, move |model: ModelConfig| { OpenAiProvider::from_custom_config(model, config_clone.clone()) }, ); } ProviderEngine::Ollama => { + let config_keys = vec![ + crate::providers::base::ConfigKey::new(&config.api_key_env, true, true, None), + crate::providers::base::ConfigKey::new( + &base_url_key, + true, + false, + Some(&config.base_url), + ), + ]; registry.register_with_name::( config.name.clone(), config.display_name.clone(), description, default_model, known_models, + config_keys, move |model: ModelConfig| { OllamaProvider::from_custom_config(model, config_clone.clone()) }, ); } ProviderEngine::Anthropic => { + let config_keys = vec![ + crate::providers::base::ConfigKey::new(&config.api_key_env, true, true, None), + crate::providers::base::ConfigKey::new( + &base_url_key, + true, + false, + Some(&config.base_url), + ), + ]; registry.register_with_name::( config.name.clone(), config.display_name.clone(), description, default_model, known_models, + config_keys, move |model: ModelConfig| { AnthropicProvider::from_custom_config(model, config_clone.clone()) }, diff --git a/crates/goose/src/providers/api_client.rs b/crates/goose/src/providers/api_client.rs index 821148bae757..20c5f06b8654 100644 --- a/crates/goose/src/providers/api_client.rs +++ b/crates/goose/src/providers/api_client.rs @@ -184,9 +184,39 @@ impl fmt::Debug for AuthMethod { } impl ApiResponse { - pub async fn from_response(response: Response) -> Result { + /// Parse a `reqwest::Response` into an ApiResponse. Optionally accepts the + /// original request payload (as JSON string) and a provider hint so we can + /// log all three (request payload, URL and response body) when an error + /// occurs. This helps debugging when the desktop auto-starts the server + /// and you cannot enable debug logging easily. + pub async fn from_response( + response: Response, + request_payload: Option<&str>, + provider_hint: Option<&str>, + ) -> Result { let status = response.status(); - let payload = response.json().await.ok(); + + // Capture URL and raw body text for debugging when there is an error + let url = response.url().clone(); + let response = response; + let text_body = response.text().await.ok(); + + if !status.is_success() { + tracing::error!( + %url, + status = %status, + provider = %provider_hint.unwrap_or(""), + request = %request_payload.unwrap_or(""), + response = %text_body.as_deref().unwrap_or(""), + "LLM_RESPONSE_ERROR" + ); + } + + // If the body is valid JSON, parse it into payload; otherwise use None + let payload = text_body + .as_deref() + .and_then(|t| serde_json::from_str::(t).ok()); + Ok(Self { status, payload }) } } @@ -305,9 +335,20 @@ impl ApiClient { base_url.set_path(&format!("{}/", base_path)); } - base_url + // Join the requested path onto the base URL. + let mut joined = base_url .join(path) - .map_err(|e| anyhow::anyhow!("Failed to construct URL: {}", e)) + .map_err(|e| anyhow::anyhow!("Failed to construct URL: {}", e))?; + + // If the caller's path does not explicitly end with a slash, ensure we + // do not introduce a trailing slash on the final URL. Some remote APIs + // treat a trailing slash as a different endpoint and return 404. + if !path.ends_with('/') { + let url_str = joined.as_str().trim_end_matches('/').to_string(); + joined = Url::parse(&url_str).map_err(|e| anyhow::anyhow!("Failed to normalize URL: {}", e))?; + } + + Ok(joined) } async fn get_oauth_token(&self, config: &OAuthConfig) -> Result { @@ -336,24 +377,40 @@ impl<'a> ApiRequestBuilder<'a> { } pub async fn api_post(self, payload: &Value) -> Result { + let payload_str = serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string()); + let provider_hint = self.client.host.as_str(); let response = self.response_post(payload).await?; - ApiResponse::from_response(response).await + ApiResponse::from_response(response, Some(&payload_str), Some(provider_hint)).await } pub async fn response_post(self, payload: &Value) -> Result { - // Log the JSON payload being sent to the LLM - tracing::debug!( - "LLM_REQUEST: {}", - serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string()) - ); - - let request = self.send_request(|url, client| client.post(url)).await?; - Ok(request.json(payload).send().await?) + // Log the JSON payload being sent to the LLM and the final URL being called + let payload_str = serde_json::to_string(payload).unwrap_or_else(|_| "{}".to_string()); + tracing::debug!(payload = %payload_str, path = %self.path, "LLM_REQUEST"); + + let request_builder = self.send_request(|url, client| client.post(url)).await?; + // For additional visibility, attempt to extract the final URL from the builder + // Note: reqwest RequestBuilder does not expose final URL before send, so we build the + // request to get the URL for logging. This consumes the builder into a Request. + match request_builder.try_clone() { + Some(rb) => { + if let Ok(req) = rb.json(payload).build() { + tracing::debug!(url = %req.url(), "LLM_OUTGOING_URL"); + } + } + None => { + tracing::debug!(path = %self.path, "LLM_OUTGOING_PATH_NO_CLONE"); + } + } + + Ok(request_builder.json(payload).send().await?) } pub async fn api_get(self) -> Result { + // GET doesn't always have a payload, but keep consistency + let provider_hint = self.client.host.as_str(); let response = self.response_get().await?; - ApiResponse::from_response(response).await + ApiResponse::from_response(response, None, Some(provider_hint)).await } pub async fn response_get(self) -> Result { diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index 3ff4b712a867..264354ae5bc1 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -36,6 +36,10 @@ struct Delta { content: Option, role: Option, tool_calls: Option>, + // Some providers put a 'name' (tool name) here instead of in tool_calls + name: Option, + // OpenAI/variants may include a 'refusal' field with refusal details + refusal: Option, } #[derive(Serialize, Deserialize, Debug)] @@ -413,7 +417,20 @@ fn ensure_valid_json_schema(schema: &mut Value) { } fn strip_data_prefix(line: &str) -> Option<&str> { - line.strip_prefix("data: ").map(|s| s.trim()) + let s = line.trim(); + // SSE style: "data: {json}" + if let Some(rest) = s.strip_prefix("data: ") { + return Some(rest.trim()); + } + // Raw NDJSON/JSON per-line: "{...}" or "[...]" + if s.starts_with('{') || s.starts_with('[') { + return Some(s); + } + // Some providers send the done sentinel without the data: prefix + if s == "[DONE]" { + return Some(s); + } + None } pub fn response_to_streaming_message( @@ -426,19 +443,63 @@ where use futures::StreamExt; 'outer: while let Some(response) = stream.next().await { - if response.as_ref().is_ok_and(|s| s == "data: [DONE]") { + // Accept both SSE style "data: [DONE]" and plain "[DONE]" sentinel + if response.as_ref().is_ok_and(|s| s.trim() == "data: [DONE]" || s.trim() == "[DONE]") { break 'outer; } let response_str = response?; + tracing::debug!(raw_stream_line = %response_str, "LLM_STREAM_RAW_LINE"); let line = strip_data_prefix(&response_str); if line.is_none() || line.is_some_and(|l| l.is_empty()) { continue } - let chunk: StreamingChunk = serde_json::from_str(line - .ok_or_else(|| anyhow!("unexpected stream format"))?) - .map_err(|e| anyhow!("Failed to parse streaming chunk: {}: {:?}", e, &line))?; + let line_str = line + .ok_or_else(|| anyhow!("unexpected stream format"))?; + + // First parse into a generic JSON value so we can detect provider-specific + // error objects (some OpenAI-compatible endpoints emit {"object":"error",...}). + let v: Value = serde_json::from_str(line_str) + .map_err(|e| anyhow!("Failed to parse streaming JSON value: {}: {:?}", e, &line_str))?; + + // Detect top-level error objects and yield a structured error message so the UI + // can render provider errors consistently during streaming. Previously we returned + // Err(...) which terminated the stream without providing a message object to the + // frontend; that made debugging custom providers difficult because the UI had no + // provider response to display. + if v.get("object").and_then(|o| o.as_str()).map(|s| s == "error").unwrap_or(false) + || v.get("error").is_some() + { + // Build a helpful text representation including status-like fields when available + let mut details = String::new(); + // Attempt to include an explicit "message" field if present + if let Some(m) = v.get("message") { + details.push_str(&format!("message: {}\n", m)); + } + // Include any nested error object + if let Some(err_obj) = v.get("error") { + details.push_str(&format!("error: {}\n", err_obj)); + } + // Include the full JSON payload as a last resort + details.push_str(&format!("raw: {}", v)); + + // Construct a Message that contains the error details so the UI can display it. + // Use assistant role so it is shown where other assistant output appears, but + // include the error text so renderers can style it as an error. + let mut msg_text = format!("LLM streaming error encountered. See details below:\n{}", details); + + let message = Message::assistant().with_content(MessageContent::text(msg_text)); + + // Yield the error message along with any usage info collected so far. + yield (Some(message), None); + + // After yielding the error message, stop processing the stream. + break 'outer; + } + + let chunk: StreamingChunk = serde_json::from_value(v.clone()) + .map_err(|e| anyhow!("Failed to parse streaming chunk: {}: {:?}", e, &line_str))?; let usage = chunk.usage.as_ref().and_then(|u| { chunk.model.as_ref().map(|model| { diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 3f493dcd8f6e..b7a2ba6fa225 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -278,10 +278,13 @@ impl Provider for OpenAiProvider { let mut payload = create_request(&self.model, system, messages, tools, &ImageFormat::OpenAi)?; payload["stream"] = serde_json::Value::Bool(true); + + /* TODO Carine Bruyndoncx Disabled for hyperbolic test payload["stream_options"] = json!({ "include_usage": true, }); - + */ + let response = self .api_client .response_post(&self.base_path, &payload) diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index e20bc98dd106..d8dd6b0bf57c 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -48,6 +48,7 @@ impl ProviderRegistry { description: String, default_model: String, known_models: Vec, + config_keys: Vec, constructor: F, ) where P: Provider + 'static, @@ -61,7 +62,7 @@ impl ProviderRegistry { default_model, known_models, model_doc_link: base_metadata.model_doc_link, - config_keys: base_metadata.config_keys, + config_keys, }; self.entries.insert( diff --git a/crates/goose/src/providers/utils.rs b/crates/goose/src/providers/utils.rs index e5dec1b72a47..921954ad2fd7 100644 --- a/crates/goose/src/providers/utils.rs +++ b/crates/goose/src/providers/utils.rs @@ -86,12 +86,32 @@ pub fn map_http_error_to_provider_error( if check_context_length_exceeded(&payload_str) { ProviderError::ContextLengthExceeded(payload_str) } else { + // Try multiple ways to extract a useful message + // 1. OpenAI style: {"error": {"message": "..."}} if let Some(error) = payload.get("error") { - error_msg = error - .get("message") - .and_then(|m| m.as_str()) - .unwrap_or("Unknown error") - .to_string(); + if let Some(m) = error.get("message").and_then(|m| m.as_str()) { + error_msg = m.to_string(); + } else if let Some(m) = error.as_str() { + error_msg = m.to_string(); + } + } + // 2. Top-level "message" field + if error_msg == "Unknown error" { + if let Some(m) = payload.get("message").and_then(|m| m.as_str()) { + error_msg = m.to_string(); + } + } + // 3. Other common fields + if error_msg == "Unknown error" { + if let Some(m) = payload.get("error_message").and_then(|m| m.as_str()) { + error_msg = m.to_string(); + } else if let Some(m) = payload.get("detail").and_then(|m| m.as_str()) { + error_msg = m.to_string(); + } + } + // 4. If still unknown, include the whole payload for debugging + if error_msg == "Unknown error" { + error_msg = payload_str; } ProviderError::RequestFailed(format!( "Request failed with status: {}. Message: {}", @@ -166,6 +186,15 @@ pub async fn handle_status_openai_compat(response: Response) -> Result(&body_str).ok(); + // attempt to parse top-level message fields for non-OpenAI error shapes + if let Ok(payload_json) = serde_json::from_str::(&body_str) { + if let Some(m) = payload_json.get("message").and_then(|m| m.as_str()) { + // Surface top-level "message" errors (some providers use this shape) + return Err(ProviderError::RequestFailed(format!("{} (status {})", m, status.as_u16()))); + } + } + + Err(map_http_error_to_provider_error(status, payload)) } diff --git a/ui/desktop/src/bin/.gitkeep b/ui/desktop/src/bin/.gitkeep deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/ui/desktop/src/bin/jbang b/ui/desktop/src/bin/jbang deleted file mode 100755 index 8748b44db8c3..000000000000 --- a/ui/desktop/src/bin/jbang +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -# Enable strict mode to exit on errors and unset variables -set -euo pipefail - -# Set log file -LOG_FILE="/tmp/mcp.log" - -# Clear the log file at the start -> "$LOG_FILE" - -# Function for logging -log() { - local MESSAGE="$1" - echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" -} - -# Trap errors and log them before exiting -trap 'log "An error occurred. Exiting with status $?."' ERR - -log "Starting jbang setup script." - -# Ensure ~/.config/goose/mcp-hermit/bin exists -log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p ~/.config/goose/mcp-hermit/bin - -# Change to the ~/.config/goose/mcp-hermit directory -log "Changing to directory ~/.config/goose/mcp-hermit." -cd ~/.config/goose/mcp-hermit - -# Check if hermit binary exists and download if not -if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then - log "Hermit binary not found. Downloading hermit binary." - curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit - log "Hermit binary downloaded and made executable." -else - log "Hermit binary already exists. Skipping download." -fi - -log "setting hermit cache to be local for MCP servers" -mkdir -p ~/.config/goose/mcp-hermit/cache -export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache - -# Update PATH -export PATH=~/.config/goose/mcp-hermit/bin:$PATH -log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." - -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" - -# Install OpenJDK using hermit -log "Installing OpenJDK with hermit." -hermit install openjdk@17 >> "$LOG_FILE" - -# Download and install jbang if not present -if [ ! -f ~/.config/goose/mcp-hermit/bin/jbang ]; then - log "Downloading and installing jbang." - curl -Ls https://sh.jbang.dev | bash -s - app setup - cp ~/.jbang/bin/jbang ~/.config/goose/mcp-hermit/bin/ -fi - -# Verify installations -log "Verifying installation locations:" -log "hermit: $(which hermit)" -log "java: $(which java)" -log "jbang: $(which jbang)" - -# Check for custom registry settings -log "Checking for GOOSE_JBANG_REGISTRY environment variable for custom jbang registry setup..." -if [ -n "${GOOSE_JBANG_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_JBANG_REGISTRY" > /dev/null; then - log "Checking custom goose registry availability: $GOOSE_JBANG_REGISTRY" - log "$GOOSE_JBANG_REGISTRY is accessible. Setting it as JBANG_REPO." - export JBANG_REPO="$GOOSE_JBANG_REGISTRY" -else - log "GOOSE_JBANG_REGISTRY is not set or not accessible. Using default jbang repository." -fi - -# Trust all jbang scripts that a user might install. Without this, Jbang will attempt to -# prompt the user to trust each script. However, Goose does not surfact this modal and without -# user input, the addExtension method will timeout and fail. -jbang --quiet trust add * - -# Final step: Execute jbang with passed arguments, always including --fresh and --quiet -log "Executing 'jbang' command with arguments: $*" -jbang --fresh --quiet "$@" || log "Failed to execute 'jbang' with arguments: $*" - -log "jbang setup script completed successfully." \ No newline at end of file diff --git a/ui/desktop/src/bin/libgcc_s_seh-1.dll b/ui/desktop/src/bin/libgcc_s_seh-1.dll new file mode 100755 index 000000000000..2a858d4fc706 Binary files /dev/null and b/ui/desktop/src/bin/libgcc_s_seh-1.dll differ diff --git a/ui/desktop/src/bin/libstdc++-6.dll b/ui/desktop/src/bin/libstdc++-6.dll new file mode 100755 index 000000000000..e6c135f72b30 Binary files /dev/null and b/ui/desktop/src/bin/libstdc++-6.dll differ diff --git a/ui/desktop/src/bin/libwinpthread-1.dll b/ui/desktop/src/bin/libwinpthread-1.dll new file mode 100755 index 000000000000..d87e5318690c Binary files /dev/null and b/ui/desktop/src/bin/libwinpthread-1.dll differ diff --git a/ui/desktop/src/bin/npx b/ui/desktop/src/bin/npx deleted file mode 100755 index 92c361e55c65..000000000000 --- a/ui/desktop/src/bin/npx +++ /dev/null @@ -1,105 +0,0 @@ -#!/bin/bash - -# Enable strict mode to exit on errors and unset variables -set -euo pipefail - -# Set log file -LOG_FILE="/tmp/mcp.log" - -# Clear the log file at the start -> "$LOG_FILE" - -# Function for logging -log() { - local MESSAGE="$1" - echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" >&2 -} - -# Trap errors and log them before exiting -trap 'log "An error occurred. Exiting with status $?."' ERR - -log "Starting npx setup script." - -# Ensure ~/.config/goose/mcp-hermit/bin exists -log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p ~/.config/goose/mcp-hermit/bin - -# Change to the ~/.config/goose/mcp-hermit directory -log "Changing to directory ~/.config/goose/mcp-hermit." -cd ~/.config/goose/mcp-hermit - - -# Check if hermit binary exists and download if not -if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then - log "Hermit binary not found. Downloading hermit binary." - curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit - log "Hermit binary downloaded and made executable." -else - log "Hermit binary already exists. Skipping download." -fi - - -log "setting hermit cache to be local for MCP servers" -mkdir -p ~/.config/goose/mcp-hermit/cache -export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache - - -# Update PATH -export PATH=~/.config/goose/mcp-hermit/bin:$PATH -log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." - - -# Verify hermit installation -log "Checking for hermit in PATH." -which hermit >> "$LOG_FILE" - -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" - -# Install Node.js using hermit -log "Installing Node.js with hermit." -hermit install node >> "$LOG_FILE" - -# Verify installations -log "Verifying installation locations:" -log "hermit: $(which hermit)" -log "node: $(which node)" -log "npx: $(which npx)" - - -log "Checking for GOOSE_NPM_REGISTRY and GOOSE_NPM_CERT environment variables for custom npm registry setup..." -# Check if GOOSE_NPM_REGISTRY is set and accessible -if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_NPM_REGISTRY" > /dev/null; then - log "Checking custom goose registry availability: $GOOSE_NPM_REGISTRY" - log "$GOOSE_NPM_REGISTRY is accessible. Using it for npm registry." - export NPM_CONFIG_REGISTRY="$GOOSE_NPM_REGISTRY" - - # Check if GOOSE_NPM_CERT is set and accessible - if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "$GOOSE_NPM_CERT" > /dev/null; then - log "Downloading certificate from: $GOOSE_NPM_CERT" - curl -sSL -o ~/.config/goose/mcp-hermit/cert.pem "$GOOSE_NPM_CERT" - if [ $? -eq 0 ]; then - log "Certificate downloaded successfully." - export NODE_EXTRA_CA_CERTS=~/.config/goose/mcp-hermit/cert.pem - else - log "Unable to download the certificate. Skipping certificate setup." - fi - else - log "GOOSE_NPM_CERT is either not set or not accessible. Skipping certificate setup." - fi - -else - log "GOOSE_NPM_REGISTRY is either not set or not accessible. Falling back to default npm registry." - export NPM_CONFIG_REGISTRY="https://registry.npmjs.org/" -fi - - - - -# Final step: Execute npx with passed arguments -log "Executing 'npx' command with arguments: $*" -npx "$@" || log "Failed to execute 'npx' with arguments: $*" - -log "npx setup script completed successfully." diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx deleted file mode 100755 index b0f1bdbed643..000000000000 --- a/ui/desktop/src/bin/uvx +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -# Enable strict mode to exit on errors and unset variables -set -euo pipefail - -# Set log file -LOG_FILE="/tmp/mcp.log" - -# Clear the log file at the start -> "$LOG_FILE" - -# Function for logging -log() { - local MESSAGE="$1" - echo "$(date +'%Y-%m-%d %H:%M:%S') - $MESSAGE" | tee -a "$LOG_FILE" >&2 -} - -# Trap errors and log them before exiting -trap 'log "An error occurred. Exiting with status $?."' ERR - -log "Starting uvx setup script." - -# Ensure ~/.config/goose/mcp-hermit/bin exists -log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p ~/.config/goose/mcp-hermit/bin - -# Change to the ~/.config/goose/mcp-hermit directory -log "Changing to directory ~/.config/goose/mcp-hermit." -cd ~/.config/goose/mcp-hermit - -# Check if hermit binary exists and download if not -if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then - log "Hermit binary not found. Downloading hermit binary." - curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit - log "Hermit binary downloaded and made executable." -else - log "Hermit binary already exists. Skipping download." -fi - - -log "setting hermit cache to be local for MCP servers" -mkdir -p ~/.config/goose/mcp-hermit/cache -export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache - -# Update PATH -export PATH=~/.config/goose/mcp-hermit/bin:$PATH -log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." - - -# Verify hermit installation -log "Checking for hermit in PATH." -which hermit >> "$LOG_FILE" - -# Initialize hermit -log "Initializing hermit." -hermit init >> "$LOG_FILE" - -# Initialize python >= 3.10 -log "hermit install python 3.10" -hermit install python3@3.10 >> "$LOG_FILE" - -# Install UV for python using hermit -log "Installing UV with hermit." -hermit install uv >> "$LOG_FILE" - -# Verify installations -log "Verifying installation locations:" -log "hermit: $(which hermit)" -log "uv: $(which uv)" -log "uvx: $(which uvx)" - - -log "Checking for GOOSE_UV_REGISTRY environment variable for custom python/pip/UV registry setup..." -# Check if GOOSE_UV_REGISTRY is set and accessible -if [ -n "${GOOSE_UV_REGISTRY:-}" ] && curl -s --head --fail "$GOOSE_UV_REGISTRY" > /dev/null; then - log "Checking custom goose registry availability: $GOOSE_UV_REGISTRY" - log "$GOOSE_UV_REGISTRY is accessible, setting it as UV_INDEX_URL. Setting UV_NATIVE_TLS to true." - export UV_INDEX_URL="$GOOSE_UV_REGISTRY" - export UV_NATIVE_TLS=true -else - log "Neither GOOSE_UV_REGISTRY nor UV_INDEX_URL is set. Falling back to default configuration." -fi - -# Final step: Execute uvx with passed arguments -log "Executing 'uvx' command with arguments: $*" -uvx "$@" || log "Failed to execute 'uvx' with arguments: $*" - -log "uvx setup script completed successfully." diff --git a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx index 661342782ae9..db8630f12522 100644 --- a/ui/desktop/src/components/settings/providers/ProviderGrid.tsx +++ b/ui/desktop/src/components/settings/providers/ProviderGrid.tsx @@ -62,10 +62,10 @@ const ProviderCards = memo(function ProviderCards({ const configureProviderViaModal = useCallback( (provider: ProviderDetails) => { openModal(provider, { - onSubmit: () => { + onSubmit: async () => { // Only refresh if the function is provided if (refreshProviders) { - refreshProviders(); + await refreshProviders(); } }, onDelete: (_values: unknown) => { diff --git a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx index 58b0df67dc86..7421f44b8e5a 100644 --- a/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx +++ b/ui/desktop/src/components/settings/providers/modal/ProviderConfiguationModal.tsx @@ -1,4 +1,6 @@ import { useEffect, useState } from 'react'; +import { SECRET_PRESENT_SENTINEL } from '../../../../utils/secretConstants'; + import { Dialog, DialogContent, @@ -18,6 +20,7 @@ import OllamaForm from './subcomponents/forms/OllamaForm'; import { useConfig } from '../../../ConfigContext'; import { useModelAndProvider } from '../../../ModelAndProviderContext'; import { AlertTriangle } from 'lucide-react'; +import { toast } from 'react-toastify'; import { ConfigKey, removeCustomProvider } from '../../../../api'; interface FormValues { @@ -34,7 +37,7 @@ const customFormsMap: Record = { export default function ProviderConfigurationModal() { const [validationErrors, setValidationErrors] = useState>({}); - const { upsert, remove } = useConfig(); + const { upsert, remove, getProviders } = useConfig(); const { getCurrentModelAndProvider } = useModelAndProvider(); const { isOpen, currentProvider, modalProps, closeModal } = useProviderModal(); const [configValues, setConfigValues] = useState>({}); @@ -85,6 +88,9 @@ export default function ProviderConfigurationModal() { setValidationErrors({}); // Validation logic + + // Response body placeholder used for toast details + let responseBody: string = ''; const parameters = currentProvider.metadata.config_keys || []; const errors: Record = {}; @@ -107,13 +113,230 @@ export default function ProviderConfigurationModal() { } try { - // Wait for the submission to complete - await SubmitHandler(upsert, currentProvider, configValues); + // If this is a custom provider, call the server update endpoint which + // writes provider settings to the JSON file (not the global config.yaml). + const isCustomProvider = currentProvider.name.startsWith('custom_'); + if (isCustomProvider) { + // Build update payload by mapping known parameter names + type UpdatePayload = { + api_key?: string; + api_url?: string; + models?: string[]; + supports_streaming?: boolean; + display_name?: string; + }; + const payload: UpdatePayload = {}; + for (const param of currentProvider.metadata.config_keys || []) { + const value = configValues[param.name]; + if (value === undefined) continue; + const lower = param.name.toLowerCase(); + if (lower.includes('api_key')) { + // Don't send sentinel values that indicate "secret present" — only include + // the API key when the user explicitly provided/changed it. + if (value !== SECRET_PRESENT_SENTINEL) { + payload.api_key = String(value); + } + } else if ( + lower.includes('api_url') || + lower.includes('host') || + lower.includes('base_url') + ) { + payload.api_url = String(value); + } else if (lower.includes('models')) { + // accept comma-separated models or array + if (Array.isArray(value)) { + payload.models = value; + } else { + payload.models = String(value) + .split(',') + .map((m) => m.trim()) + .filter(Boolean); + } + } else if (param.name.toLowerCase().includes('supports_streaming')) { + payload.supports_streaming = String(value).toLowerCase() === 'true'; + } + } - // Close the modal before triggering refreshes to avoid UI issues - closeModal(); + // allow display_name override from a form field + if (configValues['display_name']) { + payload.display_name = String(configValues['display_name']); + } + + // Fallback: try to detect any explicit per-provider base url key like CUSTOM__BASE_URL + if (!payload.api_url) { + for (const k of Object.keys(configValues)) { + if (k.toUpperCase().endsWith('_BASE_URL') || k.toLowerCase().includes('base_url')) { + payload.api_url = String(configValues[k]); + break; + } + } + } + + // If payload is still empty, warn and return + if ( + !payload.api_url && + !payload.api_key && + !payload.models && + !payload.display_name && + payload.supports_streaming === undefined + ) { + console.warn( + '[ProviderConfiguationModal] nothing to update for custom provider', + currentProvider.name + ); + // Keep modal open to let user edit fields + return; + } - // Call onSubmit callback if provided (from modal props) + try { + const secretKey = await window.electron.getSecretKey(); + + // Determine server base URL. Prefer electron main-provided config, then appConfig, + // then the page origin if it is http(s), otherwise fallback to localhost default. + const electronCfg = + window.electron && window.electron.getConfig ? window.electron.getConfig() : null; + const hostCfg = electronCfg?.GOOSE_API_HOST ?? window.appConfig?.get('GOSE_API_HOST'); + const portCfg = electronCfg?.GOOSE_PORT ?? window.appConfig?.get('GOSE_PORT'); + + let base: string | null = null; + if (hostCfg) { + base = String(hostCfg); + if (!base.startsWith('http://') && !base.startsWith('https://')) { + base = `http://${base}`; + } + base = base.replace(/\/+$/g, ''); + } else if ( + window.location && + window.location.origin && + (window.location.origin.startsWith('http://') || + window.location.origin.startsWith('https://')) + ) { + base = window.location.origin.replace(/\/+$/g, ''); + } else { + // Last resort fallback (best-effort). This will often be correct in dev. + base = 'http://127.0.0.1:17123'; + } + + const url = portCfg + ? `${base}:${portCfg}/config/custom-providers/${currentProvider.name}` + : `${base}/config/custom-providers/${currentProvider.name}`; + console.info('[ProviderConfiguationModal] PUT', url, payload); + + // Response body placeholder (declared in outer scope so it can be used below) + let responseBody = ''; + + // Execute update request and capture response body for richer feedback + const res = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Secret-Key': secretKey, + }, + body: JSON.stringify(payload), + }); + + // Read the response text (may be empty or JSON) + try { + responseBody = await res.text(); + } catch { + // ignore + responseBody = ''; + } + + if (!res.ok) { + throw new Error(`Update failed: ${res.status} ${responseBody}`); + } + } catch (err) { + console.error('Failed to update custom provider:', err); + // Keep modal open + return; + } + + // Show a success toast with expandable details (include server response) and refresh providers + try { + let parsedResponse: unknown = responseBody; + try { + parsedResponse = responseBody ? JSON.parse(responseBody) : ''; + } catch (parseErr) { + // ignore parse errors + void parseErr; + } + + const detailsObj = { + request: payload, + response: parsedResponse, + }; + + const details = JSON.stringify(detailsObj, null, 2); + + // Create a toast and ensure it does not auto-close while the details are expanded. + // We capture the returned toast id and pause/resume the auto-close timer when the
+ // element is toggled. Also disable closeOnClick so clicking the summary doesn't dismiss it. + let toastId: number | string | undefined; + + type DetailsToggleEvent = React.SyntheticEvent & { currentTarget: { open?: boolean } }; + + const onDetailsToggle = (e: DetailsToggleEvent) => { + const isOpen = !!e.currentTarget?.open; + if (!toastId) return; + try { + if (isOpen) { + // Pause the toast auto-close while details are open + // Best-effort pause (may not exist on this runtime or in types) + (toast as unknown as { pause?: (id?: string | number) => void }).pause?.(toastId); + } else { + // Resume when collapsed + // Best-effort resume (may not exist on this runtime or in types) + (toast as unknown as { resume?: (id?: string | number) => void }).resume?.(toastId); + } + } catch (err) { + // best-effort; ignore if pause/resume unavailable + void err; + } + }; + + const content = ( +
+ Custom provider updated +
Changes were saved to the provider JSON.
+
+ Show details +
{details}
+
+
+ ); + + // Prevent clicking the details from closing the toast and give it a short autoClose by default + toastId = toast.success(content, { closeOnClick: false, autoClose: 5000 }); + } catch (err) { + console.warn('Failed to render toast with details:', err); + // Fallback: simple console log + console.info('Custom provider updated:', payload, 'response:', responseBody); + } + + // Prefer caller-provided refresh callback, but also refresh global provider list as a fallback + if (modalProps.onSubmit) { + try { + modalProps.onSubmit(configValues as FormValues); + } catch (e) { + console.warn('modalProps.onSubmit threw an error:', e); + } + } + + try { + // Force the shared provider list to refresh so any consumers update + await getProviders(true); + } catch (e) { + console.warn('Failed to refresh global providers after update:', e); + } + + closeModal(); + return; + } + + // Fallback for built-in providers: use existing submit handler + await SubmitHandler(upsert, currentProvider, configValues); + closeModal(); if (modalProps.onSubmit) { modalProps.onSubmit(configValues as FormValues); } 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 index 5392639b06f5..afc82f4ff050 100644 --- a/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx +++ b/ui/desktop/src/components/settings/providers/modal/subcomponents/forms/CustomProviderForm.tsx @@ -21,7 +21,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide const [providerType, setProviderType] = useState('openai_compatible'); const [displayName, setDisplayName] = useState(''); const [apiUrl, setApiUrl] = useState(''); - const [apiKey, setApiKey] = useState(''); + const [apiKey, setApiKey] = useState(''); // for new creation flow (no sentinel expected) const [models, setModels] = useState(''); const [isLocalModel, setIsLocalModel] = useState(false); const [supportsStreaming, setSupportsStreaming] = useState(true); 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 6f8e2dd69476..46f58b1e0849 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 @@ -1,4 +1,7 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react'; + +import { SECRET_PRESENT_SENTINEL } from '../../../../../../utils/secretConstants'; + import { Input } from '../../../../../ui/input'; import { useConfig } from '../../../../../ConfigContext'; // Adjust this import path as needed import { ProviderDetails, ConfigKey } from '../../../../../../api'; @@ -10,6 +13,8 @@ interface DefaultProviderSetupFormProps { setConfigValues: React.Dispatch>>; provider: ProviderDetails; validationErrors: ValidationErrors; + // Optional callback invoked when a field is edited so parent can clear errors + onFieldChange?: (name: string, value: string) => void; } export default function DefaultProviderSetupForm({ @@ -17,6 +22,7 @@ export default function DefaultProviderSetupForm({ setConfigValues, provider, validationErrors = {}, + onFieldChange, }: DefaultProviderSetupFormProps) { const parameters = useMemo( () => provider.metadata.config_keys || [], @@ -25,65 +31,62 @@ export default function DefaultProviderSetupForm({ const [isLoading, setIsLoading] = useState(true); const { read } = useConfig(); - console.log('configValues default form', configValues); - // Initialize values when the component mounts or provider changes const loadConfigValues = useCallback(async () => { + // If there are no parameters, nothing to load + if (parameters.length === 0) { + setIsLoading(false); + return; + } + setIsLoading(true); - const newValues = { ...configValues }; - // Try to load actual values from config for each parameter that is not secret + // Collect responses per parameter without relying on current configValues + const responses: Record = {}; + for (const parameter of parameters) { 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); + responses[parameter.name] = parameter.secret + ? SECRET_PRESENT_SENTINEL + : String(configResponse); + } else if (parameter.default !== undefined && parameter.default !== null) { + responses[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); + if (parameter.default !== undefined && parameter.default !== null) { + responses[parameter.name] = String(parameter.default); } } } - // Update state with loaded values - setConfigValues((prev) => ({ - ...prev, - ...newValues, - })); + // Merge responses into state but do not overwrite user-entered values + setConfigValues((prev) => { + const merged = { ...prev }; + for (const k of Object.keys(responses)) { + if (merged[k] === undefined || merged[k] === null || merged[k] === '') { + merged[k] = responses[k]; + } + } + return merged; + }); + setIsLoading(false); - }, [configValues, parameters, read, setConfigValues]); + }, [parameters, read, setConfigValues]); + // Load configuration once on mount (modal open). Parent will remount the component + // when provider changes so a mount-time load is sufficient and avoids loops. useEffect(() => { loadConfigValues(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Filter parameters to only show required ones - const requiredParameters = useMemo(() => { - return parameters.filter((param) => param.required === true); - }, [parameters]); - - // TODO: show all params, not just required ones - // const allParameters = useMemo(() => { - // return parameters; - // }, [parameters]); + // Show all parameters (required and optional) + const visibleParameters = useMemo(() => parameters, [parameters]); // Helper function to generate appropriate placeholder text const getPlaceholder = (parameter: ConfigKey): string => { @@ -118,19 +121,30 @@ export default function DefaultProviderSetupForm({ .trim(); }; - if (isLoading) { - return
Loading configuration values...
; - } + const handleChange = (parameter: ConfigKey, value: string) => { + setConfigValues((prev) => ({ + ...prev, + [parameter.name]: value, + })); + + // Let parent clear any validation errors for this field and any submission error + if (onFieldChange) onFieldChange(parameter.name, value); + }; - console.log('required params', requiredParameters); return (
- {requiredParameters.length === 0 ? ( + {isLoading && ( +
+ Loading configuration values... +
+ )} + + {visibleParameters.length === 0 ? (
- No required configuration for this provider. + No configuration required for this provider.
) : ( - requiredParameters.map((parameter) => ( + visibleParameters.map((parameter) => (