diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 48c9c8c54883..7b7868551a54 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -5,7 +5,9 @@ use goose::agents::extension::ToolInfo; use goose::agents::extension_manager::get_parameter_names; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; -use goose::config::declarative_providers::{create_custom_provider, remove_custom_provider}; +use goose::config::declarative_providers::{ + create_custom_provider, remove_custom_provider, CreateCustomProviderParams, +}; use goose::config::extensions::{ get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name, name_to_key, remove_extension, set_extension, set_extension_enabled, @@ -1877,11 +1879,16 @@ fn add_provider() -> anyhow::Result<()> { }) .interact()?; - let api_key: String = cliclack::password("API key:") - .allow_empty() - .mask('▪') + let requires_auth = cliclack::confirm("Does this provider require authentication?") + .initial_value(true) .interact()?; + let api_key: String = if requires_auth { + cliclack::password("API key:").mask('▪').interact()? + } else { + String::new() + }; + let models_input: String = cliclack::input("Available models (separate with commas):") .placeholder("model-a, model-b, model-c") .validate(|input: &String| { @@ -1910,15 +1917,16 @@ fn add_provider() -> anyhow::Result<()> { None }; - create_custom_provider( - provider_type, - display_name.clone(), + create_custom_provider(CreateCustomProviderParams { + engine: provider_type.to_string(), + display_name: display_name.clone(), api_url, api_key, models, - Some(supports_streaming), + supports_streaming: Some(supports_streaming), headers, - )?; + requires_auth, + })?; cliclack::outro(format!("Custom provider added: {}", display_name))?; Ok(()) diff --git a/crates/goose-server/src/routes/config_management.rs b/crates/goose-server/src/routes/config_management.rs index 3dbc23d4f8fe..bc9172fa3ebe 100644 --- a/crates/goose-server/src/routes/config_management.rs +++ b/crates/goose-server/src/routes/config_management.rs @@ -95,6 +95,12 @@ pub struct UpdateCustomProviderRequest { pub models: Vec, pub supports_streaming: Option, pub headers: Option>, + #[serde(default = "default_requires_auth")] + pub requires_auth: bool, +} + +fn default_requires_auth() -> bool { + true } #[derive(Deserialize, ToSchema)] @@ -708,13 +714,16 @@ pub async fn create_custom_provider( Json(request): Json, ) -> Result, StatusCode> { let config = goose::config::declarative_providers::create_custom_provider( - &request.engine, - request.display_name, - request.api_url, - request.api_key, - request.models, - request.supports_streaming, - request.headers, + goose::config::declarative_providers::CreateCustomProviderParams { + engine: request.engine, + display_name: request.display_name, + api_url: request.api_url, + api_key: request.api_key, + models: request.models, + supports_streaming: request.supports_streaming, + headers: request.headers, + requires_auth: request.requires_auth, + }, ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -778,13 +787,17 @@ pub async fn update_custom_provider( Json(request): Json, ) -> Result, StatusCode> { goose::config::declarative_providers::update_custom_provider( - &id, - &request.engine, - request.display_name, - request.api_url, - request.api_key, - request.models, - request.supports_streaming, + goose::config::declarative_providers::UpdateCustomProviderParams { + id: id.clone(), + engine: request.engine, + display_name: request.display_name, + api_url: request.api_url, + api_key: request.api_key, + models: request.models, + supports_streaming: request.supports_streaming, + headers: request.headers, + requires_auth: request.requires_auth, + }, ) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/crates/goose-server/src/routes/utils.rs b/crates/goose-server/src/routes/utils.rs index 78a78151d893..713280b14add 100644 --- a/crates/goose-server/src/routes/utils.rs +++ b/crates/goose-server/src/routes/utils.rs @@ -96,9 +96,20 @@ pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: Pro if provider_type == ProviderType::Custom || provider_type == ProviderType::Declarative { if let Ok(loaded_provider) = load_provider(metadata.name.as_str()) { - return config - .get_secret::(&loaded_provider.config.api_key_env) - .is_ok(); + if !loaded_provider.config.requires_auth { + return true; + } + + if !loaded_provider.config.api_key_env.is_empty() { + let api_key_result = + config.get_secret::(&loaded_provider.config.api_key_env); + if api_key_result.is_ok() { + return true; + } + } + + // Custom providers with config files are intentionally created + return provider_type == ProviderType::Custom; } } diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 273612f949cf..6898eb5e4950 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -33,12 +33,19 @@ pub struct DeclarativeProviderConfig { pub engine: ProviderEngine, pub display_name: String, pub description: Option, + #[serde(default)] pub api_key_env: String, pub base_url: String, pub models: Vec, pub headers: Option>, pub timeout_seconds: Option, pub supports_streaming: Option, + #[serde(default = "default_requires_auth")] + pub requires_auth: bool, +} + +fn default_requires_auth() -> bool { + true } impl DeclarativeProviderConfig { @@ -85,42 +92,68 @@ pub fn generate_api_key_name(id: &str) -> String { format!("{}_API_KEY", id.to_uppercase()) } +#[derive(Debug, Clone)] +pub struct CreateCustomProviderParams { + pub engine: String, + pub display_name: String, + pub api_url: String, + pub api_key: String, + pub models: Vec, + pub supports_streaming: Option, + pub headers: Option>, + pub requires_auth: bool, +} + +#[derive(Debug, Clone)] +pub struct UpdateCustomProviderParams { + pub id: String, + pub engine: String, + pub display_name: String, + pub api_url: String, + pub api_key: String, + pub models: Vec, + pub supports_streaming: Option, + pub headers: Option>, + pub requires_auth: bool, +} + pub fn create_custom_provider( - engine: &str, - display_name: String, - api_url: String, - api_key: String, - models: Vec, - supports_streaming: Option, - headers: Option>, + params: CreateCustomProviderParams, ) -> Result { - let id = generate_id(&display_name); - let api_key_name = generate_api_key_name(&id); - - let config = Config::global(); - config.set_secret(&api_key_name, &api_key)?; + let id = generate_id(¶ms.display_name); + + let api_key_env = if params.requires_auth { + let api_key_name = generate_api_key_name(&id); + let config = Config::global(); + config.set_secret(&api_key_name, ¶ms.api_key)?; + api_key_name + } else { + String::new() + }; - let model_infos: Vec = models + let model_infos: Vec = params + .models .into_iter() .map(|name| ModelInfo::new(name, 128000)) .collect(); let provider_config = DeclarativeProviderConfig { name: id.clone(), - engine: match engine { + engine: match params.engine.as_str() { "openai_compatible" => ProviderEngine::OpenAI, "anthropic_compatible" => ProviderEngine::Anthropic, "ollama_compatible" => ProviderEngine::Ollama, - _ => return Err(anyhow::anyhow!("Invalid provider type: {}", engine)), + _ => return Err(anyhow::anyhow!("Invalid provider type: {}", params.engine)), }, - display_name: display_name.clone(), - description: Some(format!("Custom {} provider", display_name)), - api_key_env: api_key_name, - base_url: api_url, + display_name: params.display_name.clone(), + description: Some(format!("Custom {} provider", params.display_name)), + api_key_env, + base_url: params.api_url, models: model_infos, - headers, + headers: params.headers, timeout_seconds: None, - supports_streaming, + supports_streaming: params.supports_streaming, + requires_auth: params.requires_auth, }; let custom_providers_dir = custom_providers_dir(); @@ -133,49 +166,54 @@ pub fn create_custom_provider( Ok(provider_config) } -pub fn update_custom_provider( - id: &str, - provider_type: &str, - display_name: String, - api_url: String, - api_key: String, - models: Vec, - supports_streaming: Option, -) -> Result<()> { - let loaded_provider = load_provider(id)?; +pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> { + let loaded_provider = load_provider(¶ms.id)?; let existing_config = loaded_provider.config; let editable = loaded_provider.is_editable; let config = Config::global(); - if !api_key.is_empty() { - config.set_secret(&existing_config.api_key_env, &api_key)?; - } + + let api_key_env = if params.requires_auth { + let api_key_name = if existing_config.api_key_env.is_empty() { + generate_api_key_name(¶ms.id) + } else { + existing_config.api_key_env.clone() + }; + if !params.api_key.is_empty() { + config.set_secret(&api_key_name, ¶ms.api_key)?; + } + api_key_name + } else { + String::new() + }; if editable { - let model_infos: Vec = models + let model_infos: Vec = params + .models .into_iter() .map(|name| ModelInfo::new(name, 128000)) .collect(); let updated_config = DeclarativeProviderConfig { - name: id.to_string(), - engine: match provider_type { + name: params.id.clone(), + engine: match params.engine.as_str() { "openai_compatible" => ProviderEngine::OpenAI, "anthropic_compatible" => ProviderEngine::Anthropic, "ollama_compatible" => ProviderEngine::Ollama, - _ => return Err(anyhow::anyhow!("Invalid provider type: {}", provider_type)), + _ => return Err(anyhow::anyhow!("Invalid provider type: {}", params.engine)), }, - display_name, + display_name: params.display_name, description: existing_config.description, - api_key_env: existing_config.api_key_env, - base_url: api_url, + api_key_env, + base_url: params.api_url, models: model_infos, - headers: existing_config.headers, + headers: params.headers.or(existing_config.headers), timeout_seconds: existing_config.timeout_seconds, - supports_streaming, + supports_streaming: params.supports_streaming, + requires_auth: params.requires_auth, }; - let file_path = custom_providers_dir().join(format!("{}.json", id)); + let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name)); let json_content = serde_json::to_string_pretty(&updated_config)?; std::fs::write(file_path, json_content)?; } diff --git a/crates/goose/src/providers/api_client.rs b/crates/goose/src/providers/api_client.rs index 163257f5b6c8..22f2c22edeb1 100644 --- a/crates/goose/src/providers/api_client.rs +++ b/crates/goose/src/providers/api_client.rs @@ -21,6 +21,7 @@ pub struct ApiClient { } pub enum AuthMethod { + NoAuth, BearerToken(String), ApiKey { header_name: String, @@ -172,6 +173,7 @@ pub struct ApiResponse { impl fmt::Debug for AuthMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + AuthMethod::NoAuth => f.debug_tuple("NoAuth").finish(), AuthMethod::BearerToken(_) => f.debug_tuple("BearerToken").field(&"[hidden]").finish(), AuthMethod::ApiKey { header_name, .. } => f .debug_struct("ApiKey") @@ -379,6 +381,7 @@ impl<'a> ApiRequestBuilder<'a> { request = request.header(SESSION_ID_HEADER, self.session_id); request = match &self.client.auth { + AuthMethod::NoAuth => request, AuthMethod::BearerToken(token) => { request.header("Authorization", format!("Bearer {}", token)) } diff --git a/crates/goose/src/providers/factory.rs b/crates/goose/src/providers/factory.rs index 286b2ea40edc..281f02ce3695 100644 --- a/crates/goose/src/providers/factory.rs +++ b/crates/goose/src/providers/factory.rs @@ -316,13 +316,12 @@ mod tests { #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; - let cases = vec![ - ("openai", "OPENAI_API_KEY"), + let required_api_key_cases = vec![ ("groq", "GROQ_API_KEY"), ("mistral", "MISTRAL_API_KEY"), ("custom_deepseek", "DEEPSEEK_API_KEY"), ]; - for (name, expected_key) in cases { + for (name, expected_key) in required_api_key_cases { if let Some((meta, _)) = providers_list.iter().find(|(m, _)| m.name == name) { assert!( !meta.config_keys.is_empty(), @@ -346,5 +345,24 @@ mod tests { continue; } } + + if let Some((meta, _)) = providers_list.iter().find(|(m, _)| m.name == "openai") { + assert!( + !meta.config_keys.is_empty(), + "openai provider should have config keys" + ); + assert_eq!( + meta.config_keys[0].name, "OPENAI_API_KEY", + "First config key for openai should be OPENAI_API_KEY" + ); + assert!( + !meta.config_keys[0].required, + "OPENAI_API_KEY should be optional for local server support" + ); + assert!( + meta.config_keys[0].secret, + "OPENAI_API_KEY should be secret" + ); + } } } diff --git a/crates/goose/src/providers/litellm.rs b/crates/goose/src/providers/litellm.rs index 0648ee9ce216..ae0e65937348 100644 --- a/crates/goose/src/providers/litellm.rs +++ b/crates/goose/src/providers/litellm.rs @@ -47,7 +47,7 @@ impl LiteLLMProvider { let timeout_secs: u64 = config.get_param("LITELLM_TIMEOUT").unwrap_or(600); let auth = if api_key.is_empty() { - AuthMethod::Custom(Box::new(NoAuth)) + AuthMethod::NoAuth } else { AuthMethod::BearerToken(api_key) }; @@ -121,17 +121,6 @@ impl LiteLLMProvider { } } -// No authentication provider for LiteLLM when API key is not provided -struct NoAuth; - -#[async_trait] -impl super::api_client::AuthProvider for NoAuth { - async fn get_auth_header(&self) -> Result<(String, String)> { - // Return a dummy header that won't be used - Ok(("X-No-Auth".to_string(), "true".to_string())) - } -} - #[async_trait] impl Provider for LiteLLMProvider { fn metadata() -> ProviderMetadata { diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index 3d3746bedfe9..062d9647e03c 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -71,8 +71,8 @@ impl OllamaProvider { .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } - let auth = AuthMethod::Custom(Box::new(NoAuth)); - let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; + let api_client = + ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; Ok(Self { api_client, @@ -108,8 +108,8 @@ impl OllamaProvider { .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } - let auth = AuthMethod::Custom(Box::new(NoAuth)); - let api_client = ApiClient::with_timeout(base_url.to_string(), auth, timeout)?; + let api_client = + ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; Ok(Self { api_client, @@ -128,15 +128,6 @@ impl OllamaProvider { } } -struct NoAuth; - -#[async_trait] -impl super::api_client::AuthProvider for NoAuth { - async fn get_auth_header(&self) -> Result<(String, String)> { - Ok(("X-No-Auth".to_string(), "true".to_string())) - } -} - #[async_trait] impl Provider for OllamaProvider { fn metadata() -> ProviderMetadata { diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index 20a069fb9c2f..a9992b086b58 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -67,23 +67,27 @@ impl OpenAiProvider { let model = model.with_fast(OPEN_AI_DEFAULT_FAST_MODEL.to_string()); let config = crate::config::Config::global(); - let secrets = config.get_secrets("OPENAI_API_KEY", &["OPENAI_CUSTOM_HEADERS"])?; - let api_key = secrets.get("OPENAI_API_KEY").unwrap().clone(); let host: String = config .get_param("OPENAI_HOST") .unwrap_or_else(|_| "https://api.openai.com".to_string()); + + let api_key: Option = config.get_secret("OPENAI_API_KEY").ok(); + let custom_headers: Option> = config + .get_secret::("OPENAI_CUSTOM_HEADERS") + .ok() + .map(parse_custom_headers); + let base_path: String = config .get_param("OPENAI_BASE_PATH") .unwrap_or_else(|_| "v1/chat/completions".to_string()); let organization: Option = config.get_param("OPENAI_ORGANIZATION").ok(); let project: Option = config.get_param("OPENAI_PROJECT").ok(); - let custom_headers: Option> = secrets - .get("OPENAI_CUSTOM_HEADERS") - .cloned() - .map(parse_custom_headers); let timeout_secs: u64 = config.get_param("OPENAI_TIMEOUT").unwrap_or(600); - let auth = AuthMethod::BearerToken(api_key); + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; let mut api_client = ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; @@ -136,9 +140,12 @@ impl OpenAiProvider { config: DeclarativeProviderConfig, ) -> 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 api_key: Option = if config.requires_auth && !config.api_key_env.is_empty() { + global_config.get_secret(&config.api_key_env).ok() + } else { + None + }; let url = url::Url::parse(&config.base_url) .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; @@ -161,7 +168,11 @@ impl OpenAiProvider { }; let timeout_secs = config.timeout_seconds.unwrap_or(600); - let auth = AuthMethod::BearerToken(api_key); + + let auth = match api_key { + Some(key) if !key.is_empty() => AuthMethod::BearerToken(key), + _ => AuthMethod::NoAuth, + }; let mut api_client = ApiClient::with_timeout(host, auth, std::time::Duration::from_secs(timeout_secs))?; @@ -228,7 +239,7 @@ impl Provider for OpenAiProvider { models, OPEN_AI_DOC_URL, vec![ - ConfigKey::new("OPENAI_API_KEY", true, true, None), + ConfigKey::new("OPENAI_API_KEY", false, true, None), ConfigKey::new("OPENAI_HOST", true, false, Some("https://api.openai.com")), ConfigKey::new("OPENAI_BASE_PATH", true, false, Some("v1/chat/completions")), ConfigKey::new("OPENAI_ORGANIZATION", false, false, None), diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index f10df127d385..4f2794d884ba 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -98,12 +98,14 @@ impl ProviderRegistry { let mut config_keys = base_metadata.config_keys.clone(); - if let Some(api_key_index) = config_keys - .iter() - .position(|key| key.required && key.secret) - { - config_keys[api_key_index] = - super::base::ConfigKey::new(&config.api_key_env, true, true, None); + if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { + if !config.requires_auth { + config_keys.remove(api_key_index); + } else if !config.api_key_env.is_empty() { + let api_key_required = provider_type == ProviderType::Declarative; + config_keys[api_key_index] = + super::base::ConfigKey::new(&config.api_key_env, api_key_required, true, None); + } } let custom_metadata = ProviderMetadata { diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 23216e15b234..755394ed04b7 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3480,7 +3480,6 @@ "name", "engine", "display_name", - "api_key_env", "base_url", "models" ], @@ -3517,6 +3516,9 @@ "name": { "type": "string" }, + "requires_auth": { + "type": "boolean" + }, "supports_streaming": { "type": "boolean", "nullable": true @@ -6693,6 +6695,9 @@ "type": "string" } }, + "requires_auth": { + "type": "boolean" + }, "supports_streaming": { "type": "boolean", "nullable": true diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index f9507b27cec3..99006fec631e 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -154,7 +154,7 @@ export type CspMetadata = { }; export type DeclarativeProviderConfig = { - api_key_env: string; + api_key_env?: string; base_url: string; description?: string | null; display_name: string; @@ -164,6 +164,7 @@ export type DeclarativeProviderConfig = { } | null; models: Array; name: string; + requires_auth?: boolean; supports_streaming?: boolean | null; timeout_seconds?: number | null; }; @@ -1203,6 +1204,7 @@ export type UpdateCustomProviderRequest = { [key: string]: string; } | null; models: Array; + requires_auth?: boolean; supports_streaming?: boolean | null; }; 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 e45a503522fa..ddf4e59eff5e 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 @@ -24,7 +24,7 @@ export default function CustomProviderForm({ const [apiUrl, setApiUrl] = useState(''); const [apiKey, setApiKey] = useState(''); const [models, setModels] = useState(''); - const [isLocalModel, setIsLocalModel] = useState(false); + const [noAuthRequired, setNoAuthRequired] = useState(false); const [supportsStreaming, setSupportsStreaming] = useState(true); const [validationErrors, setValidationErrors] = useState>({}); @@ -40,14 +40,13 @@ export default function CustomProviderForm({ setApiUrl(initialData.api_url); setModels(initialData.models.join(', ')); setSupportsStreaming(initialData.supports_streaming ?? true); + setNoAuthRequired(!(initialData.requires_auth ?? true)); } }, [initialData]); - const handleLocalModels = (checked: boolean) => { - setIsLocalModel(checked); + const handleNoAuthChange = (checked: boolean) => { + setNoAuthRequired(!!checked); if (checked) { - setApiKey('notrequired'); - } else { setApiKey(''); } }; @@ -58,7 +57,8 @@ export default function CustomProviderForm({ const errors: Record = {}; if (!displayName) errors.displayName = 'Display name is required'; if (!apiUrl) errors.apiUrl = 'API URL is required'; - if (!isLocalModel && !apiKey && !initialData) errors.apiKey = 'API key is required'; + const existingHadAuth = initialData && (initialData.requires_auth ?? true); + if (!noAuthRequired && !apiKey && !existingHadAuth) errors.apiKey = 'API key is required'; if (!models) errors.models = 'At least one model is required'; if (Object.keys(errors).length > 0) { @@ -78,6 +78,7 @@ export default function CustomProviderForm({ api_key: apiKey, models: modelList, supports_streaming: supportsStreaming, + requires_auth: !noAuthRequired, }); }; @@ -173,40 +174,45 @@ export default function CustomProviderForm({ )}
- - setApiKey(e.target.value)} - placeholder={initialData ? 'Leave blank to keep existing key' : 'Your API key'} - aria-invalid={!!validationErrors.apiKey} - aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined} - className={validationErrors.apiKey ? 'border-red-500' : ''} - disabled={isLocalModel} - /> - {validationErrors.apiKey && ( -

- {validationErrors.apiKey} -

- )} +
+ + +
- {!initialData && ( -
- + {!noAuthRequired && ( + <> -
+ setApiKey(e.target.value)} + placeholder={initialData ? 'Leave blank to keep existing key' : 'Your API key'} + aria-invalid={!!validationErrors.apiKey} + aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined} + className={validationErrors.apiKey ? 'border-red-500' : ''} + /> + {validationErrors.apiKey && ( +

+ {validationErrors.apiKey} +

+ )} + )}
{isEditable && (