feat: goose2 add support for custom providers in ui & acp (#8924)
This commit is contained in:
@@ -2093,7 +2093,7 @@ fn add_provider() -> anyhow::Result<()> {
|
||||
engine: provider_type.to_string(),
|
||||
display_name: display_name.clone(),
|
||||
api_url,
|
||||
api_key,
|
||||
api_key: requires_auth.then_some(api_key),
|
||||
models,
|
||||
supports_streaming: Some(supports_streaming),
|
||||
headers,
|
||||
|
||||
@@ -384,6 +384,207 @@ pub struct ProviderConfigChangeResponse {
|
||||
pub refresh: RefreshProviderInventoryResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderCatalogEntryDto {
|
||||
pub provider_id: String,
|
||||
pub name: String,
|
||||
pub format: String,
|
||||
pub api_url: String,
|
||||
pub model_count: usize,
|
||||
pub doc_url: String,
|
||||
pub env_var: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderTemplateCapabilitiesDto {
|
||||
pub tool_call: bool,
|
||||
pub reasoning: bool,
|
||||
pub attachment: bool,
|
||||
pub temperature: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderTemplateModelDto {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub context_limit: usize,
|
||||
pub capabilities: ProviderTemplateCapabilitiesDto,
|
||||
pub deprecated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderTemplateDto {
|
||||
pub provider_id: String,
|
||||
pub name: String,
|
||||
pub format: String,
|
||||
pub api_url: String,
|
||||
pub models: Vec<ProviderTemplateModelDto>,
|
||||
pub supports_streaming: bool,
|
||||
pub env_var: String,
|
||||
pub doc_url: String,
|
||||
}
|
||||
|
||||
/// List custom-provider catalog entries. Omit `format` to list all formats.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/catalog/list",
|
||||
response = ProviderCatalogListResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderCatalogListRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderCatalogListResponse {
|
||||
pub providers: Vec<ProviderCatalogEntryDto>,
|
||||
}
|
||||
|
||||
/// Return the editable template for one catalog provider.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/catalog/template",
|
||||
response = ProviderCatalogTemplateResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderCatalogTemplateRequest {
|
||||
pub provider_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderCatalogTemplateResponse {
|
||||
pub template: ProviderTemplateDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderConfigDto {
|
||||
pub provider_id: String,
|
||||
pub engine: String,
|
||||
pub display_name: String,
|
||||
pub api_url: String,
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
pub requires_auth: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog_provider_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key_env: Option<String>,
|
||||
pub api_key_set: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderUpsertDto {
|
||||
pub engine: String,
|
||||
pub display_name: String,
|
||||
pub api_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub models: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub supports_streaming: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub headers: HashMap<String, String>,
|
||||
pub requires_auth: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub catalog_provider_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_path: Option<String>,
|
||||
}
|
||||
|
||||
/// Create a custom provider backed by Goose's declarative provider store.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/custom/create",
|
||||
response = CustomProviderCreateResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderCreateRequest {
|
||||
#[serde(flatten)]
|
||||
pub provider: CustomProviderUpsertDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderCreateResponse {
|
||||
pub provider_id: String,
|
||||
pub status: ProviderConfigStatusDto,
|
||||
pub refresh: RefreshProviderInventoryResponse,
|
||||
}
|
||||
|
||||
/// Read a declarative provider config. Custom configs are editable; bundled configs are read-only.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/custom/read",
|
||||
response = CustomProviderReadResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderReadRequest {
|
||||
pub provider_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderReadResponse {
|
||||
pub provider: CustomProviderConfigDto,
|
||||
pub editable: bool,
|
||||
pub status: ProviderConfigStatusDto,
|
||||
}
|
||||
|
||||
/// Update a custom provider backed by Goose's declarative provider store.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/custom/update",
|
||||
response = CustomProviderUpdateResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderUpdateRequest {
|
||||
pub provider_id: String,
|
||||
#[serde(flatten)]
|
||||
pub provider: CustomProviderUpsertDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderUpdateResponse {
|
||||
pub provider_id: String,
|
||||
pub status: ProviderConfigStatusDto,
|
||||
pub refresh: RefreshProviderInventoryResponse,
|
||||
}
|
||||
|
||||
/// Delete a custom provider from Goose's declarative provider store.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(
|
||||
method = "_goose/providers/custom/delete",
|
||||
response = CustomProviderDeleteResponse
|
||||
)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderDeleteRequest {
|
||||
pub provider_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProviderDeleteResponse {
|
||||
pub provider_id: String,
|
||||
pub refresh: RefreshProviderInventoryResponse,
|
||||
}
|
||||
|
||||
/// The type of source entity.
|
||||
#[derive(
|
||||
Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
|
||||
|
||||
@@ -108,6 +108,11 @@ fn default_requires_auth() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn normalize_custom_provider_api_key(api_key: String) -> Option<String> {
|
||||
let api_key = api_key.trim().to_string();
|
||||
(!api_key.is_empty()).then_some(api_key)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CheckProviderRequest {
|
||||
pub provider: String,
|
||||
@@ -583,7 +588,7 @@ pub async fn create_custom_provider(
|
||||
engine: request.engine,
|
||||
display_name: request.display_name,
|
||||
api_url: request.api_url,
|
||||
api_key: request.api_key,
|
||||
api_key: normalize_custom_provider_api_key(request.api_key),
|
||||
models: request.models,
|
||||
supports_streaming: request.supports_streaming,
|
||||
headers: request.headers,
|
||||
@@ -675,7 +680,7 @@ pub async fn update_custom_provider(
|
||||
engine: request.engine,
|
||||
display_name: request.display_name,
|
||||
api_url: request.api_url,
|
||||
api_key: request.api_key,
|
||||
api_key: normalize_custom_provider_api_key(request.api_key),
|
||||
models: request.models,
|
||||
supports_streaming: request.supports_streaming,
|
||||
headers: request.headers,
|
||||
|
||||
@@ -60,6 +60,36 @@
|
||||
"requestType": "ListProvidersRequest",
|
||||
"responseType": "ListProvidersResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/catalog/list",
|
||||
"requestType": "ProviderCatalogListRequest",
|
||||
"responseType": "ProviderCatalogListResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/catalog/template",
|
||||
"requestType": "ProviderCatalogTemplateRequest",
|
||||
"responseType": "ProviderCatalogTemplateResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/custom/create",
|
||||
"requestType": "CustomProviderCreateRequest",
|
||||
"responseType": "CustomProviderCreateResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/custom/read",
|
||||
"requestType": "CustomProviderReadRequest",
|
||||
"responseType": "CustomProviderReadResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/custom/update",
|
||||
"requestType": "CustomProviderUpdateRequest",
|
||||
"responseType": "CustomProviderUpdateResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/custom/delete",
|
||||
"requestType": "CustomProviderDeleteRequest",
|
||||
"responseType": "CustomProviderDeleteResponse"
|
||||
},
|
||||
{
|
||||
"method": "_goose/providers/inventory/refresh",
|
||||
"requestType": "RefreshProviderInventoryRequest",
|
||||
|
||||
+617
-20
@@ -471,21 +471,291 @@
|
||||
],
|
||||
"description": "A single model in provider inventory."
|
||||
},
|
||||
"RefreshProviderInventoryRequest": {
|
||||
"ProviderCatalogListRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerIds": {
|
||||
"format": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "List custom-provider catalog entries. Omit `format` to list all formats.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/catalog/list"
|
||||
},
|
||||
"ProviderCatalogListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ProviderCatalogEntryDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providers"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/catalog/list"
|
||||
},
|
||||
"ProviderCatalogEntryDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelCount": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"docUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"envVar": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"name",
|
||||
"format",
|
||||
"apiUrl",
|
||||
"modelCount",
|
||||
"docUrl",
|
||||
"envVar"
|
||||
]
|
||||
},
|
||||
"ProviderCatalogTemplateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId"
|
||||
],
|
||||
"description": "Return the editable template for one catalog provider.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/catalog/template"
|
||||
},
|
||||
"ProviderCatalogTemplateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template": {
|
||||
"$ref": "#/$defs/ProviderTemplateDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"template"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/catalog/template"
|
||||
},
|
||||
"ProviderTemplateDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ProviderTemplateModelDto"
|
||||
}
|
||||
},
|
||||
"supportsStreaming": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"envVar": {
|
||||
"type": "string"
|
||||
},
|
||||
"docUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"name",
|
||||
"format",
|
||||
"apiUrl",
|
||||
"models",
|
||||
"supportsStreaming",
|
||||
"envVar",
|
||||
"docUrl"
|
||||
]
|
||||
},
|
||||
"ProviderTemplateModelDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"contextLimit": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"capabilities": {
|
||||
"$ref": "#/$defs/ProviderTemplateCapabilitiesDto"
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"contextLimit",
|
||||
"capabilities",
|
||||
"deprecated"
|
||||
]
|
||||
},
|
||||
"ProviderTemplateCapabilitiesDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"toolCall": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"attachment": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"toolCall",
|
||||
"reasoning",
|
||||
"attachment",
|
||||
"temperature"
|
||||
]
|
||||
},
|
||||
"CustomProviderCreateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"engine": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiKey": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Which providers to refresh. Empty means all known providers.",
|
||||
"default": []
|
||||
},
|
||||
"supportsStreaming": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"requiresAuth": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogProviderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"basePath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"description": "Trigger a background refresh of provider inventories.",
|
||||
"required": [
|
||||
"engine",
|
||||
"displayName",
|
||||
"apiUrl",
|
||||
"requiresAuth"
|
||||
],
|
||||
"description": "Create a custom provider backed by Goose's declarative provider store.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/inventory/refresh"
|
||||
"x-method": "_goose/providers/custom/create"
|
||||
},
|
||||
"CustomProviderCreateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/ProviderConfigStatusDto"
|
||||
},
|
||||
"refresh": {
|
||||
"$ref": "#/$defs/RefreshProviderInventoryResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status",
|
||||
"refresh"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/create"
|
||||
},
|
||||
"ProviderConfigStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isConfigured": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"isConfigured"
|
||||
]
|
||||
},
|
||||
"RefreshProviderInventoryResponse": {
|
||||
"type": "object",
|
||||
@@ -537,6 +807,246 @@
|
||||
"already_refreshing"
|
||||
]
|
||||
},
|
||||
"CustomProviderReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId"
|
||||
],
|
||||
"description": "Read a declarative provider config. Custom configs are editable; bundled configs are read-only.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/read"
|
||||
},
|
||||
"CustomProviderReadResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"$ref": "#/$defs/CustomProviderConfigDto"
|
||||
},
|
||||
"editable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/ProviderConfigStatusDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"provider",
|
||||
"editable",
|
||||
"status"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/read"
|
||||
},
|
||||
"CustomProviderConfigDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"engine": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"supportsStreaming": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"requiresAuth": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogProviderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"basePath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"apiKeyEnv": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"apiKeySet": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"engine",
|
||||
"displayName",
|
||||
"apiUrl",
|
||||
"requiresAuth",
|
||||
"apiKeySet"
|
||||
]
|
||||
},
|
||||
"CustomProviderUpdateRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"engine": {
|
||||
"type": "string"
|
||||
},
|
||||
"displayName": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"apiKey": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"supportsStreaming": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": {}
|
||||
},
|
||||
"requiresAuth": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogProviderId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"basePath": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"engine",
|
||||
"displayName",
|
||||
"apiUrl",
|
||||
"requiresAuth"
|
||||
],
|
||||
"description": "Update a custom provider backed by Goose's declarative provider store.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/update"
|
||||
},
|
||||
"CustomProviderUpdateResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/$defs/ProviderConfigStatusDto"
|
||||
},
|
||||
"refresh": {
|
||||
"$ref": "#/$defs/RefreshProviderInventoryResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"status",
|
||||
"refresh"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/update"
|
||||
},
|
||||
"CustomProviderDeleteRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId"
|
||||
],
|
||||
"description": "Delete a custom provider from Goose's declarative provider store.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/delete"
|
||||
},
|
||||
"CustomProviderDeleteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"$ref": "#/$defs/RefreshProviderInventoryResponse"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"refresh"
|
||||
],
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/custom/delete"
|
||||
},
|
||||
"RefreshProviderInventoryRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Which providers to refresh. Empty means all known providers.",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"description": "Trigger a background refresh of provider inventories.",
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/inventory/refresh"
|
||||
},
|
||||
"ProviderConfigReadRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -628,21 +1138,6 @@
|
||||
"x-side": "agent",
|
||||
"x-method": "_goose/providers/config/status"
|
||||
},
|
||||
"ProviderConfigStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerId": {
|
||||
"type": "string"
|
||||
},
|
||||
"isConfigured": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"providerId",
|
||||
"isConfigured"
|
||||
]
|
||||
},
|
||||
"ProviderConfigSaveRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1682,6 +2177,60 @@
|
||||
"description": "Params for _goose/providers/list",
|
||||
"title": "ListProvidersRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderCatalogListRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/catalog/list",
|
||||
"title": "ProviderCatalogListRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderCatalogTemplateRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/catalog/template",
|
||||
"title": "ProviderCatalogTemplateRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderCreateRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/custom/create",
|
||||
"title": "CustomProviderCreateRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderReadRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/custom/read",
|
||||
"title": "CustomProviderReadRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderUpdateRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/custom/update",
|
||||
"title": "CustomProviderUpdateRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderDeleteRequest"
|
||||
}
|
||||
],
|
||||
"description": "Params for _goose/providers/custom/delete",
|
||||
"title": "CustomProviderDeleteRequest"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
@@ -2039,6 +2588,54 @@
|
||||
],
|
||||
"title": "ListProvidersResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderCatalogListResponse"
|
||||
}
|
||||
],
|
||||
"title": "ProviderCatalogListResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/ProviderCatalogTemplateResponse"
|
||||
}
|
||||
],
|
||||
"title": "ProviderCatalogTemplateResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderCreateResponse"
|
||||
}
|
||||
],
|
||||
"title": "CustomProviderCreateResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderReadResponse"
|
||||
}
|
||||
],
|
||||
"title": "CustomProviderReadResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderUpdateResponse"
|
||||
}
|
||||
],
|
||||
"title": "CustomProviderUpdateResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/$defs/CustomProviderDeleteResponse"
|
||||
}
|
||||
],
|
||||
"title": "CustomProviderDeleteResponse"
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -104,6 +104,54 @@ impl GooseAcpAgent {
|
||||
self.on_list_providers(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ProviderCatalogListRequest)]
|
||||
async fn dispatch_list_provider_catalog(
|
||||
&self,
|
||||
req: ProviderCatalogListRequest,
|
||||
) -> Result<ProviderCatalogListResponse, sacp::Error> {
|
||||
self.on_list_provider_catalog(req).await
|
||||
}
|
||||
|
||||
#[custom_method(ProviderCatalogTemplateRequest)]
|
||||
async fn dispatch_get_provider_catalog_template(
|
||||
&self,
|
||||
req: ProviderCatalogTemplateRequest,
|
||||
) -> Result<ProviderCatalogTemplateResponse, sacp::Error> {
|
||||
self.on_get_provider_catalog_template(req).await
|
||||
}
|
||||
|
||||
#[custom_method(CustomProviderCreateRequest)]
|
||||
async fn dispatch_create_custom_provider(
|
||||
&self,
|
||||
req: CustomProviderCreateRequest,
|
||||
) -> Result<CustomProviderCreateResponse, sacp::Error> {
|
||||
self.on_create_custom_provider(req).await
|
||||
}
|
||||
|
||||
#[custom_method(CustomProviderReadRequest)]
|
||||
async fn dispatch_read_custom_provider(
|
||||
&self,
|
||||
req: CustomProviderReadRequest,
|
||||
) -> Result<CustomProviderReadResponse, sacp::Error> {
|
||||
self.on_read_custom_provider(req).await
|
||||
}
|
||||
|
||||
#[custom_method(CustomProviderUpdateRequest)]
|
||||
async fn dispatch_update_custom_provider(
|
||||
&self,
|
||||
req: CustomProviderUpdateRequest,
|
||||
) -> Result<CustomProviderUpdateResponse, sacp::Error> {
|
||||
self.on_update_custom_provider(req).await
|
||||
}
|
||||
|
||||
#[custom_method(CustomProviderDeleteRequest)]
|
||||
async fn dispatch_delete_custom_provider(
|
||||
&self,
|
||||
req: CustomProviderDeleteRequest,
|
||||
) -> Result<CustomProviderDeleteResponse, sacp::Error> {
|
||||
self.on_delete_custom_provider(req).await
|
||||
}
|
||||
|
||||
#[custom_method(RefreshProviderInventoryRequest)]
|
||||
async fn dispatch_refresh_provider_inventory(
|
||||
&self,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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);
|
||||
@@ -110,6 +112,197 @@ fn provider_config_field_value(
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_catalog_entry_to_dto(
|
||||
entry: crate::providers::catalog::ProviderCatalogEntry,
|
||||
) -> ProviderCatalogEntryDto {
|
||||
ProviderCatalogEntryDto {
|
||||
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_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, sacp::Error> {
|
||||
let engine = engine.trim().to_lowercase();
|
||||
if declarative_providers::ProviderEngine::from_str(&engine).is_err() {
|
||||
return Err(sacp::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, sacp::Error> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(sacp::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, sacp::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(|_| sacp::Error::invalid_params().data("apiUrl must be a valid URL"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return Err(sacp::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(sacp::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(sacp::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(|_| {
|
||||
sacp::Error::invalid_params().data(format!("Invalid header name: {key}"))
|
||||
})?;
|
||||
reqwest::header::HeaderValue::from_str(&value).map_err(|_| {
|
||||
sacp::Error::invalid_params().data(format!("Invalid header value for: {key}"))
|
||||
})?;
|
||||
Ok(Some((key, value)))
|
||||
})
|
||||
.collect::<Result<Vec<_>, sacp::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, sacp::Error> {
|
||||
declarative_providers::load_provider(provider_id).map_err(|error| {
|
||||
if error.to_string().contains("Provider not found") {
|
||||
sacp::Error::invalid_params().data(format!("Unknown provider: {provider_id}"))
|
||||
} else if error.to_string().contains("Invalid provider id") {
|
||||
sacp::Error::invalid_params().data(error.to_string())
|
||||
} else {
|
||||
sacp::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,
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_skip_reason_to_dto(reason: RefreshSkipReason) -> RefreshProviderInventorySkipReasonDto {
|
||||
match reason {
|
||||
RefreshSkipReason::UnknownProvider => {
|
||||
@@ -154,6 +347,196 @@ impl GooseAcpAgent {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn on_list_provider_catalog(
|
||||
&self,
|
||||
req: ProviderCatalogListRequest,
|
||||
) -> Result<ProviderCatalogListResponse, sacp::Error> {
|
||||
let formats = match req.format {
|
||||
Some(format) => vec![format
|
||||
.parse::<crate::providers::catalog::ProviderFormat>()
|
||||
.map_err(|error| sacp::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_get_provider_catalog_template(
|
||||
&self,
|
||||
req: ProviderCatalogTemplateRequest,
|
||||
) -> Result<ProviderCatalogTemplateResponse, sacp::Error> {
|
||||
let template = crate::providers::catalog::get_provider_template(&req.provider_id)
|
||||
.ok_or_else(|| {
|
||||
sacp::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, sacp::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,
|
||||
},
|
||||
)
|
||||
.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, sacp::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, sacp::Error> {
|
||||
let loaded = load_declarative_provider_for_client(&req.provider_id)?;
|
||||
if !loaded.is_editable {
|
||||
return Err(sacp::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(sacp::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,
|
||||
},
|
||||
)
|
||||
.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, sacp::Error> {
|
||||
let loaded = load_declarative_provider_for_client(&req.provider_id)?;
|
||||
if !loaded.is_editable {
|
||||
return Err(sacp::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(sacp::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) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ use anyhow::Result;
|
||||
use include_dir::{include_dir, Dir};
|
||||
use once_cell::sync::Lazy;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Deserialize an optional string, treating empty/whitespace-only values as None.
|
||||
fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
@@ -19,7 +20,7 @@ where
|
||||
Ok(opt.filter(|s| !s.trim().is_empty()))
|
||||
}
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
@@ -37,6 +38,19 @@ pub enum ProviderEngine {
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl FromStr for ProviderEngine {
|
||||
type Err = anyhow::Error;
|
||||
|
||||
fn from_str(engine: &str) -> Result<Self> {
|
||||
match engine.trim().to_lowercase().as_str() {
|
||||
"openai" | "openai_compatible" => Ok(Self::OpenAI),
|
||||
"anthropic" | "anthropic_compatible" => Ok(Self::Anthropic),
|
||||
"ollama" | "ollama_compatible" => Ok(Self::Ollama),
|
||||
_ => Err(anyhow::anyhow!("Invalid provider type: {}", engine)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
pub struct EnvVarConfig {
|
||||
pub name: String,
|
||||
@@ -147,7 +161,19 @@ static ID_GENERATION_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||
pub fn generate_id(display_name: &str) -> String {
|
||||
let _guard = ID_GENERATION_LOCK.lock().unwrap();
|
||||
|
||||
let normalized = display_name.to_lowercase().replace(' ', "_");
|
||||
let normalized = display_name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_matches('_')
|
||||
.to_string();
|
||||
let base_id = format!("custom_{}", normalized);
|
||||
|
||||
let custom_dir = custom_providers_dir();
|
||||
@@ -162,6 +188,40 @@ pub fn generate_id(display_name: &str) -> String {
|
||||
candidate_id
|
||||
}
|
||||
|
||||
pub fn validate_provider_id(id: &str) -> Result<()> {
|
||||
let mut chars = id.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid provider id: provider id cannot be empty"
|
||||
));
|
||||
};
|
||||
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit() || first == '_') {
|
||||
return Err(anyhow::anyhow!("Invalid provider id: {}", id));
|
||||
}
|
||||
|
||||
if chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Invalid provider id: {}", id))
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_provider_file_path(id: &str) -> Result<PathBuf> {
|
||||
if id.is_empty()
|
||||
|| id
|
||||
.chars()
|
||||
.any(|ch| ch == '/' || ch == '\\' || ch.is_control())
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Invalid provider id: {}",
|
||||
if id.is_empty() { "<empty>" } else { id }
|
||||
));
|
||||
}
|
||||
|
||||
Ok(custom_providers_dir().join(format!("{}.json", id)))
|
||||
}
|
||||
|
||||
pub fn generate_api_key_name(id: &str) -> String {
|
||||
format!("{}_API_KEY", id.to_uppercase())
|
||||
}
|
||||
@@ -171,7 +231,7 @@ pub struct CreateCustomProviderParams {
|
||||
pub engine: String,
|
||||
pub display_name: String,
|
||||
pub api_url: String,
|
||||
pub api_key: String,
|
||||
pub api_key: Option<String>,
|
||||
pub models: Vec<String>,
|
||||
pub supports_streaming: Option<bool>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
@@ -186,7 +246,7 @@ pub struct UpdateCustomProviderParams {
|
||||
pub engine: String,
|
||||
pub display_name: String,
|
||||
pub api_url: String,
|
||||
pub api_key: String,
|
||||
pub api_key: Option<String>,
|
||||
pub models: Vec<String>,
|
||||
pub supports_streaming: Option<bool>,
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
@@ -199,11 +259,17 @@ pub fn create_custom_provider(
|
||||
params: CreateCustomProviderParams,
|
||||
) -> Result<DeclarativeProviderConfig> {
|
||||
let id = generate_id(¶ms.display_name);
|
||||
validate_provider_id(&id)?;
|
||||
|
||||
let api_key_env = if params.requires_auth {
|
||||
let api_key = params
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|api_key| !api_key.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("apiKey cannot be empty"))?;
|
||||
let api_key_name = generate_api_key_name(&id);
|
||||
let config = Config::global();
|
||||
config.set_secret(&api_key_name, ¶ms.api_key)?;
|
||||
config.set_secret(&api_key_name, &api_key)?;
|
||||
api_key_name
|
||||
} else {
|
||||
String::new()
|
||||
@@ -217,12 +283,7 @@ pub fn create_custom_provider(
|
||||
|
||||
let provider_config = DeclarativeProviderConfig {
|
||||
name: 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: {}", params.engine)),
|
||||
},
|
||||
engine: ProviderEngine::from_str(¶ms.engine)?,
|
||||
display_name: params.display_name.clone(),
|
||||
description: Some(format!("Custom {} provider", params.display_name)),
|
||||
api_key_env,
|
||||
@@ -258,18 +319,24 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
|
||||
let editable = loaded_provider.is_editable;
|
||||
|
||||
let config = Config::global();
|
||||
|
||||
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)?;
|
||||
if let Some(api_key) = params.api_key.as_deref() {
|
||||
config.set_secret(&api_key_name, &api_key)?;
|
||||
} else if config.get_secret::<String>(&api_key_name).is_err() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"apiKey is required when auth is enabled and no secret is stored"
|
||||
));
|
||||
}
|
||||
api_key_name
|
||||
} else {
|
||||
if existing_config.api_key_env == generate_api_key_name(¶ms.id) {
|
||||
config.delete_secret(&existing_config.api_key_env)?;
|
||||
}
|
||||
String::new()
|
||||
};
|
||||
|
||||
@@ -282,12 +349,7 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
|
||||
|
||||
let updated_config = DeclarativeProviderConfig {
|
||||
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: {}", params.engine)),
|
||||
},
|
||||
engine: ProviderEngine::from_str(¶ms.engine)?,
|
||||
display_name: params.display_name,
|
||||
description: existing_config.description,
|
||||
api_key_env,
|
||||
@@ -311,7 +373,7 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
|
||||
fast_model: existing_config.fast_model.clone(),
|
||||
};
|
||||
|
||||
let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name));
|
||||
let file_path = custom_provider_file_path(&updated_config.name)?;
|
||||
let json_content = serde_json::to_string_pretty(&updated_config)?;
|
||||
std::fs::write(file_path, json_content)?;
|
||||
}
|
||||
@@ -320,11 +382,13 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
|
||||
|
||||
pub fn remove_custom_provider(id: &str) -> Result<()> {
|
||||
let config = Config::global();
|
||||
let api_key_name = generate_api_key_name(id);
|
||||
let _ = config.delete_secret(&api_key_name);
|
||||
let loaded_provider = load_provider(id)?;
|
||||
let api_key_env = loaded_provider.config.api_key_env;
|
||||
if api_key_env == generate_api_key_name(id) {
|
||||
let _ = config.delete_secret(&api_key_env);
|
||||
}
|
||||
|
||||
let custom_providers_dir = custom_providers_dir();
|
||||
let file_path = custom_providers_dir.join(format!("{}.json", id));
|
||||
let file_path = custom_provider_file_path(id)?;
|
||||
|
||||
if file_path.exists() {
|
||||
std::fs::remove_file(file_path)?;
|
||||
@@ -334,7 +398,7 @@ pub fn remove_custom_provider(id: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn load_provider(id: &str) -> Result<LoadedProvider> {
|
||||
let custom_file_path = custom_providers_dir().join(format!("{}.json", id));
|
||||
let custom_file_path = custom_provider_file_path(id)?;
|
||||
|
||||
if custom_file_path.exists() {
|
||||
let content = std::fs::read_to_string(&custom_file_path)?;
|
||||
@@ -624,6 +688,79 @@ mod tests {
|
||||
assert_eq!(config.models[0].context_limit, 131072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_provider_id_rejects_legacy_punctuation_for_new_ids() {
|
||||
assert!(validate_provider_id("custom_z.ai").is_err());
|
||||
}
|
||||
|
||||
fn write_legacy_provider_config(id: &str, display_name: &str) {
|
||||
let custom_dir = custom_providers_dir();
|
||||
std::fs::create_dir_all(&custom_dir).unwrap();
|
||||
let content = format!(
|
||||
r#"{{
|
||||
"name": "{id}",
|
||||
"engine": "openai",
|
||||
"display_name": "{display_name}",
|
||||
"description": "legacy provider",
|
||||
"api_key_env": "",
|
||||
"base_url": "https://example.invalid/v1/chat/completions",
|
||||
"models": [],
|
||||
"requires_auth": false
|
||||
}}"#
|
||||
);
|
||||
std::fs::write(custom_dir.join(format!("{id}.json")), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_provider_allows_legacy_custom_id_with_punctuation() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let temp_root = temp_dir.path().display().to_string();
|
||||
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);
|
||||
|
||||
write_legacy_provider_config("custom_z.ai", "Z.AI");
|
||||
|
||||
let loaded = load_provider("custom_z.ai").unwrap();
|
||||
assert!(loaded.is_editable);
|
||||
assert_eq!(loaded.config.name, "custom_z.ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_and_remove_provider_allow_legacy_custom_id_with_punctuation() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let temp_root = temp_dir.path().display().to_string();
|
||||
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);
|
||||
|
||||
write_legacy_provider_config("custom_z.ai", "Z.AI");
|
||||
|
||||
update_custom_provider(UpdateCustomProviderParams {
|
||||
id: "custom_z.ai".to_string(),
|
||||
engine: "openai".to_string(),
|
||||
display_name: "Z.AI Updated".to_string(),
|
||||
api_url: "https://updated.example.invalid/v1/chat/completions".to_string(),
|
||||
api_key: None,
|
||||
models: vec!["z-model".to_string()],
|
||||
supports_streaming: Some(true),
|
||||
headers: None,
|
||||
requires_auth: false,
|
||||
catalog_provider_id: None,
|
||||
base_path: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let updated = load_provider("custom_z.ai").unwrap();
|
||||
assert_eq!(updated.config.display_name, "Z.AI Updated");
|
||||
assert_eq!(updated.config.models[0].name, "z-model");
|
||||
|
||||
remove_custom_provider("custom_z.ai").unwrap();
|
||||
assert!(!custom_providers_dir().join("custom_z.ai.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_provider_rejects_path_segments() {
|
||||
assert!(load_provider("custom_../secret").is_err());
|
||||
assert!(load_provider("custom_..\\secret").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_env_vars_replaces_placeholder() {
|
||||
let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]);
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
#[allow(dead_code)]
|
||||
#[path = "acp_common_tests/mod.rs"]
|
||||
mod common_tests;
|
||||
|
||||
use common_tests::fixtures::server::AcpServerConnection;
|
||||
use common_tests::fixtures::{run_test, send_custom, Connection, TestConnectionConfig};
|
||||
use goose::config::base::CONFIG_YAML_NAME;
|
||||
use goose::config::declarative_providers::load_provider;
|
||||
use goose::config::paths::Paths;
|
||||
use goose::config::{Config, ConfigError, DeclarativeProviderConfig};
|
||||
use goose_test_support::EnforceSessionId;
|
||||
use serial_test::serial;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn write_config(config_dir: &std::path::Path, contents: &str) {
|
||||
std::fs::create_dir_all(config_dir).unwrap();
|
||||
std::fs::write(config_dir.join(CONFIG_YAML_NAME), contents).unwrap();
|
||||
}
|
||||
|
||||
fn write_secrets(config_dir: &std::path::Path, contents: &str) {
|
||||
std::fs::write(config_dir.join("secrets.yaml"), contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[serial]
|
||||
fn acp_catalog_and_custom_provider_methods_use_core_provider_store() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let root_path = root.path().to_string_lossy().to_string();
|
||||
let _env = env_lock::lock_env([
|
||||
("GOOSE_PATH_ROOT", Some(root_path.as_str())),
|
||||
("GOOSE_DISABLE_KEYRING", Some("1")),
|
||||
("XAI_API_KEY", None),
|
||||
("XAI_HOST", None),
|
||||
("CUSTOM_STARK_ACP_PROVIDER_API_KEY", None),
|
||||
]);
|
||||
|
||||
let config_dir = Paths::config_dir();
|
||||
write_config(
|
||||
&config_dir,
|
||||
"GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\nXAI_HOST: https://api.x.ai/v1\n",
|
||||
);
|
||||
write_secrets(&config_dir, "XAI_API_KEY: xai-configured-key\n");
|
||||
Config::global().invalidate_secrets_cache();
|
||||
|
||||
run_test(async move {
|
||||
let openai = common_tests::fixtures::OpenAiFixture::new(
|
||||
vec![],
|
||||
Arc::new(EnforceSessionId::default()),
|
||||
)
|
||||
.await;
|
||||
let config = TestConnectionConfig {
|
||||
data_root: config_dir.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
let conn = AcpServerConnection::new(config, openai).await;
|
||||
|
||||
let catalog = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/catalog/list",
|
||||
serde_json::json!({ "format": "openai" }),
|
||||
)
|
||||
.await
|
||||
.expect("provider catalog list should succeed");
|
||||
let catalog_providers = catalog
|
||||
.get("providers")
|
||||
.and_then(|providers| providers.as_array())
|
||||
.expect("catalog response should include providers");
|
||||
assert!(
|
||||
catalog_providers
|
||||
.iter()
|
||||
.any(|provider| provider.get("providerId") == Some(&serde_json::json!("zai"))),
|
||||
"OpenAI-compatible catalog should include z.ai"
|
||||
);
|
||||
|
||||
let template = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/catalog/template",
|
||||
serde_json::json!({ "providerId": "zai" }),
|
||||
)
|
||||
.await
|
||||
.expect("provider catalog template should succeed");
|
||||
assert_eq!(
|
||||
template.pointer("/template/providerId"),
|
||||
Some(&serde_json::json!("zai"))
|
||||
);
|
||||
assert!(
|
||||
template
|
||||
.pointer("/template/models")
|
||||
.and_then(|models| models.as_array())
|
||||
.is_some_and(|models| !models.is_empty()),
|
||||
"provider template should expose model templates"
|
||||
);
|
||||
|
||||
let configured_status = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/config/status",
|
||||
serde_json::json!({ "providerIds": ["xai"] }),
|
||||
)
|
||||
.await
|
||||
.expect("provider config status should succeed");
|
||||
assert_eq!(
|
||||
configured_status.pointer("/statuses/0"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": "xai",
|
||||
"isConfigured": true,
|
||||
})),
|
||||
"provider configured through core config should be configured through ACP"
|
||||
);
|
||||
|
||||
let configured_read = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/config/read",
|
||||
serde_json::json!({ "providerId": "xai" }),
|
||||
)
|
||||
.await
|
||||
.expect("provider config read should succeed");
|
||||
let fields = configured_read
|
||||
.get("fields")
|
||||
.and_then(|fields| fields.as_array())
|
||||
.expect("provider config read should include fields");
|
||||
let xai_key = fields
|
||||
.iter()
|
||||
.find(|field| field.get("key") == Some(&serde_json::json!("XAI_API_KEY")))
|
||||
.expect("provider config read should include XAI_API_KEY");
|
||||
assert_eq!(xai_key.get("isSet"), Some(&serde_json::json!(true)));
|
||||
assert_ne!(
|
||||
xai_key.get("value"),
|
||||
Some(&serde_json::json!("xai-configured-key")),
|
||||
"provider config read should not expose raw secret values"
|
||||
);
|
||||
|
||||
Config::global().invalidate_secrets_cache();
|
||||
assert!(Config::global()
|
||||
.get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY")
|
||||
.is_err());
|
||||
|
||||
let created = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/create",
|
||||
serde_json::json!({
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Stark ACP Provider",
|
||||
"apiUrl": "https://stark.example/v1",
|
||||
"apiKey": "created-custom-key",
|
||||
"models": ["stark-1", "stark-2"],
|
||||
"supportsStreaming": true,
|
||||
"headers": {
|
||||
"X-Stark": "enabled"
|
||||
},
|
||||
"requiresAuth": true,
|
||||
"catalogProviderId": "openai",
|
||||
"basePath": "v1/chat/completions"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("custom provider create should succeed");
|
||||
let provider_id = created
|
||||
.get("providerId")
|
||||
.and_then(|provider_id| provider_id.as_str())
|
||||
.expect("custom provider create should return providerId")
|
||||
.to_string();
|
||||
assert_eq!(provider_id, "custom_stark_acp_provider");
|
||||
assert_eq!(
|
||||
created.get("status"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"isConfigured": true,
|
||||
})),
|
||||
"create should invalidate the secret cache before status checks"
|
||||
);
|
||||
assert_eq!(
|
||||
created.get("refresh"),
|
||||
Some(&serde_json::json!({
|
||||
"started": [],
|
||||
"skipped": [
|
||||
{
|
||||
"providerId": provider_id,
|
||||
"reason": "does_not_support_refresh",
|
||||
},
|
||||
],
|
||||
}))
|
||||
);
|
||||
|
||||
let custom_provider_path = Paths::config_dir()
|
||||
.join("custom_providers")
|
||||
.join(format!("{provider_id}.json"));
|
||||
assert!(
|
||||
custom_provider_path.exists(),
|
||||
"custom provider should be saved in Goose's declarative provider store"
|
||||
);
|
||||
let saved_provider: DeclarativeProviderConfig =
|
||||
serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap())
|
||||
.expect("saved provider should be core-compatible declarative config");
|
||||
assert_eq!(saved_provider.name, provider_id);
|
||||
assert_eq!(saved_provider.display_name, "Stark ACP Provider");
|
||||
assert_eq!(saved_provider.base_url, "https://stark.example/v1");
|
||||
assert_eq!(
|
||||
saved_provider
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["stark-1", "stark-2"]
|
||||
);
|
||||
assert_eq!(
|
||||
Config::global()
|
||||
.get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY")
|
||||
.unwrap(),
|
||||
"created-custom-key",
|
||||
"custom provider create should write through Goose's config store"
|
||||
);
|
||||
assert!(
|
||||
load_provider(&provider_id)
|
||||
.expect("core should load the ACP-created custom provider")
|
||||
.is_editable
|
||||
);
|
||||
|
||||
let read = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/read",
|
||||
serde_json::json!({ "providerId": provider_id }),
|
||||
)
|
||||
.await
|
||||
.expect("custom provider read should succeed");
|
||||
assert_eq!(read.get("editable"), Some(&serde_json::json!(true)));
|
||||
assert_eq!(
|
||||
read.pointer("/provider"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Stark ACP Provider",
|
||||
"apiUrl": "https://stark.example/v1",
|
||||
"models": ["stark-1", "stark-2"],
|
||||
"supportsStreaming": true,
|
||||
"headers": {
|
||||
"X-Stark": "enabled"
|
||||
},
|
||||
"requiresAuth": true,
|
||||
"catalogProviderId": "openai",
|
||||
"basePath": "v1/chat/completions",
|
||||
"apiKeyEnv": "CUSTOM_STARK_ACP_PROVIDER_API_KEY",
|
||||
"apiKeySet": true,
|
||||
}))
|
||||
);
|
||||
|
||||
let inventory = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/list",
|
||||
serde_json::json!({ "providerIds": [provider_id] }),
|
||||
)
|
||||
.await
|
||||
.expect("provider inventory list should include custom provider");
|
||||
assert_eq!(
|
||||
inventory.pointer("/entries/0/providerType"),
|
||||
Some(&serde_json::json!("Custom"))
|
||||
);
|
||||
assert_eq!(
|
||||
inventory.pointer("/entries/0/providerId"),
|
||||
Some(&serde_json::json!(provider_id))
|
||||
);
|
||||
|
||||
let updated = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/update",
|
||||
serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"engine": "openai",
|
||||
"displayName": "Stark ACP Provider Updated",
|
||||
"apiUrl": "https://stark.example/openai",
|
||||
"apiKey": "updated-custom-key",
|
||||
"models": ["stark-3"],
|
||||
"supportsStreaming": false,
|
||||
"headers": {},
|
||||
"requiresAuth": true,
|
||||
"catalogProviderId": "zai"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("custom provider update should succeed");
|
||||
assert_eq!(
|
||||
updated.get("status"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"isConfigured": true,
|
||||
})),
|
||||
"update should invalidate the secret cache before status checks"
|
||||
);
|
||||
assert_eq!(
|
||||
Config::global()
|
||||
.get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY")
|
||||
.unwrap(),
|
||||
"updated-custom-key",
|
||||
"custom provider update should write through Goose's config store"
|
||||
);
|
||||
let updated_provider: DeclarativeProviderConfig =
|
||||
serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap())
|
||||
.expect("updated provider should remain core-compatible");
|
||||
assert_eq!(updated_provider.display_name, "Stark ACP Provider Updated");
|
||||
assert_eq!(updated_provider.base_url, "https://stark.example/openai");
|
||||
assert_eq!(
|
||||
updated_provider.catalog_provider_id,
|
||||
Some("zai".to_string())
|
||||
);
|
||||
assert_eq!(updated_provider.base_path, None);
|
||||
assert_eq!(updated_provider.headers, None);
|
||||
assert_eq!(
|
||||
updated_provider
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["stark-3"]
|
||||
);
|
||||
|
||||
let auth_disabled = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/update",
|
||||
serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Stark ACP Provider No Auth",
|
||||
"apiUrl": "https://stark.example/openai",
|
||||
"apiKey": "",
|
||||
"models": ["stark-3"],
|
||||
"supportsStreaming": false,
|
||||
"headers": {},
|
||||
"requiresAuth": false,
|
||||
"catalogProviderId": "zai"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("custom provider auth disable should succeed");
|
||||
assert_eq!(
|
||||
auth_disabled.get("status"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"isConfigured": true,
|
||||
})),
|
||||
"auth disable should invalidate the secret cache before status checks"
|
||||
);
|
||||
let no_auth_provider: DeclarativeProviderConfig =
|
||||
serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap())
|
||||
.expect("no-auth provider should remain core-compatible");
|
||||
assert!(!no_auth_provider.requires_auth);
|
||||
assert_eq!(no_auth_provider.api_key_env, "");
|
||||
assert!(
|
||||
matches!(
|
||||
Config::global().get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY"),
|
||||
Err(ConfigError::NotFound(_))
|
||||
),
|
||||
"disabling auth should delete the previously stored API key"
|
||||
);
|
||||
|
||||
let auth_reenabled_without_key = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/update",
|
||||
serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Stark ACP Provider Reauth",
|
||||
"apiUrl": "https://stark.example/openai",
|
||||
"apiKey": "",
|
||||
"models": ["stark-3"],
|
||||
"supportsStreaming": false,
|
||||
"headers": {},
|
||||
"requiresAuth": true,
|
||||
"catalogProviderId": "zai"
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect_err("re-enabling auth without a stored secret should fail");
|
||||
assert!(
|
||||
auth_reenabled_without_key
|
||||
.to_string()
|
||||
.contains("apiKey is required"),
|
||||
"unexpected error: {auth_reenabled_without_key}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
Config::global().get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY"),
|
||||
Err(ConfigError::NotFound(_))
|
||||
),
|
||||
"blank re-enable should not recreate the previous API key"
|
||||
);
|
||||
|
||||
let deleted = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/delete",
|
||||
serde_json::json!({ "providerId": provider_id }),
|
||||
)
|
||||
.await
|
||||
.expect("custom provider delete should succeed");
|
||||
assert_eq!(
|
||||
deleted.pointer("/providerId"),
|
||||
Some(&serde_json::json!(provider_id))
|
||||
);
|
||||
assert_eq!(
|
||||
deleted.get("refresh"),
|
||||
Some(&serde_json::json!({
|
||||
"started": [],
|
||||
"skipped": [],
|
||||
}))
|
||||
);
|
||||
assert!(
|
||||
!custom_provider_path.exists(),
|
||||
"custom provider delete should remove the declarative provider file"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
Config::global().get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY"),
|
||||
Err(ConfigError::NotFound(_))
|
||||
),
|
||||
"custom provider delete should invalidate the secret cache before later reads"
|
||||
);
|
||||
|
||||
let deleted_status = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/config/status",
|
||||
serde_json::json!({ "providerIds": [provider_id] }),
|
||||
)
|
||||
.await
|
||||
.expect("provider config status should succeed after delete");
|
||||
assert_eq!(
|
||||
deleted_status.pointer("/statuses/0"),
|
||||
Some(&serde_json::json!({
|
||||
"providerId": provider_id,
|
||||
"isConfigured": false,
|
||||
}))
|
||||
);
|
||||
|
||||
for invalid_id in [
|
||||
"../escape",
|
||||
"foo/bar",
|
||||
".hidden",
|
||||
"-bad",
|
||||
"",
|
||||
"Uppercase",
|
||||
"has space",
|
||||
] {
|
||||
let read = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/read",
|
||||
serde_json::json!({ "providerId": invalid_id }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
read.is_err(),
|
||||
"invalid provider id should fail: {invalid_id:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for valid_id in ["custom_openai", "openai-compat", "a1"] {
|
||||
assert!(
|
||||
goose::config::declarative_providers::validate_provider_id(valid_id).is_ok(),
|
||||
"provider id should be valid: {valid_id}"
|
||||
);
|
||||
}
|
||||
|
||||
for (name, patch) in [
|
||||
(
|
||||
"ftp URL",
|
||||
serde_json::json!({ "apiUrl": "ftp://example.com" }),
|
||||
),
|
||||
("relative URL", serde_json::json!({ "apiUrl": "/v1" })),
|
||||
("empty models", serde_json::json!({ "models": [] })),
|
||||
("blank models", serde_json::json!({ "models": [" ", "\n"] })),
|
||||
(
|
||||
"invalid header name",
|
||||
serde_json::json!({ "headers": { "Bad Header": "value" } }),
|
||||
),
|
||||
(
|
||||
"invalid header value",
|
||||
serde_json::json!({ "headers": { "X-Test": "bad\r\nvalue" } }),
|
||||
),
|
||||
(
|
||||
"unsupported engine",
|
||||
serde_json::json!({ "engine": "future_engine" }),
|
||||
),
|
||||
] {
|
||||
let mut payload = serde_json::json!({
|
||||
"engine": "openai_compatible",
|
||||
"displayName": format!("Invalid {name}"),
|
||||
"apiUrl": "https://api.example.test/v1",
|
||||
"apiKey": "secret",
|
||||
"models": ["model-a"],
|
||||
"headers": {},
|
||||
"requiresAuth": true
|
||||
});
|
||||
let payload_obj = payload.as_object_mut().unwrap();
|
||||
for (key, value) in patch.as_object().unwrap() {
|
||||
payload_obj.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
let result = send_custom(conn.cx(), "_goose/providers/custom/create", payload).await;
|
||||
assert!(result.is_err(), "{name} should be rejected");
|
||||
}
|
||||
|
||||
Config::global()
|
||||
.set_secret("SHARED_API_KEY", &"shared-secret")
|
||||
.unwrap();
|
||||
|
||||
let shared = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/create",
|
||||
serde_json::json!({
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Shared Secret Test",
|
||||
"apiUrl": "https://api.example.test/v1",
|
||||
"apiKey": "owned-secret",
|
||||
"models": ["model-a"],
|
||||
"headers": {},
|
||||
"requiresAuth": true
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("shared-secret provider create should succeed");
|
||||
let shared_id = shared
|
||||
.get("providerId")
|
||||
.and_then(|provider_id| provider_id.as_str())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let shared_path = Paths::config_dir()
|
||||
.join("custom_providers")
|
||||
.join(format!("{shared_id}.json"));
|
||||
let mut shared_config: DeclarativeProviderConfig =
|
||||
serde_json::from_str(&std::fs::read_to_string(&shared_path).unwrap()).unwrap();
|
||||
shared_config.api_key_env = "SHARED_API_KEY".to_string();
|
||||
std::fs::write(
|
||||
&shared_path,
|
||||
serde_json::to_string_pretty(&shared_config).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
Config::global().invalidate_secrets_cache();
|
||||
|
||||
send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/update",
|
||||
serde_json::json!({
|
||||
"providerId": shared_id,
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Shared Secret Test",
|
||||
"apiUrl": "https://api.example.test/v1",
|
||||
"models": ["model-a"],
|
||||
"headers": {},
|
||||
"requiresAuth": false
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("disabling auth should preserve shared secrets");
|
||||
assert_eq!(
|
||||
Config::global()
|
||||
.get_secret::<String>("SHARED_API_KEY")
|
||||
.unwrap(),
|
||||
"shared-secret"
|
||||
);
|
||||
|
||||
let shared_delete = send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/create",
|
||||
serde_json::json!({
|
||||
"engine": "openai_compatible",
|
||||
"displayName": "Shared Secret Delete",
|
||||
"apiUrl": "https://api.example.test/v1",
|
||||
"apiKey": "owned-secret",
|
||||
"models": ["model-a"],
|
||||
"headers": {},
|
||||
"requiresAuth": true
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("shared-delete provider create should succeed");
|
||||
let shared_delete_id = shared_delete
|
||||
.get("providerId")
|
||||
.and_then(|provider_id| provider_id.as_str())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let shared_delete_path = Paths::config_dir()
|
||||
.join("custom_providers")
|
||||
.join(format!("{shared_delete_id}.json"));
|
||||
let mut shared_delete_config: DeclarativeProviderConfig =
|
||||
serde_json::from_str(&std::fs::read_to_string(&shared_delete_path).unwrap()).unwrap();
|
||||
shared_delete_config.api_key_env = "SHARED_API_KEY".to_string();
|
||||
std::fs::write(
|
||||
&shared_delete_path,
|
||||
serde_json::to_string_pretty(&shared_delete_config).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
Config::global().invalidate_secrets_cache();
|
||||
|
||||
send_custom(
|
||||
conn.cx(),
|
||||
"_goose/providers/custom/delete",
|
||||
serde_json::json!({ "providerId": shared_delete_id }),
|
||||
)
|
||||
.await
|
||||
.expect("deleting provider should preserve shared secrets");
|
||||
assert_eq!(
|
||||
Config::global()
|
||||
.get_secret::<String>("SHARED_API_KEY")
|
||||
.unwrap(),
|
||||
"shared-secret"
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user