bf54314b67
Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Kalvin Chau <kalvin@block.xyz>
940 lines
36 KiB
Rust
940 lines
36 KiB
Rust
use super::*;
|
|
use crate::config::declarative_providers;
|
|
use std::str::FromStr;
|
|
|
|
fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEntryDto {
|
|
let stale = ProviderInventoryService::is_stale(&entry);
|
|
ProviderInventoryEntryDto {
|
|
provider_id: entry.provider_id,
|
|
provider_name: entry.provider_name,
|
|
description: entry.description,
|
|
default_model: entry.default_model,
|
|
configured: entry.configured,
|
|
provider_type: format!("{:?}", entry.provider_type),
|
|
category: provider_setup_category_to_dto(entry.category),
|
|
config_keys: entry
|
|
.config_keys
|
|
.into_iter()
|
|
.map(provider_config_key_to_dto)
|
|
.collect(),
|
|
setup_steps: entry.setup_steps,
|
|
supports_refresh: entry.supports_refresh,
|
|
refreshing: entry.refreshing,
|
|
models: entry
|
|
.models
|
|
.into_iter()
|
|
.map(|m| ProviderInventoryModelDto {
|
|
id: m.id,
|
|
name: m.name,
|
|
family: m.family,
|
|
context_limit: m.context_limit,
|
|
reasoning: m.reasoning,
|
|
recommended: m.recommended,
|
|
})
|
|
.collect(),
|
|
last_updated_at: entry.last_updated_at.map(|t| t.to_rfc3339()),
|
|
last_refresh_attempt_at: entry.last_refresh_attempt_at.map(|t| t.to_rfc3339()),
|
|
last_refresh_error: entry.last_refresh_error,
|
|
stale,
|
|
model_selection_hint: entry.model_selection_hint,
|
|
}
|
|
}
|
|
|
|
fn provider_config_key_to_dto(key: crate::providers::base::ConfigKey) -> ProviderConfigKey {
|
|
ProviderConfigKey {
|
|
name: key.name,
|
|
required: key.required,
|
|
secret: key.secret,
|
|
default: key.default,
|
|
oauth_flow: key.oauth_flow,
|
|
device_code_flow: key.device_code_flow,
|
|
primary: key.primary,
|
|
}
|
|
}
|
|
|
|
const SECRET_MASK_PREFIX_LEN: usize = 4;
|
|
const SECRET_MASK_SUFFIX_LEN: usize = 3;
|
|
const SECRET_MASK_FALLBACK: &str = "***";
|
|
|
|
fn mask_secret_value(value: &str) -> String {
|
|
let prefix: String = value.chars().take(SECRET_MASK_PREFIX_LEN).collect();
|
|
let suffix_chars: Vec<char> = value.chars().rev().take(SECRET_MASK_SUFFIX_LEN).collect();
|
|
let suffix: String = suffix_chars.into_iter().rev().collect();
|
|
|
|
if prefix.is_empty()
|
|
|| suffix.is_empty()
|
|
|| value.chars().count() <= SECRET_MASK_PREFIX_LEN + SECRET_MASK_SUFFIX_LEN
|
|
{
|
|
return SECRET_MASK_FALLBACK.to_string();
|
|
}
|
|
|
|
format!("{prefix}...{suffix}")
|
|
}
|
|
|
|
fn config_value_to_string(value: &serde_json::Value) -> Option<String> {
|
|
match value {
|
|
serde_json::Value::Null => None,
|
|
serde_json::Value::String(value) if value.is_empty() => None,
|
|
serde_json::Value::String(value) => Some(value.clone()),
|
|
other => serde_json::to_string(other).ok(),
|
|
}
|
|
}
|
|
|
|
fn provider_config_field_value(
|
|
config: &Config,
|
|
key: &crate::providers::base::ConfigKey,
|
|
secrets: Option<&HashMap<String, serde_json::Value>>,
|
|
) -> ProviderConfigFieldValueDto {
|
|
let value = if key.secret {
|
|
std::env::var(key.name.to_uppercase()).ok().or_else(|| {
|
|
secrets
|
|
.and_then(|values| values.get(&key.name))
|
|
.and_then(config_value_to_string)
|
|
})
|
|
} else {
|
|
config
|
|
.get_param::<serde_json::Value>(&key.name)
|
|
.ok()
|
|
.and_then(|value| config_value_to_string(&value))
|
|
};
|
|
|
|
ProviderConfigFieldValueDto {
|
|
key: key.name.clone(),
|
|
value: value.as_deref().map(|value| {
|
|
if key.secret {
|
|
mask_secret_value(value)
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}),
|
|
is_set: value.is_some(),
|
|
is_secret: key.secret,
|
|
required: key.required,
|
|
}
|
|
}
|
|
|
|
fn provider_catalog_entry_to_dto(
|
|
entry: crate::providers::catalog::ProviderCatalogEntry,
|
|
) -> ProviderTemplateCatalogEntryDto {
|
|
ProviderTemplateCatalogEntryDto {
|
|
provider_id: entry.id,
|
|
name: entry.name,
|
|
format: entry.format,
|
|
api_url: entry.api_url,
|
|
model_count: entry.model_count,
|
|
doc_url: entry.doc_url,
|
|
env_var: entry.env_var,
|
|
}
|
|
}
|
|
|
|
fn provider_setup_category_to_dto(
|
|
category: crate::providers::catalog::ProviderSetupCategory,
|
|
) -> ProviderSetupCategoryDto {
|
|
match category {
|
|
crate::providers::catalog::ProviderSetupCategory::Agent => ProviderSetupCategoryDto::Agent,
|
|
crate::providers::catalog::ProviderSetupCategory::Model => ProviderSetupCategoryDto::Model,
|
|
}
|
|
}
|
|
|
|
fn provider_setup_method_to_dto(
|
|
method: crate::providers::catalog::ProviderSetupMethod,
|
|
) -> ProviderSetupMethodDto {
|
|
match method {
|
|
crate::providers::catalog::ProviderSetupMethod::None => ProviderSetupMethodDto::None,
|
|
crate::providers::catalog::ProviderSetupMethod::SingleApiKey => {
|
|
ProviderSetupMethodDto::SingleApiKey
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::ConfigFields => {
|
|
ProviderSetupMethodDto::ConfigFields
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::HostWithOauthFallback => {
|
|
ProviderSetupMethodDto::HostWithOauthFallback
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::OauthBrowser => {
|
|
ProviderSetupMethodDto::OauthBrowser
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::OauthDeviceCode => {
|
|
ProviderSetupMethodDto::OauthDeviceCode
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::CloudCredentials => {
|
|
ProviderSetupMethodDto::CloudCredentials
|
|
}
|
|
crate::providers::catalog::ProviderSetupMethod::Local => ProviderSetupMethodDto::Local,
|
|
crate::providers::catalog::ProviderSetupMethod::CliAuth => ProviderSetupMethodDto::CliAuth,
|
|
}
|
|
}
|
|
|
|
fn provider_setup_group_to_dto(
|
|
group: crate::providers::catalog::ProviderSetupGroup,
|
|
) -> ProviderSetupGroupDto {
|
|
match group {
|
|
crate::providers::catalog::ProviderSetupGroup::Default => ProviderSetupGroupDto::Default,
|
|
crate::providers::catalog::ProviderSetupGroup::Additional => {
|
|
ProviderSetupGroupDto::Additional
|
|
}
|
|
}
|
|
}
|
|
|
|
fn provider_setup_entry_to_dto(
|
|
entry: crate::providers::catalog::ProviderSetupCatalogEntry,
|
|
) -> ProviderSetupCatalogEntryDto {
|
|
ProviderSetupCatalogEntryDto {
|
|
provider_id: entry.provider_id,
|
|
name: entry.display_name,
|
|
category: provider_setup_category_to_dto(entry.category),
|
|
description: entry.description,
|
|
setup_method: provider_setup_method_to_dto(entry.setup_method),
|
|
native_connect_query: entry.native_connect_query,
|
|
fields: entry
|
|
.fields
|
|
.into_iter()
|
|
.map(|field| ProviderSetupFieldDto {
|
|
key: field.key,
|
|
label: field.label,
|
|
secret: field.secret,
|
|
required: field.required,
|
|
placeholder: field.placeholder,
|
|
default_value: field.default_value,
|
|
})
|
|
.collect(),
|
|
binary_name: entry.binary_name,
|
|
doc_url: entry.docs_url,
|
|
group: provider_setup_group_to_dto(entry.group),
|
|
show_only_when_installed: entry.show_only_when_installed,
|
|
aliases: entry.aliases,
|
|
supports_install: entry.setup_capabilities.install,
|
|
supports_auth: entry.setup_capabilities.auth,
|
|
supports_auth_status: entry.setup_capabilities.auth_status,
|
|
}
|
|
}
|
|
|
|
fn provider_template_to_dto(
|
|
template: crate::providers::catalog::ProviderTemplate,
|
|
) -> ProviderTemplateDto {
|
|
ProviderTemplateDto {
|
|
provider_id: template.id,
|
|
name: template.name,
|
|
format: template.format,
|
|
api_url: template.api_url,
|
|
models: template
|
|
.models
|
|
.into_iter()
|
|
.map(|model| ProviderTemplateModelDto {
|
|
id: model.id,
|
|
name: model.name,
|
|
context_limit: model.context_limit,
|
|
capabilities: ProviderTemplateCapabilitiesDto {
|
|
tool_call: model.capabilities.tool_call,
|
|
reasoning: model.capabilities.reasoning,
|
|
attachment: model.capabilities.attachment,
|
|
temperature: model.capabilities.temperature,
|
|
},
|
|
deprecated: model.deprecated,
|
|
})
|
|
.collect(),
|
|
supports_streaming: template.supports_streaming,
|
|
env_var: template.env_var,
|
|
doc_url: template.doc_url,
|
|
}
|
|
}
|
|
|
|
fn custom_provider_engine_to_dto(engine: &declarative_providers::ProviderEngine) -> &'static str {
|
|
match engine {
|
|
declarative_providers::ProviderEngine::OpenAI => "openai_compatible",
|
|
declarative_providers::ProviderEngine::Anthropic => "anthropic_compatible",
|
|
declarative_providers::ProviderEngine::Ollama => "ollama_compatible",
|
|
}
|
|
}
|
|
|
|
fn normalize_custom_provider_engine(engine: &str) -> Result<String, agent_client_protocol::Error> {
|
|
let engine = engine.trim().to_lowercase();
|
|
if declarative_providers::ProviderEngine::from_str(&engine).is_err() {
|
|
return Err(agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Unsupported custom provider engine: {engine}")));
|
|
}
|
|
|
|
match engine.as_str() {
|
|
"openai" | "openai_compatible" => Ok("openai_compatible".to_string()),
|
|
"anthropic" | "anthropic_compatible" => Ok("anthropic_compatible".to_string()),
|
|
"ollama" | "ollama_compatible" => Ok("ollama_compatible".to_string()),
|
|
_ => unreachable!("provider engine was validated above"),
|
|
}
|
|
}
|
|
|
|
fn non_empty_trimmed(value: String, field: &str) -> Result<String, agent_client_protocol::Error> {
|
|
let value = value.trim().to_string();
|
|
if value.is_empty() {
|
|
return Err(
|
|
agent_client_protocol::Error::invalid_params().data(format!("{field} cannot be empty"))
|
|
);
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|
value.and_then(|value| {
|
|
let value = value.trim().to_string();
|
|
(!value.is_empty()).then_some(value)
|
|
})
|
|
}
|
|
|
|
fn normalize_custom_provider_upsert(
|
|
mut provider: CustomProviderUpsertDto,
|
|
require_api_key: bool,
|
|
) -> Result<CustomProviderUpsertDto, agent_client_protocol::Error> {
|
|
provider.engine = normalize_custom_provider_engine(&provider.engine)?;
|
|
provider.display_name = non_empty_trimmed(provider.display_name, "displayName")?;
|
|
provider.api_url = non_empty_trimmed(provider.api_url, "apiUrl")?;
|
|
let url = url::Url::parse(&provider.api_url).map_err(|_| {
|
|
agent_client_protocol::Error::invalid_params().data("apiUrl must be a valid URL")
|
|
})?;
|
|
if !matches!(url.scheme(), "http" | "https") {
|
|
return Err(
|
|
agent_client_protocol::Error::invalid_params().data("apiUrl must use HTTP or HTTPS")
|
|
);
|
|
}
|
|
|
|
provider.api_key = provider.api_key.and_then(|api_key| {
|
|
let api_key = api_key.trim().to_string();
|
|
(!api_key.is_empty()).then_some(api_key)
|
|
});
|
|
if require_api_key && provider.requires_auth && provider.api_key.is_none() {
|
|
return Err(agent_client_protocol::Error::invalid_params().data("apiKey cannot be empty"));
|
|
}
|
|
provider.models = provider
|
|
.models
|
|
.into_iter()
|
|
.filter_map(|model| {
|
|
let model = model.trim().to_string();
|
|
(!model.is_empty()).then_some(model)
|
|
})
|
|
.collect();
|
|
if provider.models.is_empty() {
|
|
return Err(agent_client_protocol::Error::invalid_params().data("models cannot be empty"));
|
|
}
|
|
|
|
provider.headers = provider
|
|
.headers
|
|
.into_iter()
|
|
.map(|(key, value)| {
|
|
let key = key.trim().to_string();
|
|
let value = value.trim().to_string();
|
|
if key.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
reqwest::header::HeaderName::from_bytes(key.as_bytes()).map_err(|_| {
|
|
agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Invalid header name: {key}"))
|
|
})?;
|
|
reqwest::header::HeaderValue::from_str(&value).map_err(|_| {
|
|
agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Invalid header value for: {key}"))
|
|
})?;
|
|
Ok(Some((key, value)))
|
|
})
|
|
.collect::<Result<Vec<_>, agent_client_protocol::Error>>()?
|
|
.into_iter()
|
|
.flatten()
|
|
.collect();
|
|
provider.catalog_provider_id = normalize_optional_string(provider.catalog_provider_id);
|
|
provider.base_path = normalize_optional_string(provider.base_path);
|
|
Ok(provider)
|
|
}
|
|
|
|
fn custom_provider_headers(headers: HashMap<String, String>) -> Option<HashMap<String, String>> {
|
|
(!headers.is_empty()).then_some(headers)
|
|
}
|
|
|
|
fn load_declarative_provider_for_client(
|
|
provider_id: &str,
|
|
) -> Result<declarative_providers::LoadedProvider, agent_client_protocol::Error> {
|
|
declarative_providers::load_provider(provider_id).map_err(|error| {
|
|
if error.to_string().contains("Provider not found") {
|
|
agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Unknown provider: {provider_id}"))
|
|
} else if error.to_string().contains("Invalid provider id") {
|
|
agent_client_protocol::Error::invalid_params().data(error.to_string())
|
|
} else {
|
|
agent_client_protocol::Error::internal_error().data(error.to_string())
|
|
}
|
|
})
|
|
}
|
|
|
|
fn custom_provider_config_to_dto(
|
|
config: &declarative_providers::DeclarativeProviderConfig,
|
|
) -> CustomProviderConfigDto {
|
|
let api_key_env = normalize_optional_string(Some(config.api_key_env.clone()));
|
|
let api_key_set = api_key_env
|
|
.as_ref()
|
|
.map(|key| {
|
|
Config::global()
|
|
.get_secret::<serde_json::Value>(key)
|
|
.is_ok()
|
|
})
|
|
.unwrap_or(false);
|
|
|
|
CustomProviderConfigDto {
|
|
provider_id: config.name.clone(),
|
|
engine: custom_provider_engine_to_dto(&config.engine).to_string(),
|
|
display_name: config.display_name.clone(),
|
|
api_url: config.base_url.clone(),
|
|
models: config
|
|
.models
|
|
.iter()
|
|
.map(|model| model.name.clone())
|
|
.collect(),
|
|
supports_streaming: config.supports_streaming,
|
|
headers: config.headers.clone().unwrap_or_default(),
|
|
requires_auth: config.requires_auth,
|
|
catalog_provider_id: config.catalog_provider_id.clone(),
|
|
base_path: config.base_path.clone(),
|
|
api_key_env,
|
|
api_key_set,
|
|
preserves_thinking: config.preserves_thinking,
|
|
}
|
|
}
|
|
|
|
fn refresh_skip_reason_to_dto(reason: RefreshSkipReason) -> RefreshProviderInventorySkipReasonDto {
|
|
match reason {
|
|
RefreshSkipReason::UnknownProvider => {
|
|
RefreshProviderInventorySkipReasonDto::UnknownProvider
|
|
}
|
|
RefreshSkipReason::NotConfigured => RefreshProviderInventorySkipReasonDto::NotConfigured,
|
|
RefreshSkipReason::DoesNotSupportRefresh => {
|
|
RefreshProviderInventorySkipReasonDto::DoesNotSupportRefresh
|
|
}
|
|
RefreshSkipReason::AlreadyRefreshing => {
|
|
RefreshProviderInventorySkipReasonDto::AlreadyRefreshing
|
|
}
|
|
}
|
|
}
|
|
|
|
fn refresh_plan_to_response(refresh_plan: RefreshPlan) -> RefreshProviderInventoryResponse {
|
|
RefreshProviderInventoryResponse {
|
|
started: refresh_plan.started,
|
|
skipped: refresh_plan
|
|
.skipped
|
|
.into_iter()
|
|
.map(|entry| RefreshProviderInventorySkipDto {
|
|
provider_id: entry.provider_id,
|
|
reason: refresh_skip_reason_to_dto(entry.reason),
|
|
})
|
|
.collect(),
|
|
}
|
|
}
|
|
|
|
impl GooseAcpAgent {
|
|
pub(super) async fn on_list_providers(
|
|
&self,
|
|
req: ListProvidersRequest,
|
|
) -> Result<ListProvidersResponse, agent_client_protocol::Error> {
|
|
let entries = self
|
|
.provider_inventory
|
|
.entries(&req.provider_ids)
|
|
.await
|
|
.internal_err()?;
|
|
Ok(ListProvidersResponse {
|
|
entries: entries.into_iter().map(inventory_entry_to_dto).collect(),
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_list_provider_catalog(
|
|
&self,
|
|
req: ProviderCatalogListRequest,
|
|
) -> Result<ProviderCatalogListResponse, agent_client_protocol::Error> {
|
|
let formats = match req.format {
|
|
Some(format) => vec![format
|
|
.parse::<crate::providers::catalog::ProviderFormat>()
|
|
.map_err(|error| agent_client_protocol::Error::invalid_params().data(error))?],
|
|
None => vec![
|
|
crate::providers::catalog::ProviderFormat::OpenAI,
|
|
crate::providers::catalog::ProviderFormat::Anthropic,
|
|
crate::providers::catalog::ProviderFormat::Ollama,
|
|
],
|
|
};
|
|
|
|
let mut providers = Vec::new();
|
|
for format in formats {
|
|
providers.extend(
|
|
crate::providers::catalog::get_providers_by_format(format)
|
|
.await
|
|
.into_iter()
|
|
.map(provider_catalog_entry_to_dto),
|
|
);
|
|
}
|
|
providers.sort_by(|a, b| {
|
|
a.name
|
|
.cmp(&b.name)
|
|
.then_with(|| a.provider_id.cmp(&b.provider_id))
|
|
});
|
|
|
|
Ok(ProviderCatalogListResponse { providers })
|
|
}
|
|
|
|
pub(super) async fn on_list_provider_setup_catalog(
|
|
&self,
|
|
_req: ProviderSetupCatalogListRequest,
|
|
) -> Result<ProviderSetupCatalogListResponse, agent_client_protocol::Error> {
|
|
let providers = crate::providers::catalog::get_setup_catalog_entries()
|
|
.await
|
|
.into_iter()
|
|
.map(provider_setup_entry_to_dto)
|
|
.collect();
|
|
Ok(ProviderSetupCatalogListResponse { providers })
|
|
}
|
|
|
|
pub(super) async fn on_get_provider_catalog_template(
|
|
&self,
|
|
req: ProviderCatalogTemplateRequest,
|
|
) -> Result<ProviderCatalogTemplateResponse, agent_client_protocol::Error> {
|
|
let template = crate::providers::catalog::get_provider_template(&req.provider_id)
|
|
.ok_or_else(|| {
|
|
agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Unknown catalog provider: {}", req.provider_id))
|
|
})?;
|
|
Ok(ProviderCatalogTemplateResponse {
|
|
template: provider_template_to_dto(template),
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_create_custom_provider(
|
|
&self,
|
|
req: CustomProviderCreateRequest,
|
|
) -> Result<CustomProviderCreateResponse, agent_client_protocol::Error> {
|
|
let provider = normalize_custom_provider_upsert(req.provider, true)?;
|
|
let config = declarative_providers::create_custom_provider(
|
|
declarative_providers::CreateCustomProviderParams {
|
|
engine: provider.engine,
|
|
display_name: provider.display_name,
|
|
api_url: provider.api_url,
|
|
api_key: provider.api_key,
|
|
models: provider.models,
|
|
supports_streaming: provider.supports_streaming,
|
|
headers: custom_provider_headers(provider.headers),
|
|
requires_auth: provider.requires_auth,
|
|
catalog_provider_id: provider.catalog_provider_id,
|
|
base_path: provider.base_path,
|
|
preserves_thinking: provider.preserves_thinking,
|
|
},
|
|
)
|
|
.internal_err_ctx("Failed to create custom provider")?;
|
|
|
|
Config::global().invalidate_secrets_cache();
|
|
crate::providers::refresh_custom_providers()
|
|
.await
|
|
.internal_err_ctx("Failed to refresh custom providers")?;
|
|
|
|
let provider_id = config.name;
|
|
let provider_ids = [provider_id.clone()];
|
|
let status = Self::provider_config_status(provider_id.clone()).await;
|
|
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
|
Ok(CustomProviderCreateResponse {
|
|
provider_id,
|
|
status,
|
|
refresh,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_read_custom_provider(
|
|
&self,
|
|
req: CustomProviderReadRequest,
|
|
) -> Result<CustomProviderReadResponse, agent_client_protocol::Error> {
|
|
let loaded = load_declarative_provider_for_client(&req.provider_id)?;
|
|
let status = Self::provider_config_status(req.provider_id).await;
|
|
Ok(CustomProviderReadResponse {
|
|
provider: custom_provider_config_to_dto(&loaded.config),
|
|
editable: loaded.is_editable,
|
|
status,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_update_custom_provider(
|
|
&self,
|
|
req: CustomProviderUpdateRequest,
|
|
) -> Result<CustomProviderUpdateResponse, agent_client_protocol::Error> {
|
|
let loaded = load_declarative_provider_for_client(&req.provider_id)?;
|
|
if !loaded.is_editable {
|
|
return Err(agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Provider is not editable: {}", req.provider_id)));
|
|
}
|
|
|
|
let provider = normalize_custom_provider_upsert(req.provider, false)?;
|
|
if provider.requires_auth && provider.api_key.is_none() {
|
|
let api_key_env = if loaded.config.api_key_env.is_empty() {
|
|
declarative_providers::generate_api_key_name(&req.provider_id)
|
|
} else {
|
|
loaded.config.api_key_env.clone()
|
|
};
|
|
if Config::global().get_secret::<String>(&api_key_env).is_err() {
|
|
return Err(agent_client_protocol::Error::invalid_params()
|
|
.data("apiKey is required when auth is enabled and no secret is stored"));
|
|
}
|
|
}
|
|
declarative_providers::update_custom_provider(
|
|
declarative_providers::UpdateCustomProviderParams {
|
|
id: req.provider_id.clone(),
|
|
engine: provider.engine,
|
|
display_name: provider.display_name,
|
|
api_url: provider.api_url,
|
|
api_key: provider.api_key,
|
|
models: provider.models,
|
|
supports_streaming: provider.supports_streaming,
|
|
headers: Some(provider.headers),
|
|
requires_auth: provider.requires_auth,
|
|
catalog_provider_id: provider.catalog_provider_id,
|
|
base_path: provider.base_path,
|
|
preserves_thinking: provider.preserves_thinking,
|
|
},
|
|
)
|
|
.internal_err_ctx("Failed to update custom provider")?;
|
|
|
|
Config::global().invalidate_secrets_cache();
|
|
crate::providers::refresh_custom_providers()
|
|
.await
|
|
.internal_err_ctx("Failed to refresh custom providers")?;
|
|
|
|
let provider_ids = [req.provider_id.clone()];
|
|
let status = Self::provider_config_status(req.provider_id.clone()).await;
|
|
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
|
Ok(CustomProviderUpdateResponse {
|
|
provider_id: req.provider_id,
|
|
status,
|
|
refresh,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_delete_custom_provider(
|
|
&self,
|
|
req: CustomProviderDeleteRequest,
|
|
) -> Result<CustomProviderDeleteResponse, agent_client_protocol::Error> {
|
|
let loaded = load_declarative_provider_for_client(&req.provider_id)?;
|
|
if !loaded.is_editable {
|
|
return Err(agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Provider is not editable: {}", req.provider_id)));
|
|
}
|
|
|
|
if Config::global()
|
|
.get_param::<String>("GOOSE_PROVIDER")
|
|
.ok()
|
|
.as_deref()
|
|
== Some(req.provider_id.as_str())
|
|
{
|
|
return Err(agent_client_protocol::Error::invalid_params().data(format!(
|
|
"Cannot delete active provider: {}",
|
|
req.provider_id
|
|
)));
|
|
}
|
|
|
|
declarative_providers::remove_custom_provider(&req.provider_id)
|
|
.internal_err_ctx("Failed to delete custom provider")?;
|
|
|
|
Config::global().invalidate_secrets_cache();
|
|
crate::providers::refresh_custom_providers()
|
|
.await
|
|
.internal_err_ctx("Failed to refresh custom providers")?;
|
|
|
|
Ok(CustomProviderDeleteResponse {
|
|
provider_id: req.provider_id,
|
|
refresh: RefreshProviderInventoryResponse {
|
|
started: Vec::new(),
|
|
skipped: Vec::new(),
|
|
},
|
|
})
|
|
}
|
|
|
|
pub(super) async fn provider_config_status(provider_id: String) -> ProviderConfigStatusDto {
|
|
let is_configured = match crate::providers::get_from_registry(&provider_id).await {
|
|
Ok(entry) => {
|
|
match tokio::task::spawn_blocking(move || entry.inventory_configured()).await {
|
|
Ok(is_configured) => is_configured,
|
|
Err(error) => {
|
|
warn!(
|
|
provider = %provider_id,
|
|
error = %error,
|
|
"provider config status check failed"
|
|
);
|
|
false
|
|
}
|
|
}
|
|
}
|
|
Err(_) => false,
|
|
};
|
|
|
|
ProviderConfigStatusDto {
|
|
provider_id,
|
|
is_configured,
|
|
}
|
|
}
|
|
|
|
pub(super) async fn provider_config_statuses(
|
|
provider_ids: &[String],
|
|
) -> Vec<ProviderConfigStatusDto> {
|
|
let mut ids = if provider_ids.is_empty() {
|
|
crate::providers::providers()
|
|
.await
|
|
.into_iter()
|
|
.map(|(metadata, _)| metadata.name)
|
|
.collect::<Vec<_>>()
|
|
} else {
|
|
provider_ids.to_vec()
|
|
};
|
|
ids.sort();
|
|
ids.dedup();
|
|
|
|
let mut statuses = stream::iter(ids)
|
|
.map(Self::provider_config_status)
|
|
.buffer_unordered(PROVIDER_CONFIG_STATUS_CHECK_CONCURRENCY)
|
|
.collect::<Vec<_>>()
|
|
.await;
|
|
statuses.sort_by(|a, b| a.provider_id.cmp(&b.provider_id));
|
|
statuses
|
|
}
|
|
|
|
pub(super) fn spawn_provider_inventory_refresh_jobs(&self, refresh_plan: &RefreshJobPlan) {
|
|
for refresh_job in refresh_plan.started.iter().cloned() {
|
|
let provider_inventory = self.provider_inventory.clone();
|
|
let provider_factory = Arc::clone(&self.provider_factory);
|
|
let provider_id = refresh_job.provider_id.clone();
|
|
let identity = refresh_job.identity.clone();
|
|
tokio::spawn(async move {
|
|
let mut refresh_guard = provider_inventory.refresh_guard(&identity);
|
|
let provider_result = AssertUnwindSafe(async {
|
|
let metadata = crate::providers::get_from_registry(&provider_id).await?;
|
|
let model_config =
|
|
crate::model::ModelConfig::new(&metadata.metadata().default_model)?
|
|
.with_canonical_limits(&provider_id);
|
|
provider_factory(provider_id.clone(), model_config, Vec::new(), None).await
|
|
})
|
|
.catch_unwind()
|
|
.await;
|
|
|
|
let fetch_result: Result<Vec<String>> = match provider_result {
|
|
Ok(Ok(provider)) => {
|
|
match ensure_refresh_identity_current(&provider_id, &identity).await {
|
|
Ok(()) => match AssertUnwindSafe(provider.fetch_recommended_models())
|
|
.catch_unwind()
|
|
.await
|
|
{
|
|
Ok(Ok(models)) => Ok(models),
|
|
Ok(Err(error)) => Err(anyhow::anyhow!(error.to_string())),
|
|
Err(_) => {
|
|
Err(anyhow::anyhow!("provider inventory refresh task panicked"))
|
|
}
|
|
},
|
|
Err(error) => Err(error),
|
|
}
|
|
}
|
|
Ok(Err(error)) => Err(error),
|
|
Err(_) => Err(anyhow::anyhow!("provider inventory refresh task panicked")),
|
|
};
|
|
|
|
match fetch_result {
|
|
Ok(models) => match provider_inventory
|
|
.store_refreshed_models_for_identity(&identity, &models)
|
|
.await
|
|
{
|
|
Ok(()) => refresh_guard.complete(),
|
|
Err(error) => warn!(
|
|
provider = %provider_id,
|
|
error = %error,
|
|
"failed to store refreshed provider inventory"
|
|
),
|
|
},
|
|
Err(error) => {
|
|
let error_message = error.to_string();
|
|
match provider_inventory
|
|
.store_refresh_error_for_identity(&identity, error_message.clone())
|
|
.await
|
|
{
|
|
Ok(()) => refresh_guard.complete(),
|
|
Err(store_error) => warn!(
|
|
provider = %provider_id,
|
|
error = %store_error,
|
|
refresh_error = %error_message,
|
|
"failed to store provider inventory refresh error"
|
|
),
|
|
}
|
|
warn!(provider = %provider_id, error = %error_message, "provider inventory refresh failed");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(super) async fn start_provider_inventory_refresh(
|
|
&self,
|
|
provider_ids: &[String],
|
|
) -> Result<RefreshProviderInventoryResponse, agent_client_protocol::Error> {
|
|
let refresh_job_plan = self
|
|
.provider_inventory
|
|
.plan_refresh_jobs(provider_ids)
|
|
.await
|
|
.internal_err()?;
|
|
self.spawn_provider_inventory_refresh_jobs(&refresh_job_plan);
|
|
Ok(refresh_plan_to_response(
|
|
refresh_job_plan.into_public_plan(),
|
|
))
|
|
}
|
|
|
|
pub(super) async fn on_refresh_provider_inventory(
|
|
&self,
|
|
req: RefreshProviderInventoryRequest,
|
|
) -> Result<RefreshProviderInventoryResponse, agent_client_protocol::Error> {
|
|
Config::global().invalidate_secrets_cache();
|
|
self.start_provider_inventory_refresh(&req.provider_ids)
|
|
.await
|
|
}
|
|
|
|
pub(super) async fn on_read_provider_config(
|
|
&self,
|
|
req: ProviderConfigReadRequest,
|
|
) -> Result<ProviderConfigReadResponse, agent_client_protocol::Error> {
|
|
let entry = crate::providers::get_from_registry(&req.provider_id)
|
|
.await
|
|
.invalid_params_err_ctx("Unknown provider")?;
|
|
let config = Config::global();
|
|
let config_keys = &entry.metadata().config_keys;
|
|
let secrets = if config_keys.iter().any(|key| key.secret) {
|
|
Some(config.all_secrets().internal_err()?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
Ok(ProviderConfigReadResponse {
|
|
fields: config_keys
|
|
.iter()
|
|
.map(|key| provider_config_field_value(config, key, secrets.as_ref()))
|
|
.collect(),
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_provider_config_status(
|
|
&self,
|
|
req: ProviderConfigStatusRequest,
|
|
) -> Result<ProviderConfigStatusResponse, agent_client_protocol::Error> {
|
|
Ok(ProviderConfigStatusResponse {
|
|
statuses: Self::provider_config_statuses(&req.provider_ids).await,
|
|
})
|
|
}
|
|
|
|
pub(super) async fn on_save_provider_config(
|
|
&self,
|
|
req: ProviderConfigSaveRequest,
|
|
) -> Result<ProviderConfigChangeResponse, agent_client_protocol::Error> {
|
|
let entry = crate::providers::get_from_registry(&req.provider_id)
|
|
.await
|
|
.invalid_params_err_ctx("Unknown provider")?;
|
|
let metadata = entry.metadata().clone();
|
|
let config = Config::global();
|
|
let mut config_updates = Vec::new();
|
|
let mut secret_updates = Vec::new();
|
|
|
|
for field in &req.fields {
|
|
let Some(config_key) = metadata
|
|
.config_keys
|
|
.iter()
|
|
.find(|config_key| config_key.name == field.key)
|
|
else {
|
|
return Err(agent_client_protocol::Error::invalid_params()
|
|
.data(format!("Unsupported provider config field: {}", field.key)));
|
|
};
|
|
|
|
let value = field.value.trim();
|
|
if value.is_empty() {
|
|
return Err(agent_client_protocol::Error::invalid_params().data(format!(
|
|
"Provider config field cannot be empty: {}",
|
|
field.key
|
|
)));
|
|
}
|
|
|
|
if config_key.secret {
|
|
secret_updates.push((
|
|
config_key.name.clone(),
|
|
serde_json::Value::String(value.to_string()),
|
|
));
|
|
} else {
|
|
config_updates.push((config_key.name.clone(), value.to_string()));
|
|
}
|
|
}
|
|
|
|
for (key, value) in config_updates {
|
|
config
|
|
.set_param(&key, &value)
|
|
.internal_err_ctx("Failed to save provider config field")?;
|
|
}
|
|
config
|
|
.set_secret_values(&secret_updates)
|
|
.internal_err_ctx("Failed to save provider secret fields")?;
|
|
|
|
let provider_ids = [req.provider_id.clone()];
|
|
let status = Self::provider_config_status(req.provider_id.clone()).await;
|
|
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
|
Ok(ProviderConfigChangeResponse { status, refresh })
|
|
}
|
|
|
|
pub(super) async fn on_delete_provider_config(
|
|
&self,
|
|
req: ProviderConfigDeleteRequest,
|
|
) -> Result<ProviderConfigChangeResponse, agent_client_protocol::Error> {
|
|
let entry = crate::providers::get_from_registry(&req.provider_id)
|
|
.await
|
|
.invalid_params_err_ctx("Unknown provider")?;
|
|
let metadata = entry.metadata().clone();
|
|
let config = Config::global();
|
|
let mut secret_keys = Vec::new();
|
|
|
|
for config_key in &metadata.config_keys {
|
|
if config_key.secret {
|
|
secret_keys.push(config_key.name.clone());
|
|
} else {
|
|
config
|
|
.delete(&config_key.name)
|
|
.internal_err_ctx("Failed to delete provider config field")?;
|
|
}
|
|
}
|
|
|
|
config
|
|
.delete_secret_values(&secret_keys)
|
|
.internal_err_ctx("Failed to delete provider secret fields")?;
|
|
crate::providers::cleanup_provider(&req.provider_id)
|
|
.await
|
|
.internal_err_ctx("Failed to clean up provider state")?;
|
|
|
|
let provider_ids = [req.provider_id.clone()];
|
|
let status = Self::provider_config_status(req.provider_id.clone()).await;
|
|
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
|
Ok(ProviderConfigChangeResponse { status, refresh })
|
|
}
|
|
|
|
pub(super) async fn on_authenticate_provider_config(
|
|
&self,
|
|
req: ProviderConfigAuthenticateRequest,
|
|
) -> Result<ProviderConfigChangeResponse, agent_client_protocol::Error> {
|
|
let entry = crate::providers::get_from_registry(&req.provider_id)
|
|
.await
|
|
.invalid_params_err_ctx("Unknown provider")?;
|
|
let metadata = entry.metadata().clone();
|
|
if !metadata.config_keys.iter().any(|key| key.oauth_flow) {
|
|
return Err(agent_client_protocol::Error::invalid_params().data(format!(
|
|
"Provider does not support native authentication: {}",
|
|
req.provider_id
|
|
)));
|
|
}
|
|
|
|
let provider = entry
|
|
.create_with_default_model(Vec::new())
|
|
.await
|
|
.internal_err_ctx("Failed to initialize provider")?;
|
|
provider
|
|
.configure_oauth()
|
|
.await
|
|
.internal_err_ctx("Failed to authenticate provider")?;
|
|
Config::global().invalidate_secrets_cache();
|
|
|
|
let provider_ids = [req.provider_id.clone()];
|
|
let status = Self::provider_config_status(req.provider_id.clone()).await;
|
|
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
|
|
Ok(ProviderConfigChangeResponse { status, refresh })
|
|
}
|
|
}
|