feat: add requires_auth flag for custom providers without authentication (#6705)

Signed-off-by: rabi <ramishra@redhat.com>
This commit is contained in:
Rabi Mishra
2026-01-28 22:34:12 +05:30
committed by GitHub
parent 5b757c7f72
commit 2673e8acca
13 changed files with 250 additions and 153 deletions
+17 -9
View File
@@ -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,
@@ -1864,11 +1866,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| {
@@ -1897,15 +1904,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(())
@@ -93,6 +93,12 @@ pub struct UpdateCustomProviderRequest {
pub models: Vec<String>,
pub supports_streaming: Option<bool>,
pub headers: Option<std::collections::HashMap<String, String>>,
#[serde(default = "default_requires_auth")]
pub requires_auth: bool,
}
fn default_requires_auth() -> bool {
true
}
#[derive(Deserialize, ToSchema)]
@@ -699,13 +705,16 @@ pub async fn create_custom_provider(
Json(request): Json<UpdateCustomProviderRequest>,
) -> Result<Json<String>, 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)?;
@@ -769,13 +778,17 @@ pub async fn update_custom_provider(
Json(request): Json<UpdateCustomProviderRequest>,
) -> Result<Json<String>, 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)?;
+14 -3
View File
@@ -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::<String>(&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::<String>(&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;
}
}
@@ -33,12 +33,19 @@ pub struct DeclarativeProviderConfig {
pub engine: ProviderEngine,
pub display_name: String,
pub description: Option<String>,
#[serde(default)]
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
pub headers: Option<HashMap<String, String>>,
pub timeout_seconds: Option<u64>,
pub supports_streaming: Option<bool>,
#[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<String>,
pub supports_streaming: Option<bool>,
pub headers: Option<HashMap<String, String>>,
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<String>,
pub supports_streaming: Option<bool>,
pub headers: Option<HashMap<String, String>>,
pub requires_auth: bool,
}
pub fn create_custom_provider(
engine: &str,
display_name: String,
api_url: String,
api_key: String,
models: Vec<String>,
supports_streaming: Option<bool>,
headers: Option<HashMap<String, String>>,
params: CreateCustomProviderParams,
) -> Result<DeclarativeProviderConfig> {
let id = generate_id(&display_name);
let api_key_name = generate_api_key_name(&id);
let id = generate_id(&params.display_name);
let config = Config::global();
config.set_secret(&api_key_name, &api_key)?;
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, &params.api_key)?;
api_key_name
} else {
String::new()
};
let model_infos: Vec<ModelInfo> = models
let model_infos: Vec<ModelInfo> = 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<String>,
supports_streaming: Option<bool>,
) -> Result<()> {
let loaded_provider = load_provider(id)?;
pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> {
let loaded_provider = load_provider(&params.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(&params.id)
} else {
existing_config.api_key_env.clone()
};
if !params.api_key.is_empty() {
config.set_secret(&api_key_name, &params.api_key)?;
}
api_key_name
} else {
String::new()
};
if editable {
let model_infos: Vec<ModelInfo> = models
let model_infos: Vec<ModelInfo> = 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)?;
}
+3
View File
@@ -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")
@@ -390,6 +392,7 @@ impl<'a> ApiRequestBuilder<'a> {
request = request.headers(headers);
request = match &self.client.auth {
AuthMethod::NoAuth => request,
AuthMethod::BearerToken(token) => {
request.header("Authorization", format!("Bearer {}", token))
}
+21 -3
View File
@@ -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"
);
}
}
}
+1 -12
View File
@@ -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)
};
@@ -129,17 +129,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 {
+4 -13
View File
@@ -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,
@@ -132,15 +132,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 {
+23 -12
View File
@@ -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<String> = config.get_secret("OPENAI_API_KEY").ok();
let custom_headers: Option<HashMap<String, String>> = config
.get_secret::<String>("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<String> = config.get_param("OPENAI_ORGANIZATION").ok();
let project: Option<String> = config.get_param("OPENAI_PROJECT").ok();
let custom_headers: Option<HashMap<String, String>> = 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<Self> {
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<String> = 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))?;
@@ -232,7 +243,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),
@@ -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 {
+6 -1
View File
@@ -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
@@ -6698,6 +6700,9 @@
"type": "string"
}
},
"requires_auth": {
"type": "boolean"
},
"supports_streaming": {
"type": "boolean",
"nullable": true
+3 -1
View File
@@ -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<ModelInfo>;
name: string;
requires_auth?: boolean;
supports_streaming?: boolean | null;
timeout_seconds?: number | null;
};
@@ -1204,6 +1205,7 @@ export type UpdateCustomProviderRequest = {
[key: string]: string;
} | null;
models: Array<string>;
requires_auth?: boolean;
supports_streaming?: boolean | null;
};
@@ -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<Record<string, string>>({});
@@ -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<string, string> = {};
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({
)}
<div>
<label
htmlFor="api-key"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API Key
{!isLocalModel && !initialData && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => 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 && (
<p id="api-key-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiKey}
</p>
)}
<div className="flex items-center space-x-2 mb-2">
<Checkbox
id="no-auth-required"
checked={noAuthRequired}
onCheckedChange={handleNoAuthChange}
/>
<label
htmlFor="no-auth-required"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
No authentication required
</label>
</div>
{!initialData && (
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
{!noAuthRequired && (
<>
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
htmlFor="api-key"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
This is a local model (no auth required)
API Key
{!initialData && <span className="text-red-500 ml-1">*</span>}
</label>
</div>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => 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 && (
<p id="api-key-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiKey}
</p>
)}
</>
)}
</div>
{isEditable && (