diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index 2ebe70d3..ed2b4c11 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -427,7 +427,7 @@ pub struct ProviderConfigChangeResponse { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub struct ProviderCatalogEntryDto { +pub struct ProviderTemplateCatalogEntryDto { pub provider_id: String, pub name: String, pub format: String, @@ -437,6 +437,73 @@ pub struct ProviderCatalogEntryDto { pub env_var: String, } +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupCategoryDto { + Agent, + #[default] + Model, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupMethodDto { + None, + SingleApiKey, + ConfigFields, + HostWithOauthFallback, + OauthBrowser, + OauthDeviceCode, + CloudCredentials, + Local, + CliAuth, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupGroupDto { + Default, + Additional, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderSetupFieldDto { + pub key: String, + pub label: String, + pub secret: bool, + pub required: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_value: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProviderSetupCatalogEntryDto { + pub provider_id: String, + pub name: String, + pub category: ProviderSetupCategoryDto, + pub description: String, + pub setup_method: ProviderSetupMethodDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native_connect_query: Option, + #[serde(default)] + pub fields: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub binary_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub doc_url: Option, + pub group: ProviderSetupGroupDto, + pub show_only_when_installed: bool, + #[serde(default)] + pub aliases: Vec, + pub supports_install: bool, + pub supports_auth: bool, + pub supports_auth_status: bool, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProviderTemplateCapabilitiesDto { @@ -484,7 +551,22 @@ pub struct ProviderCatalogListRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[serde(rename_all = "camelCase")] pub struct ProviderCatalogListResponse { - pub providers: Vec, + pub providers: Vec, +} + +/// List provider setup catalog entries +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/providers/setup/catalog/list", + response = ProviderSetupCatalogListResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ProviderSetupCatalogListRequest {} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct ProviderSetupCatalogListResponse { + pub providers: Vec, } /// Return the editable template for one catalog provider. @@ -949,6 +1031,8 @@ pub struct ProviderInventoryEntryDto { pub configured: bool, /// Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`. pub provider_type: String, + /// Whether this inventory entry represents an agent provider or a model provider. + pub category: ProviderSetupCategoryDto, /// Required configuration keys and setup metadata. pub config_keys: Vec, /// Step-by-step setup instructions, when present. diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index db2ad918..619e8519 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -70,6 +70,11 @@ "requestType": "ProviderCatalogListRequest", "responseType": "ProviderCatalogListResponse" }, + { + "method": "_goose/providers/setup/catalog/list", + "requestType": "ProviderSetupCatalogListRequest", + "responseType": "ProviderSetupCatalogListResponse" + }, { "method": "_goose/providers/catalog/template", "requestType": "ProviderCatalogTemplateRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index 59be3b71..c7d9da61 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -351,6 +351,10 @@ "type": "string", "description": "Provider classification such as `Preferred`, `Builtin`, `Declarative`, or `Custom`." }, + "category": { + "$ref": "#/$defs/ProviderSetupCategoryDto", + "description": "Whether this inventory entry represents an agent provider or a model provider." + }, "configKeys": { "type": "array", "items": { @@ -420,6 +424,7 @@ "defaultModel", "configured", "providerType", + "category", "configKeys", "setupSteps", "supportsRefresh", @@ -429,6 +434,13 @@ ], "description": "Provider inventory entry." }, + "ProviderSetupCategoryDto": { + "type": "string", + "enum": [ + "agent", + "model" + ] + }, "ProviderConfigKey": { "type": "object", "properties": { @@ -533,7 +545,7 @@ "providers": { "type": "array", "items": { - "$ref": "#/$defs/ProviderCatalogEntryDto" + "$ref": "#/$defs/ProviderTemplateCatalogEntryDto" } } }, @@ -543,7 +555,7 @@ "x-side": "agent", "x-method": "_goose/providers/catalog/list" }, - "ProviderCatalogEntryDto": { + "ProviderTemplateCatalogEntryDto": { "type": "object", "properties": { "providerId": { @@ -579,6 +591,163 @@ "envVar" ] }, + "ProviderSetupCatalogListRequest": { + "type": "object", + "description": "List provider setup catalog entries", + "x-side": "agent", + "x-method": "_goose/providers/setup/catalog/list" + }, + "ProviderSetupCatalogListResponse": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderSetupCatalogEntryDto" + } + } + }, + "required": [ + "providers" + ], + "x-side": "agent", + "x-method": "_goose/providers/setup/catalog/list" + }, + "ProviderSetupCatalogEntryDto": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "category": { + "$ref": "#/$defs/ProviderSetupCategoryDto" + }, + "description": { + "type": "string" + }, + "setupMethod": { + "$ref": "#/$defs/ProviderSetupMethodDto" + }, + "nativeConnectQuery": { + "type": [ + "string", + "null" + ] + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/$defs/ProviderSetupFieldDto" + }, + "default": [] + }, + "binaryName": { + "type": [ + "string", + "null" + ] + }, + "docUrl": { + "type": [ + "string", + "null" + ] + }, + "group": { + "$ref": "#/$defs/ProviderSetupGroupDto" + }, + "showOnlyWhenInstalled": { + "type": "boolean" + }, + "aliases": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "supportsInstall": { + "type": "boolean" + }, + "supportsAuth": { + "type": "boolean" + }, + "supportsAuthStatus": { + "type": "boolean" + } + }, + "required": [ + "providerId", + "name", + "category", + "description", + "setupMethod", + "group", + "showOnlyWhenInstalled", + "supportsInstall", + "supportsAuth", + "supportsAuthStatus" + ] + }, + "ProviderSetupMethodDto": { + "type": "string", + "enum": [ + "none", + "single_api_key", + "config_fields", + "host_with_oauth_fallback", + "oauth_browser", + "oauth_device_code", + "cloud_credentials", + "local", + "cli_auth" + ] + }, + "ProviderSetupFieldDto": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "secret": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "placeholder": { + "type": [ + "string", + "null" + ] + }, + "defaultValue": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key", + "label", + "secret", + "required" + ] + }, + "ProviderSetupGroupDto": { + "type": "string", + "enum": [ + "default", + "additional" + ] + }, "ProviderCatalogTemplateRequest": { "type": "object", "properties": { @@ -2265,6 +2434,15 @@ "description": "Params for _goose/providers/catalog/list", "title": "ProviderCatalogListRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/ProviderSetupCatalogListRequest" + } + ], + "description": "Params for _goose/providers/setup/catalog/list", + "title": "ProviderSetupCatalogListRequest" + }, { "allOf": [ { @@ -2683,6 +2861,14 @@ ], "title": "ProviderCatalogListResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/ProviderSetupCatalogListResponse" + } + ], + "title": "ProviderSetupCatalogListResponse" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 5d055de8..55aa44a2 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -3969,6 +3969,7 @@ print(\"hello, world\") default_model: "unused".to_string(), configured: true, provider_type: crate::providers::base::ProviderType::Builtin, + category: crate::providers::catalog::ProviderSetupCategory::Model, config_keys: vec![], setup_steps: vec![], supports_refresh: true, diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index 473814ef..ac1deca8 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -120,6 +120,14 @@ impl GooseAcpAgent { self.on_list_provider_catalog(req).await } + #[custom_method(ProviderSetupCatalogListRequest)] + async fn dispatch_list_provider_setup_catalog( + &self, + req: ProviderSetupCatalogListRequest, + ) -> Result { + self.on_list_provider_setup_catalog(req).await + } + #[custom_method(ProviderCatalogTemplateRequest)] async fn dispatch_get_provider_catalog_template( &self, diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index e61c8a4f..52ac1cbe 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -11,6 +11,7 @@ fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEnt 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() @@ -114,8 +115,8 @@ fn provider_config_field_value( fn provider_catalog_entry_to_dto( entry: crate::providers::catalog::ProviderCatalogEntry, -) -> ProviderCatalogEntryDto { - ProviderCatalogEntryDto { +) -> ProviderTemplateCatalogEntryDto { + ProviderTemplateCatalogEntryDto { provider_id: entry.id, name: entry.name, format: entry.format, @@ -126,6 +127,87 @@ fn provider_catalog_entry_to_dto( } } +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 { @@ -380,6 +462,18 @@ impl GooseAcpAgent { Ok(ProviderCatalogListResponse { providers }) } + pub(super) async fn on_list_provider_setup_catalog( + &self, + _req: ProviderSetupCatalogListRequest, + ) -> Result { + 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, diff --git a/crates/goose/src/providers/catalog.rs b/crates/goose/src/providers/catalog.rs index 2418fa84..33ea12d9 100644 --- a/crates/goose/src/providers/catalog.rs +++ b/crates/goose/src/providers/catalog.rs @@ -2,7 +2,10 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::canonical::CanonicalModelRegistry; +use super::{ + base::{ConfigKey, ProviderMetadata}, + canonical::CanonicalModelRegistry, +}; const PROVIDER_METADATA_JSON: &str = include_str!("canonical/data/provider_metadata.json"); @@ -110,6 +113,784 @@ pub struct ModelCapabilities { pub temperature: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupCategory { + Agent, + Model, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupMethod { + None, + SingleApiKey, + ConfigFields, + HostWithOauthFallback, + OauthBrowser, + OauthDeviceCode, + CloudCredentials, + Local, + CliAuth, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProviderSetupGroup { + Default, + Additional, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSetupField { + pub key: String, + pub label: String, + pub secret: bool, + pub required: bool, + pub placeholder: Option, + pub default_value: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct ProviderSetupCapabilities { + pub install: bool, + pub auth: bool, + pub auth_status: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderSetupCatalogEntry { + pub provider_id: String, + pub display_name: String, + pub category: ProviderSetupCategory, + pub description: String, + pub setup_method: ProviderSetupMethod, + pub docs_url: Option, + pub group: ProviderSetupGroup, + pub fields: Vec, + pub aliases: Vec, + pub native_connect_query: Option, + pub binary_name: Option, + pub setup_capabilities: ProviderSetupCapabilities, + pub show_only_when_installed: bool, +} + +#[derive(Debug, Clone, Copy)] +struct CuratedSetupMetadata { + provider_id: &'static str, + category: ProviderSetupCategory, + setup_method: ProviderSetupMethod, + group: ProviderSetupGroup, + display_name: Option<&'static str>, + description: Option<&'static str>, + docs_url: Option<&'static str>, + aliases: &'static [&'static str], + native_connect_query: Option<&'static str>, + binary_name: Option<&'static str>, + setup_capabilities: ProviderSetupCapabilities, + show_only_when_installed: bool, + synthetic: bool, + secret_field_default: Option, + field_overrides: &'static [CuratedFieldMetadata], +} + +#[derive(Debug, Clone, Copy)] +struct CuratedFieldMetadata { + key: &'static str, + label: &'static str, + placeholder: Option<&'static str>, + default_value: Option<&'static str>, +} + +const fn setup_capabilities( + install: bool, + auth: bool, + auth_status: bool, +) -> ProviderSetupCapabilities { + ProviderSetupCapabilities { + install, + auth, + auth_status, + } +} + +const API_KEY_FIELD: CuratedFieldMetadata = CuratedFieldMetadata { + key: "", + label: "API Key", + placeholder: Some("Paste your API key"), + default_value: None, +}; + +const SETUP_METADATA: &[CuratedSetupMetadata] = &[ + CuratedSetupMetadata { + provider_id: "goose", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::None, + group: ProviderSetupGroup::Default, + display_name: Some("Goose"), + description: Some("Block's open-source coding agent"), + docs_url: None, + aliases: &["goose"], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: true, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "claude-acp", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: Some("Claude Code"), + description: Some("Anthropic's agentic coding tool"), + docs_url: Some("https://docs.anthropic.com/en/docs/claude-code"), + aliases: &["claude-acp", "claude_code", "claude"], + native_connect_query: None, + binary_name: Some("claude-agent-acp"), + setup_capabilities: setup_capabilities(true, true, true), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "codex-acp", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: Some("Codex"), + description: Some("OpenAI's coding agent"), + docs_url: Some("https://github.com/openai/codex"), + aliases: &["codex-acp", "codex_cli", "codex"], + native_connect_query: None, + binary_name: Some("codex-acp"), + setup_capabilities: setup_capabilities(true, true, true), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "copilot-acp", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: Some("GitHub Copilot"), + description: Some("GitHub's AI pair programmer"), + docs_url: Some("https://docs.github.com/en/copilot/github-copilot-in-the-cli"), + aliases: &["copilot-acp", "github_copilot", "github_copilot_cli"], + native_connect_query: None, + binary_name: Some("copilot"), + setup_capabilities: setup_capabilities(true, true, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "amp-acp", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Sourcegraph's coding agent"), + docs_url: Some("https://ampcode.com"), + aliases: &["amp-acp", "amp"], + native_connect_query: None, + binary_name: Some("amp-acp"), + setup_capabilities: setup_capabilities(true, true, true), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "cursor-agent", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Cursor's AI agent"), + docs_url: Some("https://docs.cursor.com/en/cli/overview"), + aliases: &["cursor-agent", "cursor_agent", "cursor"], + native_connect_query: None, + binary_name: Some("cursor-agent"), + setup_capabilities: setup_capabilities(true, true, true), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "pi-acp", + category: ProviderSetupCategory::Agent, + setup_method: ProviderSetupMethod::CliAuth, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Open-source AI coding agent"), + docs_url: Some("https://github.com/badlogic/pi-mono"), + aliases: &["pi-acp", "pi"], + native_connect_query: None, + binary_name: Some("pi-acp"), + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: true, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "anthropic", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Claude models"), + docs_url: Some("https://console.anthropic.com/settings/keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "google", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Default, + display_name: Some("Google Gemini"), + description: Some("Gemini models"), + docs_url: Some("https://aistudio.google.com/apikey"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "chatgpt_codex", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::OauthDeviceCode, + group: ProviderSetupGroup::Default, + display_name: Some("ChatGPT"), + description: Some("OpenAI via ChatGPT subscription"), + docs_url: Some("https://chatgpt.com"), + aliases: &[], + native_connect_query: Some("ChatGPT Codex"), + binary_name: None, + setup_capabilities: setup_capabilities(false, true, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "openai", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("GPT and o-series models"), + docs_url: Some("https://platform.openai.com/api-keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "mistral", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Default, + display_name: None, + description: None, + docs_url: Some("https://console.mistral.ai/api-keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "ollama", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Run local or self-hosted models"), + docs_url: Some("https://ollama.com"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[CuratedFieldMetadata { + key: "OLLAMA_HOST", + label: "Host", + placeholder: Some("localhost or http://localhost:11434"), + default_value: Some("http://localhost:11434"), + }], + }, + CuratedSetupMetadata { + provider_id: "openrouter", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Unified API for many models"), + docs_url: Some("https://openrouter.ai/keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "databricks", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::HostWithOauthFallback, + group: ProviderSetupGroup::Default, + display_name: None, + description: Some("Databricks Foundation Models"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, true, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[ + CuratedFieldMetadata { + key: "DATABRICKS_HOST", + label: "Host URL", + placeholder: Some("https://dbc-...cloud.databricks.com"), + default_value: None, + }, + CuratedFieldMetadata { + key: "DATABRICKS_TOKEN", + label: "Access Token", + placeholder: Some("Paste your access token"), + default_value: None, + }, + ], + }, + CuratedSetupMetadata { + provider_id: "github_copilot", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::OauthDeviceCode, + group: ProviderSetupGroup::Default, + display_name: Some("GitHub Copilot Models"), + description: Some("Models via GitHub Copilot subscription"), + docs_url: None, + aliases: &[], + native_connect_query: Some("GitHub Copilot"), + binary_name: None, + setup_capabilities: setup_capabilities(false, true, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "custom_deepseek", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Additional, + display_name: None, + description: Some("DeepSeek chat and reasoning models"), + docs_url: Some("https://platform.deepseek.com/api_keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "xai", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Additional, + display_name: None, + description: Some("Grok models"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "groq", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Additional, + display_name: Some("Groq"), + description: None, + docs_url: Some("https://console.groq.com/keys"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "azure_openai", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Additional, + display_name: None, + description: None, + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[ + CuratedFieldMetadata { + key: "AZURE_OPENAI_ENDPOINT", + label: "Endpoint", + placeholder: Some("https://your-resource.openai.azure.com"), + default_value: None, + }, + CuratedFieldMetadata { + key: "AZURE_OPENAI_DEPLOYMENT_NAME", + label: "Deployment", + placeholder: Some("gpt-4o"), + default_value: None, + }, + CuratedFieldMetadata { + key: "AZURE_OPENAI_API_KEY", + label: "API Key", + placeholder: Some("Paste your API key"), + default_value: None, + }, + ], + }, + CuratedSetupMetadata { + provider_id: "aws_bedrock", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::CloudCredentials, + group: ProviderSetupGroup::Additional, + display_name: Some("AWS Bedrock"), + description: Some("Models on AWS"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[CuratedFieldMetadata { + key: "AWS_REGION", + label: "AWS Region", + placeholder: Some("us-west-2"), + default_value: None, + }], + }, + CuratedSetupMetadata { + provider_id: "gcp_vertex_ai", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::CloudCredentials, + group: ProviderSetupGroup::Additional, + display_name: None, + description: Some("Models on Google Cloud"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[ + CuratedFieldMetadata { + key: "GCP_PROJECT_ID", + label: "Project ID", + placeholder: Some("my-gcp-project"), + default_value: None, + }, + CuratedFieldMetadata { + key: "GCP_LOCATION", + label: "Location", + placeholder: Some("us-central1"), + default_value: None, + }, + ], + }, + CuratedSetupMetadata { + provider_id: "litellm", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Additional, + display_name: None, + description: Some("LiteLLM proxy gateway"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[ + CuratedFieldMetadata { + key: "LITELLM_HOST", + label: "Host URL", + placeholder: Some("https://your-proxy.example.com"), + default_value: None, + }, + CuratedFieldMetadata { + key: "LITELLM_API_KEY", + label: "API Key", + placeholder: Some("Paste your API key"), + default_value: None, + }, + ], + }, + CuratedSetupMetadata { + provider_id: "lmstudio", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Additional, + display_name: None, + description: None, + docs_url: Some("https://lmstudio.ai/docs/app/api"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[CuratedFieldMetadata { + key: "LMSTUDIO_HOST", + label: "Host URL", + placeholder: Some("http://localhost:1234/v1/chat/completions"), + default_value: None, + }], + }, + CuratedSetupMetadata { + provider_id: "nvidia", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Additional, + display_name: None, + description: None, + docs_url: Some("https://build.nvidia.com/models"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "cerebras", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::SingleApiKey, + group: ProviderSetupGroup::Additional, + display_name: None, + description: None, + docs_url: Some("https://cloud.cerebras.ai/platform"), + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: Some(API_KEY_FIELD), + field_overrides: &[], + }, + CuratedSetupMetadata { + provider_id: "snowflake", + category: ProviderSetupCategory::Model, + setup_method: ProviderSetupMethod::ConfigFields, + group: ProviderSetupGroup::Additional, + display_name: None, + description: Some("Snowflake Cortex"), + docs_url: None, + aliases: &[], + native_connect_query: None, + binary_name: None, + setup_capabilities: setup_capabilities(false, false, false), + show_only_when_installed: false, + synthetic: false, + secret_field_default: None, + field_overrides: &[ + CuratedFieldMetadata { + key: "SNOWFLAKE_HOST", + label: "Host URL", + placeholder: Some("https://your-account.snowflakecomputing.com"), + default_value: None, + }, + CuratedFieldMetadata { + key: "SNOWFLAKE_TOKEN", + label: "Access Token", + placeholder: Some("Paste your access token"), + default_value: None, + }, + ], + }, +]; + +fn field_label(key: &str) -> String { + let label = key + .strip_prefix("GOOSE_") + .unwrap_or(key) + .replace('_', " ") + .to_lowercase(); + label + .split_whitespace() + .map(|word| { + if matches!( + word, + "api" | "url" | "id" | "openai" | "aws" | "gcp" | "llm" | "oauth" + ) { + word.to_uppercase() + } else { + let mut chars = word.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + } + }) + .collect::>() + .join(" ") +} + +fn field_override<'a>( + key: &str, + config_key: &ConfigKey, + curated: &'a CuratedSetupMetadata, +) -> Option<&'a CuratedFieldMetadata> { + if let Some(field) = curated + .field_overrides + .iter() + .find(|field| field.key == key) + { + return Some(field); + } + + if config_key.secret { + return curated.secret_field_default.as_ref(); + } + + None +} + +fn setup_field(config_key: &ConfigKey, curated: &CuratedSetupMetadata) -> ProviderSetupField { + let field_override = field_override(&config_key.name, config_key, curated); + ProviderSetupField { + key: config_key.name.clone(), + label: field_override + .map(|field| field.label.to_string()) + .unwrap_or_else(|| field_label(&config_key.name)), + secret: config_key.secret, + required: config_key.required, + placeholder: field_override.and_then(|field| field.placeholder.map(str::to_string)), + default_value: field_override + .and_then(|field| field.default_value.map(str::to_string)) + .or_else(|| config_key.default.clone()), + } +} + +fn setup_entry_from_metadata( + curated: &CuratedSetupMetadata, + metadata: &ProviderMetadata, +) -> ProviderSetupCatalogEntry { + ProviderSetupCatalogEntry { + provider_id: curated.provider_id.to_string(), + display_name: curated + .display_name + .unwrap_or(metadata.display_name.as_str()) + .to_string(), + category: curated.category, + description: curated + .description + .unwrap_or(metadata.description.as_str()) + .to_string(), + setup_method: curated.setup_method, + docs_url: curated.docs_url.map(str::to_string).or_else(|| { + (!metadata.model_doc_link.is_empty()).then(|| metadata.model_doc_link.clone()) + }), + group: curated.group, + fields: metadata + .config_keys + .iter() + .filter(|key| key.primary) + .map(|key| setup_field(key, curated)) + .collect(), + aliases: curated + .aliases + .iter() + .map(|alias| alias.to_string()) + .collect(), + native_connect_query: curated.native_connect_query.map(str::to_string), + binary_name: curated.binary_name.map(str::to_string), + setup_capabilities: curated.setup_capabilities, + show_only_when_installed: curated.show_only_when_installed, + } +} + +fn synthetic_goose_setup_entry(curated: &CuratedSetupMetadata) -> ProviderSetupCatalogEntry { + ProviderSetupCatalogEntry { + provider_id: curated.provider_id.to_string(), + display_name: curated.display_name.unwrap_or("Goose").to_string(), + category: ProviderSetupCategory::Agent, + description: curated.description.unwrap_or_default().to_string(), + setup_method: ProviderSetupMethod::None, + docs_url: curated.docs_url.map(str::to_string), + group: curated.group, + fields: Vec::new(), + aliases: curated + .aliases + .iter() + .map(|alias| alias.to_string()) + .collect(), + native_connect_query: None, + binary_name: None, + setup_capabilities: curated.setup_capabilities, + show_only_when_installed: false, + } +} + pub async fn get_providers_by_format(format: ProviderFormat) -> Vec { let native_provider_ids = super::init::providers() .await @@ -154,6 +935,34 @@ pub async fn get_providers_by_format(format: ProviderFormat) -> Vec Vec { + let registry_metadata = super::providers() + .await + .into_iter() + .map(|(metadata, _)| (metadata.name.clone(), metadata)) + .collect::>(); + + SETUP_METADATA + .iter() + .filter_map(|curated| { + if curated.synthetic { + return Some(synthetic_goose_setup_entry(curated)); + } + + registry_metadata + .get(curated.provider_id) + .map(|metadata| setup_entry_from_metadata(curated, metadata)) + }) + .collect() +} + +pub fn get_provider_setup_category(provider_id: &str) -> Option { + SETUP_METADATA + .iter() + .find(|curated| curated.provider_id == provider_id) + .map(|curated| curated.category) +} + pub fn get_provider_template(provider_id: &str) -> Option { let metadata = PROVIDER_METADATA.get(provider_id)?; @@ -242,4 +1051,62 @@ mod tests { "z.ai template should have models" ); } + + #[tokio::test] + async fn setup_catalog_includes_goose_and_curated_fields() { + let entries = get_setup_catalog_entries().await; + + let goose = entries + .iter() + .find(|entry| entry.provider_id == "goose") + .expect("setup catalog should include synthetic goose"); + assert_eq!(goose.category, ProviderSetupCategory::Agent); + assert_eq!(goose.setup_method, ProviderSetupMethod::None); + assert!(goose.fields.is_empty()); + + let ollama = entries + .iter() + .find(|entry| entry.provider_id == "ollama") + .expect("setup catalog should include ollama"); + assert_eq!(ollama.setup_method, ProviderSetupMethod::ConfigFields); + assert_eq!(ollama.fields.len(), 1); + assert_eq!(ollama.fields[0].key, "OLLAMA_HOST"); + assert_eq!(ollama.fields[0].label, "Host"); + assert_eq!( + ollama.fields[0].default_value.as_deref(), + Some("http://localhost:11434") + ); + + let databricks = entries + .iter() + .find(|entry| entry.provider_id == "databricks") + .expect("setup catalog should include databricks"); + assert_eq!( + databricks.setup_method, + ProviderSetupMethod::HostWithOauthFallback + ); + assert_eq!( + databricks + .fields + .iter() + .map(|field| field.key.as_str()) + .collect::>(), + ["DATABRICKS_HOST", "DATABRICKS_TOKEN"] + ); + } + + #[tokio::test] + async fn setup_catalog_excludes_uncurated_deprecated_providers() { + let provider_ids = get_setup_catalog_entries() + .await + .into_iter() + .map(|entry| entry.provider_id) + .collect::>(); + + assert!(provider_ids.contains("claude-acp")); + assert!(provider_ids.contains("codex-acp")); + assert!(!provider_ids.contains("claude_code")); + assert!(!provider_ids.contains("codex")); + assert!(!provider_ids.contains("gemini_cli")); + } } diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs index 148c6e5b..67d496e5 100644 --- a/crates/goose/src/providers/inventory/mod.rs +++ b/crates/goose/src/providers/inventory/mod.rs @@ -1,5 +1,6 @@ use super::base::{ConfigKey, ModelInfo, ProviderType}; use super::canonical::{map_provider_name, map_to_canonical_model, CanonicalModelRegistry}; +use super::catalog::ProviderSetupCategory; use crate::config::declarative_providers::{DeclarativeProviderConfig, ProviderEngine}; use crate::config::Config; use crate::session::session_manager::SessionStorage; @@ -23,6 +24,7 @@ pub struct ProviderInventoryEntry { pub default_model: String, pub configured: bool, pub provider_type: ProviderType, + pub category: ProviderSetupCategory, pub config_keys: Vec, pub setup_steps: Vec, pub supports_refresh: bool, @@ -248,6 +250,7 @@ struct ProviderDescriptor { identity: InventoryIdentity, configured: bool, provider_type: ProviderType, + category: ProviderSetupCategory, config_keys: Vec, setup_steps: Vec, supports_refresh: bool, @@ -289,6 +292,7 @@ impl ProviderInventoryService { default_model: descriptor.default_model, configured: descriptor.configured, provider_type: descriptor.provider_type, + category: descriptor.category, config_keys: descriptor.config_keys, setup_steps: descriptor.setup_steps, supports_refresh: descriptor.supports_refresh, @@ -582,6 +586,8 @@ impl ProviderInventoryService { identity, configured: entry.inventory_configured(), provider_type: entry.provider_type(), + category: crate::providers::catalog::get_provider_setup_category(&metadata.name) + .unwrap_or(ProviderSetupCategory::Model), config_keys: metadata.config_keys.clone(), setup_steps: metadata.setup_steps.clone(), supports_refresh: entry.supports_inventory_refresh(), diff --git a/crates/goose/tests/acp_custom_provider_methods_test.rs b/crates/goose/tests/acp_custom_provider_methods_test.rs index 7a0d7005..ef8e56cc 100644 --- a/crates/goose/tests/acp_custom_provider_methods_test.rs +++ b/crates/goose/tests/acp_custom_provider_methods_test.rs @@ -72,6 +72,69 @@ fn acp_catalog_and_custom_provider_methods_use_core_provider_store() { "OpenAI-compatible catalog should include z.ai" ); + let setup_catalog = send_custom( + conn.cx(), + "_goose/providers/setup/catalog/list", + serde_json::json!({}), + ) + .await + .expect("provider setup catalog list should succeed"); + let setup_providers = setup_catalog + .get("providers") + .and_then(|providers| providers.as_array()) + .expect("setup catalog response should include providers"); + for provider_id in [ + "goose", + "anthropic", + "openai", + "claude-acp", + "codex-acp", + "copilot-acp", + "amp-acp", + "cursor-agent", + "pi-acp", + ] { + assert!( + setup_providers + .iter() + .any(|provider| provider.get("providerId") + == Some(&serde_json::json!(provider_id))), + "setup catalog should include {provider_id}" + ); + } + for provider_id in ["codex", "claude_code", "gemini_cli"] { + assert!( + setup_providers + .iter() + .all(|provider| provider.get("providerId") + != Some(&serde_json::json!(provider_id))), + "setup catalog should exclude deprecated provider {provider_id}" + ); + } + let codex_setup = setup_providers + .iter() + .find(|provider| provider.get("providerId") == Some(&serde_json::json!("codex-acp"))) + .expect("setup catalog should include codex-acp"); + assert_eq!( + codex_setup.get("category"), + Some(&serde_json::json!("agent")) + ); + assert_eq!( + codex_setup.get("setupMethod"), + Some(&serde_json::json!("cli_auth")) + ); + assert_eq!( + codex_setup.get("supportsInstall"), + Some(&serde_json::json!(true)) + ); + assert!( + codex_setup + .get("aliases") + .and_then(|aliases| aliases.as_array()) + .is_some_and(|aliases| aliases.contains(&serde_json::json!("codex"))), + "codex-acp setup aliases should include codex" + ); + let template = send_custom( conn.cx(), "_goose/providers/catalog/template", diff --git a/ui/goose2/src/app/hooks/useAppStartup.test.ts b/ui/goose2/src/app/hooks/useAppStartup.test.ts new file mode 100644 index 00000000..a8b9e848 --- /dev/null +++ b/ui/goose2/src/app/hooks/useAppStartup.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { filterStartupProvidersForDistro } from "./useAppStartup"; + +const providers = [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, +]; + +describe("filterStartupProvidersForDistro", () => { + it("keeps providers when no allowlist is configured", () => { + expect(filterStartupProvidersForDistro(providers, null, [])).toEqual( + providers, + ); + }); + + it("removes Goose when an allowlist exists but no allowed model provider is known", () => { + expect( + filterStartupProvidersForDistro(providers, new Set(["anthropic"]), []), + ).toEqual([{ id: "codex-acp", label: "Codex" }]); + }); + + it("keeps Goose when an allowed model provider exists", () => { + expect( + filterStartupProvidersForDistro(providers, new Set(["anthropic"]), [ + { id: "anthropic" }, + ]), + ).toEqual(providers); + }); + + it("removes Goose when no model provider is allowed", () => { + expect( + filterStartupProvidersForDistro(providers, new Set(["anthropic"]), [ + { id: "openai" }, + ]), + ).toEqual([{ id: "codex-acp", label: "Codex" }]); + }); +}); diff --git a/ui/goose2/src/app/hooks/useAppStartup.ts b/ui/goose2/src/app/hooks/useAppStartup.ts index a3fcd62e..d0b57137 100644 --- a/ui/goose2/src/app/hooks/useAppStartup.ts +++ b/ui/goose2/src/app/hooks/useAppStartup.ts @@ -2,13 +2,40 @@ import { useEffect } from "react"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; -import { discoverAcpProvidersFromEntries } from "@/shared/api/acp"; +import { + discoverAcpProvidersFromEntries, + type AcpProvider, +} from "@/shared/api/acp"; import { setNotificationHandler, getClient } from "@/shared/api/acpConnection"; import notificationHandler from "@/shared/api/acpNotificationHandler"; import { perfLog } from "@/shared/lib/perfLog"; -import { parseProviderAllowlist } from "@/features/providers/distroProviderConstraints"; +import { + hasAllowedModelProvider, + parseProviderAllowlist, +} from "@/features/providers/distroProviderConstraints"; import { getModelProviders } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { useDistroStore } from "@/features/settings/stores/distroStore"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; + +export function filterStartupProvidersForDistro( + providers: AcpProvider[], + providerAllowlist: Set | null, + modelProviders: Pick[], +): AcpProvider[] { + if (!providerAllowlist) { + return providers; + } + + const shouldKeepGoose = hasAllowedModelProvider( + modelProviders, + providerAllowlist, + ); + + return providers.filter( + (provider) => provider.id !== "goose" || shouldKeepGoose, + ); +} export function useAppStartup() { useEffect(() => { @@ -28,7 +55,26 @@ export function useAppStartup() { const store = useAgentStore.getState(); const inventoryStore = useProviderInventoryStore.getState(); + const catalogStore = useProviderCatalogStore.getState(); const distroStore = useDistroStore.getState(); + + const applyProvidersFromInventory = ( + entries: Parameters[0], + ) => { + const providers = discoverAcpProvidersFromEntries(entries); + const providerAllowlist = parseProviderAllowlist( + useDistroStore.getState().manifest, + ); + store.setProviders( + filterStartupProvidersForDistro( + providers, + providerAllowlist, + getModelProviders(), + ), + ); + return providers; + }; + const loadDistroBundle = async () => { try { const { getDistroBundle } = await import("@/shared/api/distro"); @@ -57,6 +103,24 @@ export function useAppStartup() { } }; + const loadProviderCatalog = async () => { + const t0 = performance.now(); + try { + const entries = await catalogStore.load(); + const inventoryEntries = [ + ...useProviderInventoryStore.getState().entries.values(), + ]; + if (inventoryEntries.length > 0) { + applyProvidersFromInventory(inventoryEntries); + } + perfLog( + `[perf:startup] loadProviderCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${entries.length})`, + ); + } catch (err) { + console.error("Failed to load provider catalog on startup:", err); + } + }; + const loadProvidersAndInventory = async () => { const t0 = performance.now(); store.setProvidersLoading(true); @@ -71,23 +135,7 @@ export function useAppStartup() { inventoryStore.setEntries(entries); // Derive ACP providers from the same response - const providers = discoverAcpProvidersFromEntries(entries); - const providerAllowlist = parseProviderAllowlist( - useDistroStore.getState().manifest, - ); - if (!providerAllowlist) { - store.setProviders(providers); - } else { - const hasAllowedModelProvider = getModelProviders().some( - (provider) => providerAllowlist.has(provider.id), - ); - store.setProviders( - providers.filter( - (provider) => - provider.id !== "goose" || hasAllowedModelProvider, - ), - ); - } + const providers = applyProvidersFromInventory(entries); perfLog( `[perf:startup] loadProvidersAndInventory done in ${(performance.now() - t0).toFixed(1)}ms (entries=${entries.length}, providers=${providers.length})`, @@ -117,6 +165,10 @@ export function useAppStartup() { setActiveSession(null); }; + // Catalog loading has its own fallback/error state and should not block + // sessions, personas, or configured provider inventory during startup. + void loadProviderCatalog(); + await loadDistroBundle(); const providersAndInventoryLoad = loadProvidersAndInventory(); @@ -126,6 +178,8 @@ export function useAppStartup() { providersAndInventoryLoad, loadSessionState(), ]); + // Background refresh updates stale inventory after the first usable + // provider list is available. void providersAndInventoryLoad.then(async (entries) => { try { const { backgroundRefreshInventory } = await import( diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts index 110abb63..58c330b9 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useAgentModelPickerState.test.ts @@ -1,5 +1,6 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { useAgentModelPickerState } from "../useAgentModelPickerState"; const mockUseProviderInventory = vi.fn(); @@ -9,6 +10,11 @@ vi.mock("@/features/providers/hooks/useProviderInventory", () => ({ })); describe("useAgentModelPickerState", () => { + beforeEach(() => { + vi.clearAllMocks(); + useProviderCatalogStore.getState().reset(); + }); + it("switches to goose when the current provider is goose-backed", () => { const onProviderSelected = vi.fn(); @@ -173,4 +179,183 @@ describe("useAgentModelPickerState", () => { recommended: undefined, }); }); + + it("routes unresolved model providers through Goose before the catalog loads", () => { + const getModelsForAgent = vi.fn((agentId: string) => + agentId === "goose" + ? [ + { + id: "gpt-5.4", + name: "GPT-5.4", + providerId: "openai", + providerName: "OpenAI", + }, + { + id: "claude-sonnet-4", + name: "Claude Sonnet 4", + providerId: "anthropic", + providerName: "Anthropic", + }, + ] + : [], + ); + + mockUseProviderInventory.mockReturnValue({ + entries: new Map([ + [ + "openai", + { + providerId: "openai", + providerName: "OpenAI", + category: "model", + configured: true, + refreshing: false, + models: [], + }, + ], + ]), + getEntry: (providerId: string) => + providerId === "openai" + ? { + providerId: "openai", + providerName: "OpenAI", + category: "model", + configured: true, + refreshing: false, + models: [], + } + : undefined, + configuredModelProviderEntries: [], + getModelsForAgent, + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [{ id: "goose", label: "Goose" }], + selectedProvider: "openai", + onProviderSelected: vi.fn(), + }), + ); + + expect(result.current.selectedAgentId).toBe("goose"); + expect(getModelsForAgent).toHaveBeenCalledWith("goose"); + expect( + result.current.availableModels.map((model) => model.providerId), + ).toEqual(["openai", "anthropic"]); + }); + + it("preserves unresolved agent providers before the catalog loads when inventory identifies an agent", () => { + const getModelsForAgent = vi.fn(() => [ + { + id: "current", + name: "Current", + providerId: "codex-acp", + providerName: "Codex", + }, + ]); + + mockUseProviderInventory.mockReturnValue({ + entries: new Map([ + [ + "codex-acp", + { + providerId: "codex-acp", + providerName: "Codex", + category: "agent", + configured: true, + refreshing: false, + models: [], + }, + ], + ]), + getEntry: (providerId: string) => + providerId === "codex-acp" + ? { + providerId: "codex-acp", + providerName: "Codex", + category: "agent", + configured: true, + refreshing: false, + models: [], + } + : undefined, + configuredModelProviderEntries: [], + getModelsForAgent, + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [{ id: "codex-acp", label: "Codex" }], + selectedProvider: "codex-acp", + onProviderSelected: vi.fn(), + }), + ); + + expect(result.current.selectedAgentId).toBe("codex-acp"); + expect(getModelsForAgent).toHaveBeenCalledWith("codex-acp"); + }); + + it("shows configured inventory agent providers before the catalog loads", () => { + mockUseProviderInventory.mockReturnValue({ + entries: new Map([ + [ + "codex-acp", + { + providerId: "codex-acp", + providerName: "Codex", + category: "agent", + configured: true, + refreshing: false, + models: [], + }, + ], + [ + "cursor-agent", + { + providerId: "cursor-agent", + providerName: "Cursor", + category: "agent", + configured: true, + refreshing: false, + models: [], + }, + ], + [ + "unconfigured-agent", + { + providerId: "unconfigured-agent", + providerName: "Unconfigured", + category: "agent", + configured: false, + refreshing: false, + models: [], + }, + ], + ]), + getEntry: () => undefined, + configuredModelProviderEntries: [], + getModelsForAgent: () => [], + loading: false, + }); + + const { result } = renderHook(() => + useAgentModelPickerState({ + providers: [ + { id: "codex-acp", label: "Codex" }, + { id: "cursor-agent", label: "Cursor" }, + { id: "unconfigured-agent", label: "Unconfigured" }, + ], + selectedProvider: "goose", + onProviderSelected: vi.fn(), + }), + ); + + expect(result.current.pickerAgents).toEqual([ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + { id: "cursor-agent", label: "Cursor" }, + ]); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts b/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts index e5ad182a..e84f5846 100644 --- a/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts +++ b/ui/goose2/src/features/chat/hooks/__tests__/useResolvedAgentModelPicker.test.ts @@ -1,10 +1,12 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { useResolvedAgentModelPicker } from "../useResolvedAgentModelPicker"; const mockUseProviderInventory = vi.fn(); const mockUseAgentModelPickerState = vi.fn(); const mockGetClient = vi.fn(); +const mockAcpSetModel = vi.fn(); vi.mock("@/features/providers/hooks/useProviderInventory", () => ({ useProviderInventory: () => mockUseProviderInventory(), @@ -19,10 +21,43 @@ vi.mock("@/shared/api/acpConnection", () => ({ getClient: (...args: unknown[]) => mockGetClient(...args), })); +vi.mock("@/shared/api/acp", () => ({ + acpSetModel: (...args: unknown[]) => mockAcpSetModel(...args), +})); + describe("useResolvedAgentModelPicker", () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); + useProviderCatalogStore.getState().reset(); + useProviderCatalogStore.getState().setEntries([ + { + id: "codex-acp", + displayName: "Codex CLI", + category: "agent", + description: "Codex CLI", + setupMethod: "cli_auth", + group: "default", + aliases: ["codex-acp", "codex_cli", "codex"], + }, + { + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "Claude Code", + setupMethod: "cli_auth", + group: "default", + aliases: ["claude-acp", "claude_code", "claude"], + }, + { + id: "openai", + displayName: "OpenAI", + category: "model", + description: "OpenAI", + setupMethod: "single_api_key", + group: "default", + }, + ]); mockGetClient.mockResolvedValue({ goose: { @@ -32,12 +67,14 @@ describe("useResolvedAgentModelPicker", () => { }), }, }); + mockAcpSetModel.mockResolvedValue(undefined); mockUseProviderInventory.mockReturnValue({ getEntry: (providerId: string) => providerId === "codex-acp" ? { providerId: "codex-acp", + category: "agent", defaultModel: "gpt-5.4", models: [ { @@ -396,4 +433,259 @@ describe("useResolvedAgentModelPicker", () => { expect(result.current.effectiveModelSelection).toBeNull(); }); + + it("enforces concrete provider compatibility from inventory before catalog loads", () => { + useProviderCatalogStore.getState().reset(); + window.localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + goose: { + modelId: "claude-sonnet-4", + modelName: "Claude Sonnet 4", + providerId: "anthropic", + }, + }), + ); + + mockUseProviderInventory.mockReturnValue({ + getEntry: (providerId: string) => + providerId === "openai" + ? { + providerId: "openai", + category: "model", + defaultModel: "gpt-5.4", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + recommended: true, + }, + ], + } + : undefined, + }); + + mockUseAgentModelPickerState.mockImplementation(() => ({ + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [], + modelsLoading: true, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: vi.fn(), + })); + + const { result } = renderHook(() => + useResolvedAgentModelPicker({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "openai", label: "OpenAI" }, + ], + selectedProvider: "openai", + sessionId: "session-1", + session: { + id: "session-1", + title: "Chat", + providerId: "openai", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", + messageCount: 0, + }, + pendingModelSelection: undefined, + setPendingProviderId: vi.fn(), + setPendingModelSelection: vi.fn(), + setGlobalSelectedProvider: vi.fn(), + prepareSelectedProvider: vi.fn(), + }), + ); + + expect(result.current.effectiveModelSelection).toBeNull(); + }); + + it("preserves unresolved agent provider identity before catalog loads", async () => { + useProviderCatalogStore.getState().reset(); + + mockUseAgentModelPickerState.mockImplementation( + ({ + onProviderSelected, + onModelSelected, + }: { + onProviderSelected: (providerId: string) => void; + onModelSelected?: (model: { + id: string; + name: string; + displayName?: string; + providerId?: string; + }) => void; + }) => ({ + pickerAgents: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + ], + availableModels: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + displayName: "GPT-5.4", + providerId: "codex-acp", + }, + ], + modelsLoading: false, + modelStatusMessage: null, + handleProviderChange: (providerId: string) => + onProviderSelected(providerId), + handleModelChange: (modelId: string) => + onModelSelected?.({ + id: modelId, + name: "GPT-5.4", + displayName: "GPT-5.4", + providerId: "codex-acp", + }), + }), + ); + + const { result } = renderHook(() => + useResolvedAgentModelPicker({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "codex-acp", label: "Codex" }, + ], + selectedProvider: "codex-acp", + sessionId: "session-1", + session: { + id: "session-1", + title: "Chat", + providerId: "codex-acp", + modelId: "current", + modelName: "current", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", + messageCount: 0, + }, + pendingModelSelection: undefined, + setPendingProviderId: vi.fn(), + setPendingModelSelection: vi.fn(), + setGlobalSelectedProvider: vi.fn(), + prepareSelectedProvider: vi.fn(), + }), + ); + + act(() => { + result.current.handleModelChange("gpt-5.4"); + }); + + await waitFor(() => { + expect( + JSON.parse( + localStorage.getItem("goose:preferredModelsByAgent") ?? "{}", + ), + ).toEqual({ + "codex-acp": { + modelId: "gpt-5.4", + modelName: "GPT-5.4", + providerId: "codex-acp", + }, + }); + }); + }); + + it("routes unresolved model provider identity through Goose before catalog loads", async () => { + useProviderCatalogStore.getState().reset(); + + mockUseProviderInventory.mockReturnValue({ + getEntry: (providerId: string) => + providerId === "openai" + ? { + providerId: "openai", + category: "model", + defaultModel: "gpt-5.4", + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + recommended: true, + }, + ], + } + : undefined, + }); + + mockUseAgentModelPickerState.mockImplementation( + ({ + onModelSelected, + }: { + onModelSelected?: (model: { + id: string; + name: string; + displayName?: string; + providerId?: string; + }) => void; + }) => ({ + pickerAgents: [{ id: "goose", label: "Goose" }], + availableModels: [ + { + id: "gpt-5.4", + name: "GPT-5.4", + displayName: "GPT-5.4", + providerId: "openai", + }, + ], + modelsLoading: false, + modelStatusMessage: null, + handleProviderChange: vi.fn(), + handleModelChange: (modelId: string) => + onModelSelected?.({ + id: modelId, + name: "GPT-5.4", + displayName: "GPT-5.4", + providerId: "openai", + }), + }), + ); + + const { result } = renderHook(() => + useResolvedAgentModelPicker({ + providers: [ + { id: "goose", label: "Goose" }, + { id: "openai", label: "OpenAI" }, + ], + selectedProvider: "openai", + sessionId: "session-1", + session: { + id: "session-1", + title: "Chat", + providerId: "openai", + modelId: "current", + modelName: "current", + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:00.000Z", + messageCount: 0, + }, + pendingModelSelection: undefined, + setPendingProviderId: vi.fn(), + setPendingModelSelection: vi.fn(), + setGlobalSelectedProvider: vi.fn(), + prepareSelectedProvider: vi.fn(), + }), + ); + + expect(result.current.selectedAgentId).toBe("goose"); + + act(() => { + result.current.handleModelChange("gpt-5.4"); + }); + + await waitFor(() => { + expect( + JSON.parse( + localStorage.getItem("goose:preferredModelsByAgent") ?? "{}", + ), + ).toEqual({ + goose: { + modelId: "gpt-5.4", + modelName: "GPT-5.4", + providerId: "openai", + }, + }); + }); + }); }); diff --git a/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts index d8a817af..91a19cf5 100644 --- a/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts +++ b/ui/goose2/src/features/chat/hooks/useAgentModelPickerState.ts @@ -3,9 +3,11 @@ import type { AcpProvider } from "@/shared/api/acp"; import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; import { - getCatalogEntry, - resolveAgentProviderCatalogIdStrict, + getCatalogEntryFromEntries, + resolveAgentProviderCatalogIdStrictFromEntries, } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; +import { resolveSelectedAgentId } from "../lib/agentProviderResolution"; import type { ModelOption } from "../types"; interface UseAgentModelPickerStateOptions { @@ -23,6 +25,8 @@ export function useAgentModelPickerState({ onProviderSelected, onModelSelected, }: UseAgentModelPickerStateOptions) { + const catalogEntries = useProviderCatalogStore((state) => state.entries); + const catalogLoaded = useProviderCatalogStore((state) => state.loaded); const { entries: providerInventoryEntries, getEntry: getProviderInventoryEntry, @@ -31,9 +35,21 @@ export function useAgentModelPickerState({ loading: providerInventoryLoading, } = useProviderInventory(); - const selectedAgentId = selectedProvider - ? (resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose") - : "goose"; + const selectedAgentId = useMemo( + () => + resolveSelectedAgentId({ + catalogEntries, + catalogLoaded, + selectedProvider, + getProviderInventoryEntry, + }), + [ + catalogEntries, + catalogLoaded, + getProviderInventoryEntry, + selectedProvider, + ], + ); const selectedProviderInventory = getProviderInventoryEntry(selectedAgentId); const pickerAgents = useMemo(() => { @@ -41,11 +57,21 @@ export function useAgentModelPickerState({ visible.set("goose", { id: "goose", - label: getCatalogEntry("goose")?.displayName ?? "Goose", + label: + getCatalogEntryFromEntries(catalogEntries, "goose")?.displayName ?? + "Goose", }); for (const provider of providers) { - const agentId = resolveAgentProviderCatalogIdStrict(provider.id); + const agentId = + resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + provider.id, + ) ?? + (!catalogLoaded && + providerInventoryEntries.get(provider.id)?.category === "agent" + ? provider.id + : null); if (!agentId || agentId === "goose") { continue; } @@ -57,19 +83,29 @@ export function useAgentModelPickerState({ visible.set(agentId, { id: agentId, - label: getCatalogEntry(agentId)?.displayName ?? provider.label, + label: + getCatalogEntryFromEntries(catalogEntries, agentId)?.displayName ?? + provider.label, }); } if (!visible.has(selectedAgentId)) { visible.set(selectedAgentId, { id: selectedAgentId, - label: getCatalogEntry(selectedAgentId)?.displayName ?? selectedAgentId, + label: + getCatalogEntryFromEntries(catalogEntries, selectedAgentId) + ?.displayName ?? selectedAgentId, }); } return [...visible.values()]; - }, [providerInventoryEntries, providers, selectedAgentId]); + }, [ + catalogEntries, + catalogLoaded, + providerInventoryEntries, + providers, + selectedAgentId, + ]); const availableModels = useMemo( () => getModelsForAgent(selectedAgentId) ?? EMPTY_MODELS, diff --git a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts index fa54bdb0..f2acb128 100644 --- a/ui/goose2/src/features/chat/hooks/useChatSessionController.ts +++ b/ui/goose2/src/features/chat/hooks/useChatSessionController.ts @@ -10,7 +10,8 @@ import { useChatSessionStore } from "../stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProviderSelection } from "@/features/agents/hooks/useProviderSelection"; import { useProjectStore } from "@/features/projects/stores/projectStore"; -import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; +import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { buildProjectSystemPrompt, composeSystemPrompt, @@ -64,6 +65,7 @@ export function useChatSessionController({ ); const projects = useProjectStore((s) => s.projects); const projectsLoading = useProjectStore((s) => s.loading); + const catalogEntries = useProviderCatalogStore((s) => s.entries); const [pendingPersonaId, setPendingPersonaId] = useState(); const [pendingProjectId, setPendingProjectId] = useState(); const [pendingProviderId, setPendingProviderId] = useState(); @@ -435,7 +437,7 @@ export function useChatSessionController({ supportsContextCompactionControls(selectedAgentId); const isCompactingContext = chatState === "compacting"; const resolveAutoCompactAgentId = useCallback( - (overridePersona?: { id: string; name?: string }) => { + (overridePersona?: { id: string; name?: string }): string | null => { if (!overridePersona?.id) { return selectedAgentId; } @@ -447,11 +449,22 @@ export function useChatSessionController({ return selectedAgentId; } - return ( - resolveAgentProviderCatalogIdStrict(targetPersona.provider) ?? "goose" + const targetAgentId = resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + targetPersona.provider, ); + if (targetAgentId) { + return targetAgentId; + } + + const isGooseModelProvider = providers.some( + (provider) => + provider.id === targetPersona.provider || + provider.label.toLowerCase().includes(targetPersona.provider ?? ""), + ); + return isGooseModelProvider ? "goose" : null; }, - [personas, selectedAgentId], + [catalogEntries, personas, providers, selectedAgentId], ); const canAutoCompactBeforeSend = useCallback( (overridePersona?: { id: string; name?: string }) => { @@ -721,7 +734,8 @@ export function useChatSessionController({ } if (pendingModelSelection?.source === "explicit") { const agentId = - resolveAgentProviderCatalogIdStrict( + resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, pendingModelSelection.providerId ?? nextProviderId, ) ?? "goose"; setStoredModelPreference(agentId, { @@ -764,6 +778,7 @@ export function useChatSessionController({ }; }, [ activeWorkspace?.path, + catalogEntries, pendingDraftValue, pendingSkillDrafts, pendingModelSelection, diff --git a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts index f0542a38..81509350 100644 --- a/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts +++ b/ui/goose2/src/features/chat/hooks/useResolvedAgentModelPicker.ts @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import type { AcpProvider } from "@/shared/api/acp"; import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; -import { resolveAgentProviderCatalogIdStrict } from "@/features/providers/providerCatalog"; +import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { getClient } from "@/shared/api/acpConnection"; import { acpSetModel } from "@/shared/api/acp"; import { @@ -14,6 +15,7 @@ import { getStoredModelPreference, setStoredModelPreference, } from "../lib/modelPreferences"; +import { resolveSelectedAgentId } from "../lib/agentProviderResolution"; const MODEL_ALIAS_IDS = new Set(["current", "default"]); @@ -56,16 +58,48 @@ export function useResolvedAgentModelPicker({ setGlobalSelectedProvider, prepareSelectedProvider, }: UseResolvedAgentModelPickerOptions) { + const catalogEntries = useProviderCatalogStore((state) => state.entries); + const catalogLoaded = useProviderCatalogStore((state) => state.loaded); const { getEntry: getProviderInventoryEntry } = useProviderInventory(); const [gooseDefaultSelection, setGooseDefaultSelection] = useState(null); - const selectedAgentId = - resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose"; - const concreteSelectedProviderId = - resolveAgentProviderCatalogIdStrict(selectedProvider) == null - ? selectedProvider - : null; + const selectedAgentId = useMemo( + () => + resolveSelectedAgentId({ + catalogEntries, + catalogLoaded, + selectedProvider, + getProviderInventoryEntry, + }), + [ + catalogEntries, + catalogLoaded, + getProviderInventoryEntry, + selectedProvider, + ], + ); + const concreteSelectedProviderId = useMemo(() => { + const resolvedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + selectedProvider, + ); + if (resolvedAgentId) { + return null; + } + + if (!catalogLoaded) { + const inventoryEntry = getProviderInventoryEntry(selectedProvider); + return inventoryEntry?.category === "model" ? selectedProvider : null; + } + + return selectedProvider; + }, [ + catalogEntries, + catalogLoaded, + getProviderInventoryEntry, + selectedProvider, + ]); const storedModelPreference = useMemo( () => getStoredModelPreference(selectedAgentId), [selectedAgentId], @@ -178,9 +212,20 @@ export function useResolvedAgentModelPicker({ providers, selectedProvider, onProviderSelected: (providerId) => { - const requestedAgentId = resolveAgentProviderCatalogIdStrict(providerId); + const requestedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + providerId, + ); + const resolvedRequestedAgentId = + requestedAgentId ?? + resolveSelectedAgentId({ + catalogEntries, + catalogLoaded, + selectedProvider: providerId, + getProviderInventoryEntry, + }); const preferredModelSelection = getPreferredSelectionForAgent( - requestedAgentId ?? "goose", + resolvedRequestedAgentId, providerId, ); const nextProviderId = requestedAgentId diff --git a/ui/goose2/src/features/chat/lib/agentProviderResolution.ts b/ui/goose2/src/features/chat/lib/agentProviderResolution.ts new file mode 100644 index 00000000..95aca1ee --- /dev/null +++ b/ui/goose2/src/features/chat/lib/agentProviderResolution.ts @@ -0,0 +1,40 @@ +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; + +interface ResolveSelectedAgentIdOptions { + catalogEntries: ProviderCatalogEntry[]; + catalogLoaded: boolean; + selectedProvider?: string; + getProviderInventoryEntry: ( + providerId: string, + ) => ProviderInventoryEntryDto | undefined; +} + +export function resolveSelectedAgentId({ + catalogEntries, + catalogLoaded, + selectedProvider, + getProviderInventoryEntry, +}: ResolveSelectedAgentIdOptions): string { + if (!selectedProvider) { + return "goose"; + } + + const resolvedAgentId = resolveAgentProviderCatalogIdStrictFromEntries( + catalogEntries, + selectedProvider, + ); + if (resolvedAgentId) { + return resolvedAgentId; + } + + if (!catalogLoaded) { + const inventoryEntry = getProviderInventoryEntry(selectedProvider); + if (inventoryEntry?.category === "agent") { + return selectedProvider; + } + } + + return "goose"; +} diff --git a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx index c999e9b2..e292a35e 100644 --- a/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx +++ b/ui/goose2/src/features/chat/ui/ChatInputToolbar.tsx @@ -29,7 +29,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/shared/ui/tooltip"; import { AgentModelPicker } from "./AgentModelPicker"; import type { ModelOption } from "../types"; import { formatProviderLabel } from "@/shared/ui/icons/ProviderIcons"; -import { getCatalogEntry } from "@/features/providers/providerCatalog"; +import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { supportsContextCompactionControls } from "../lib/autoCompact"; import { requestOpenSettings } from "@/features/settings/lib/settingsEvents"; import { ProjectSelectorIcon } from "./ProjectSelectorIcon"; @@ -127,6 +128,7 @@ export function ChatInputToolbar({ }: ChatInputToolbarProps) { const { t } = useTranslation("chat"); const { formatNumber } = useLocaleFormatting(); + const catalogEntries = useProviderCatalogStore((state) => state.entries); const [isContextPopoverOpen, setIsContextPopoverOpen] = useState(false); const compactionControlsSupported = supportsCompactionControls ?? @@ -142,7 +144,9 @@ export function ChatInputToolbar({ seen.add(provider.id); available.push({ id: provider.id, - label: getCatalogEntry(provider.id)?.displayName ?? provider.label, + label: + getCatalogEntryFromEntries(catalogEntries, provider.id) + ?.displayName ?? provider.label, }); } if (available.length > 0) return available; @@ -150,11 +154,11 @@ export function ChatInputToolbar({ { id: selectedProvider, label: - getCatalogEntry(selectedProvider)?.displayName ?? - formatProviderLabel(selectedProvider), + getCatalogEntryFromEntries(catalogEntries, selectedProvider) + ?.displayName ?? formatProviderLabel(selectedProvider), }, ]; - }, [providers, selectedProvider]); + }, [catalogEntries, providers, selectedProvider]); const selectedProject = availableProjects.find( (project) => project.id === selectedProjectId, ); diff --git a/ui/goose2/src/features/chat/ui/MessageBubble.tsx b/ui/goose2/src/features/chat/ui/MessageBubble.tsx index 86b746a3..19b5575e 100644 --- a/ui/goose2/src/features/chat/ui/MessageBubble.tsx +++ b/ui/goose2/src/features/chat/ui/MessageBubble.tsx @@ -7,7 +7,8 @@ import { cn } from "@/shared/lib/cn"; import { useLocaleFormatting } from "@/shared/i18n"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; -import { getCatalogEntry } from "@/features/providers/providerCatalog"; +import { getCatalogEntryFromEntries } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { getProviderIcon, formatProviderLabel, @@ -329,6 +330,7 @@ export const MessageBubble = memo(function MessageBubble({ ); const { isCopied: isCopyConfirmed, copyToClipboard } = useCopyToClipboard(); const personaAvatarUrl = useAvatarSrc(persona?.avatar); + const catalogEntries = useProviderCatalogStore((state) => state.entries); // Skip empty user bubbles (all blocks filtered as assistant-only). if (role === "user" && content.length === 0) return null; @@ -357,8 +359,8 @@ export const MessageBubble = memo(function MessageBubble({ const isUser = role === "user"; const assistantProviderId = message.metadata?.providerId; const assistantProviderName = assistantProviderId - ? (getCatalogEntry(assistantProviderId)?.displayName ?? - formatProviderLabel(assistantProviderId)) + ? (getCatalogEntryFromEntries(catalogEntries, assistantProviderId) + ?.displayName ?? formatProviderLabel(assistantProviderId)) : undefined; const assistantDisplayName = message.metadata?.personaName ?? diff --git a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx index 7698c4d4..90c0a6c9 100644 --- a/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx +++ b/ui/goose2/src/features/chat/ui/__tests__/MessageBubble.test.tsx @@ -3,10 +3,35 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MessageBubble } from "../MessageBubble"; import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import type { Message } from "@/shared/types/messages"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; import { openPath } from "@tauri-apps/plugin-opener"; const mockWriteText = vi.fn().mockResolvedValue(undefined); +const providerCatalogEntries: ProviderCatalogEntry[] = [ + { + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "Anthropic's agentic coding tool", + setupMethod: "cli_auth", + binaryName: "claude-agent-acp", + group: "default", + aliases: ["claude-acp", "claude_code", "claude"], + }, + { + id: "codex-acp", + displayName: "Codex", + category: "agent", + description: "OpenAI's coding agent", + setupMethod: "cli_auth", + binaryName: "codex-acp", + group: "default", + aliases: ["codex-acp", "codex_cli", "codex"], + }, +]; + vi.mock("@mcp-ui/client", () => ({ UI_EXTENSION_CONFIG: { mimeTypes: ["text/html;profile=mcp-app"] }, AppRenderer: (props: { toolName?: string }) => ( @@ -57,6 +82,7 @@ function assistantMessage( describe("MessageBubble", () => { beforeEach(() => { useAgentStore.setState({ personas: [] }); + useProviderCatalogStore.getState().setEntries(providerCatalogEntries); vi.mocked(openPath).mockClear(); mockWriteText.mockClear(); Object.defineProperty(navigator, "clipboard", { @@ -69,6 +95,7 @@ describe("MessageBubble", () => { afterEach(() => { vi.useRealTimers(); + useProviderCatalogStore.getState().reset(); }); it("renders user message with correct alignment", () => { diff --git a/ui/goose2/src/features/providers/api/catalog.test.ts b/ui/goose2/src/features/providers/api/catalog.test.ts new file mode 100644 index 00000000..b339ba2c --- /dev/null +++ b/ui/goose2/src/features/providers/api/catalog.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + listProviderSetupCatalog, + mapProviderSetupCatalogEntryDto, +} from "./catalog"; + +const mocks = vi.hoisted(() => ({ + catalogList: vi.fn(), + getClient: vi.fn(), +})); + +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: () => mocks.getClient(), +})); + +describe("provider setup catalog API", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getClient.mockResolvedValue({ + goose: { + GooseProvidersSetupCatalogList: mocks.catalogList, + }, + }); + }); + + it("maps setup catalog DTO fields to provider catalog entries", () => { + expect( + mapProviderSetupCatalogEntryDto({ + providerId: "claude-acp", + name: "Claude Code", + docUrl: "https://docs.anthropic.com/en/docs/claude-code", + category: "agent", + description: "Anthropic's agentic coding tool", + setupMethod: "cli_auth", + binaryName: "claude-agent-acp", + group: "default", + showOnlyWhenInstalled: false, + aliases: ["claude-acp", "claude_code", "claude"], + supportsInstall: true, + supportsAuth: true, + supportsAuthStatus: true, + }), + ).toEqual({ + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "Anthropic's agentic coding tool", + setupMethod: "cli_auth", + binaryName: "claude-agent-acp", + docsUrl: "https://docs.anthropic.com/en/docs/claude-code", + group: "default", + showOnlyWhenInstalled: false, + aliases: ["claude-acp", "claude_code", "claude"], + supportsInstall: true, + supportsAuth: true, + supportsAuthStatus: true, + }); + }); + + it("requests the setup catalog through ACP", async () => { + mocks.catalogList.mockResolvedValue({ + providers: [ + { + providerId: "ollama", + name: "Ollama", + category: "model", + description: "Run local models", + setupMethod: "config_fields", + fields: [ + { + key: "OLLAMA_HOST", + label: "Host", + secret: false, + required: true, + }, + ], + group: "default", + showOnlyWhenInstalled: false, + supportsInstall: false, + supportsAuth: false, + supportsAuthStatus: false, + }, + ], + }); + + await expect(listProviderSetupCatalog()).resolves.toEqual([ + { + id: "ollama", + displayName: "Ollama", + category: "model", + description: "Run local models", + setupMethod: "config_fields", + fields: [ + { + key: "OLLAMA_HOST", + label: "Host", + secret: false, + required: true, + }, + ], + group: "default", + showOnlyWhenInstalled: false, + supportsInstall: false, + supportsAuth: false, + supportsAuthStatus: false, + }, + ]); + expect(mocks.catalogList).toHaveBeenCalledWith({}); + }); +}); diff --git a/ui/goose2/src/features/providers/api/catalog.ts b/ui/goose2/src/features/providers/api/catalog.ts new file mode 100644 index 00000000..3f4ec752 --- /dev/null +++ b/ui/goose2/src/features/providers/api/catalog.ts @@ -0,0 +1,42 @@ +import type { ProviderSetupCatalogEntryDto } from "@aaif/goose-sdk"; +import { getClient } from "@/shared/api/acpConnection"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; +import { perfLog } from "@/shared/lib/perfLog"; + +export function mapProviderSetupCatalogEntryDto( + dto: ProviderSetupCatalogEntryDto, +): ProviderCatalogEntry { + return { + id: dto.providerId, + displayName: dto.name, + category: dto.category, + description: dto.description, + setupMethod: dto.setupMethod, + ...(dto.nativeConnectQuery + ? { nativeConnectQuery: dto.nativeConnectQuery } + : {}), + ...(dto.fields?.length ? { fields: dto.fields } : {}), + ...(dto.binaryName ? { binaryName: dto.binaryName } : {}), + ...(dto.docUrl ? { docsUrl: dto.docUrl } : {}), + group: dto.group, + showOnlyWhenInstalled: dto.showOnlyWhenInstalled, + ...(dto.aliases?.length ? { aliases: dto.aliases } : {}), + supportsInstall: dto.supportsInstall, + supportsAuth: dto.supportsAuth, + supportsAuthStatus: dto.supportsAuthStatus, + }; +} + +export async function listProviderSetupCatalog(): Promise< + ProviderCatalogEntry[] +> { + const client = await getClient(); + const t0 = performance.now(); + const response = await client.goose.GooseProvidersSetupCatalogList({}); + const providers = response.providers.map(mapProviderSetupCatalogEntryDto); + + perfLog( + `[perf:catalog] listProviderSetupCatalog done in ${(performance.now() - t0).toFixed(1)}ms (n=${providers.length})`, + ); + return providers; +} diff --git a/ui/goose2/src/features/providers/api/customProviders.ts b/ui/goose2/src/features/providers/api/customProviders.ts index 88c8dd55..f4d97c6d 100644 --- a/ui/goose2/src/features/providers/api/customProviders.ts +++ b/ui/goose2/src/features/providers/api/customProviders.ts @@ -4,7 +4,7 @@ import type { CustomProviderDeleteResponse, CustomProviderReadResponse, CustomProviderUpdateResponse, - ProviderCatalogEntryDto, + ProviderTemplateCatalogEntryDto, ProviderTemplateDto, } from "@aaif/goose-sdk"; import type { @@ -19,7 +19,7 @@ async function getProviderClient() { export async function listCustomProviderCatalog( format?: CustomProviderFormat, -): Promise { +): Promise { const client = await getProviderClient(); const response = await client.GooseProvidersCatalogList( format ? { format } : {}, diff --git a/ui/goose2/src/features/providers/api/inventory.test.ts b/ui/goose2/src/features/providers/api/inventory.test.ts index daafbcce..718ec220 100644 --- a/ui/goose2/src/features/providers/api/inventory.test.ts +++ b/ui/goose2/src/features/providers/api/inventory.test.ts @@ -23,6 +23,7 @@ function providerEntry( defaultModel: "", configured: false, providerType: "Preferred", + category: "model", configKeys: [], setupSteps: [], supportsRefresh: false, diff --git a/ui/goose2/src/features/providers/api/inventorySync.test.ts b/ui/goose2/src/features/providers/api/inventorySync.test.ts index 67b955f2..aa8abe4c 100644 --- a/ui/goose2/src/features/providers/api/inventorySync.test.ts +++ b/ui/goose2/src/features/providers/api/inventorySync.test.ts @@ -17,6 +17,7 @@ function inventoryEntry( defaultModel: "default-model", configured: true, providerType: "remote", + category: "model", configKeys: [], setupSteps: [], supportsRefresh: true, diff --git a/ui/goose2/src/features/providers/distroProviderConstraints.test.ts b/ui/goose2/src/features/providers/distroProviderConstraints.test.ts index 1a88939c..8973e397 100644 --- a/ui/goose2/src/features/providers/distroProviderConstraints.test.ts +++ b/ui/goose2/src/features/providers/distroProviderConstraints.test.ts @@ -9,7 +9,7 @@ describe("filterModelProvidersForDistro", () => { category: "model", description: "Claude models", setupMethod: "single_api_key", - tier: "promoted", + group: "default", }, { id: "openai", @@ -17,7 +17,7 @@ describe("filterModelProvidersForDistro", () => { category: "model", description: "GPT models", setupMethod: "single_api_key", - tier: "promoted", + group: "default", }, { id: "ollama", @@ -25,7 +25,7 @@ describe("filterModelProvidersForDistro", () => { category: "model", description: "Local models", setupMethod: "local", - tier: "promoted", + group: "default", }, ] as const; diff --git a/ui/goose2/src/features/providers/distroProviderConstraints.ts b/ui/goose2/src/features/providers/distroProviderConstraints.ts index 30cb78d9..f962a274 100644 --- a/ui/goose2/src/features/providers/distroProviderConstraints.ts +++ b/ui/goose2/src/features/providers/distroProviderConstraints.ts @@ -32,3 +32,19 @@ export function filterModelProvidersForDistro( return providers.filter((provider) => allowlist.has(provider.id)); } + +export function isProviderAllowedByAllowlist( + providerId: string, + allowlist: Set | null, +): boolean { + return !allowlist || allowlist.has(providerId); +} + +export function hasAllowedModelProvider( + providers: Pick[], + allowlist: Set | null, +): boolean { + return providers.some((provider) => + isProviderAllowedByAllowlist(provider.id, allowlist), + ); +} diff --git a/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts b/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts index 4d064b72..fb2b98ff 100644 --- a/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts +++ b/ui/goose2/src/features/providers/hooks/useAgentProviderStatus.ts @@ -3,10 +3,9 @@ import { checkAgentInstalled, checkAgentAuth, } from "@/features/providers/api/agentSetup"; -import { - getAgentProviders, - getCatalogEntry, -} from "@/features/providers/providerCatalog"; +import { getAgentProvidersFromEntries } from "@/features/providers/providerCatalog"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; interface UseAgentProviderStatusReturn { readyAgentIds: Set; @@ -14,9 +13,10 @@ interface UseAgentProviderStatusReturn { refresh: () => Promise; } -async function checkAgentProviderReady(providerId: string): Promise { - const provider = getCatalogEntry(providerId); - if (!provider || provider.category !== "agent") { +async function checkAgentProviderReady( + provider: ProviderCatalogEntry, +): Promise { + if (provider.category !== "agent") { return false; } @@ -34,11 +34,11 @@ async function checkAgentProviderReady(providerId: string): Promise { return false; } - if (provider.authStatusCommand) { + if (provider.supportsAuthStatus) { return checkAgentAuth(provider.id); } - if (provider.authCommand) { + if (provider.supportsAuth) { return ( localStorage.getItem(`agent-provider-auth:${provider.id}`) === "true" ); @@ -52,61 +52,57 @@ async function checkAgentProviderReady(providerId: string): Promise { const INITIAL_READY_AGENTS = new Set(["goose"]); +async function checkReadyAgentIds( + agents: ProviderCatalogEntry[], +): Promise> { + const readiness = await Promise.all( + agents.map(async (provider) => ({ + id: provider.id, + isReady: await checkAgentProviderReady(provider), + })), + ); + const readyIds = readiness + .filter((provider) => provider.isReady) + .map((provider) => provider.id); + return new Set(["goose", ...readyIds]); +} + export function useAgentProviderStatus(): UseAgentProviderStatusReturn { + const catalogEntries = useProviderCatalogStore((state) => state.entries); const [readyAgentIds, setReadyAgentIds] = useState>(INITIAL_READY_AGENTS); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; - const agentIds = getAgentProviders().map((provider) => provider.id); - let remaining = agentIds.length; - - for (const agentId of agentIds) { - checkAgentProviderReady(agentId) - .then((isReady) => { - if (!cancelled && isReady) { - setReadyAgentIds((current) => { - if (current.has(agentId)) { - return current; - } - - const next = new Set(current); - next.add(agentId); - return next; - }); - } - }) - .finally(() => { - remaining -= 1; - if (!cancelled && remaining === 0) { - setLoading(false); - } - }); - } + const agents = getAgentProvidersFromEntries(catalogEntries); + setLoading(true); + checkReadyAgentIds(agents) + .then((nextReadyAgentIds) => { + if (!cancelled) { + setReadyAgentIds(nextReadyAgentIds); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); return () => { cancelled = true; }; - }, []); + }, [catalogEntries]); const refresh = useCallback(async () => { setLoading(true); try { - const readiness = await Promise.all( - getAgentProviders().map(async (provider) => ({ - id: provider.id, - isReady: await checkAgentProviderReady(provider.id), - })), - ); - const readyIds = readiness - .filter((provider) => provider.isReady) - .map((provider) => provider.id); - setReadyAgentIds(new Set(["goose", ...readyIds])); + const agents = getAgentProvidersFromEntries(catalogEntries); + setReadyAgentIds(await checkReadyAgentIds(agents)); } finally { setLoading(false); } - }, []); + }, [catalogEntries]); return { readyAgentIds, diff --git a/ui/goose2/src/features/providers/hooks/useCustomProviders.test.tsx b/ui/goose2/src/features/providers/hooks/useCustomProviders.test.tsx index f13ffe76..01a5f49f 100644 --- a/ui/goose2/src/features/providers/hooks/useCustomProviders.test.tsx +++ b/ui/goose2/src/features/providers/hooks/useCustomProviders.test.tsx @@ -1,4 +1,5 @@ import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useProviderInventoryStore } from "../stores/providerInventoryStore"; import { useCustomProviders } from "./useCustomProviders"; @@ -26,7 +27,7 @@ vi.mock("../api/inventorySync", () => ({ syncProviderInventory: mocks.syncProviderInventory, })); -function providerEntry(providerId: string) { +function providerEntry(providerId: string): ProviderInventoryEntryDto { return { providerId, providerName: "Acme AI", @@ -34,6 +35,7 @@ function providerEntry(providerId: string) { defaultModel: "acme-large", configured: true, providerType: "Custom", + category: "model", configKeys: [], setupSteps: [], supportsRefresh: true, diff --git a/ui/goose2/src/features/providers/hooks/useCustomProviders.ts b/ui/goose2/src/features/providers/hooks/useCustomProviders.ts index 1913b25e..1b1d77da 100644 --- a/ui/goose2/src/features/providers/hooks/useCustomProviders.ts +++ b/ui/goose2/src/features/providers/hooks/useCustomProviders.ts @@ -25,7 +25,7 @@ import type { CustomProviderReadResponse, CustomProviderUpdateResponse, CustomProviderUpsertRequest, - ProviderCatalogEntryDto, + ProviderTemplateCatalogEntryDto, ProviderTemplateDto, } from "../lib/customProviderTypes"; import { useProviderInventoryStore } from "../stores/providerInventoryStore"; @@ -35,7 +35,7 @@ interface SaveDraftOptions extends CustomProviderValidationOptions { } interface UseCustomProvidersReturn { - catalog: ProviderCatalogEntryDto[]; + catalog: ProviderTemplateCatalogEntryDto[]; catalogLoading: boolean; saving: boolean; savingProviderIds: Set; @@ -46,7 +46,7 @@ interface UseCustomProvidersReturn { configuredIds: Set; loadCatalog: ( format?: CustomProviderFormat, - ) => Promise; + ) => Promise; getTemplate: (providerId: string) => Promise; read: (providerId: string) => Promise; create: ( @@ -113,7 +113,7 @@ export function useCustomProviders(): UseCustomProvidersReturn { const catalogRequestIdRef = useRef(0); const operationIdRef = useRef(0); const deletedProviderIdsRef = useRef(new Set()); - const [catalog, setCatalog] = useState([]); + const [catalog, setCatalog] = useState([]); const [catalogLoading, setCatalogLoading] = useState(false); const [savingProviderIds, setProviderSaving] = useSetMembershipState(); const [deletingProviderIds, setProviderDeleting] = useSetMembershipState(); diff --git a/ui/goose2/src/features/providers/hooks/useProviderInventory.test.ts b/ui/goose2/src/features/providers/hooks/useProviderInventory.test.ts index 677d427b..11fa8fe5 100644 --- a/ui/goose2/src/features/providers/hooks/useProviderInventory.test.ts +++ b/ui/goose2/src/features/providers/hooks/useProviderInventory.test.ts @@ -1,6 +1,8 @@ import { renderHook } from "@testing-library/react"; import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; import { beforeEach, describe, expect, it } from "vitest"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; +import { useProviderCatalogStore } from "../stores/providerCatalogStore"; import { useProviderInventoryStore } from "../stores/providerInventoryStore"; import { useProviderInventory } from "./useProviderInventory"; @@ -16,6 +18,7 @@ function providerEntry( defaultModel: "", configured: true, providerType: "Preferred", + category: "model", configKeys: [], setupSteps: [], supportsRefresh: true, @@ -28,6 +31,25 @@ function providerEntry( describe("useProviderInventory", () => { beforeEach(() => { + useProviderCatalogStore.getState().setEntries([ + { + id: "openai", + displayName: "OpenAI", + category: "model", + description: "GPT and o-series models", + setupMethod: "config_fields", + group: "default", + }, + { + id: "custom_deepseek", + displayName: "DeepSeek", + category: "model", + description: "DeepSeek chat and reasoning models", + setupMethod: "single_api_key", + group: "additional", + }, + ]); + useDistroStore.setState({ loaded: false, manifest: { present: false } }); useProviderInventoryStore.setState({ entries: new Map(), loading: false, @@ -83,6 +105,103 @@ describe("useProviderInventory", () => { ).toEqual(["openai", "custom_acme_openai", "custom_deepseek"]); }); + it("falls back to configured inventory providers before the catalog loads", () => { + useProviderCatalogStore.getState().reset(); + useProviderInventoryStore.getState().setEntries([ + providerEntry({ + providerId: "openai", + providerName: "OpenAI", + providerType: "Preferred", + models: [{ id: "gpt-4o", name: "GPT-4o", recommended: true }], + }), + providerEntry({ + providerId: "custom_acme_openai", + providerName: "Acme OpenAI", + providerType: "Custom", + }), + providerEntry({ + providerId: "codex-acp", + providerName: "Codex", + providerType: "Builtin", + category: "agent", + models: [{ id: "current", name: "Current", recommended: true }], + }), + providerEntry({ + providerId: "local", + providerName: "Local", + providerType: "Custom", + }), + providerEntry({ + providerId: "unconfigured_anthropic", + providerName: "Anthropic", + providerType: "Preferred", + configured: false, + }), + ]); + + const { result } = renderHook(() => useProviderInventory()); + + expect( + result.current.configuredModelProviderEntries.map( + (entry) => entry.providerId, + ), + ).toEqual(["openai", "custom_acme_openai"]); + expect(result.current.getModelsForAgent("goose")).toEqual([ + { + id: "gpt-4o", + name: "GPT-4o", + displayName: "GPT-4o", + provider: undefined, + providerId: "openai", + providerName: "OpenAI", + contextLimit: undefined, + recommended: true, + }, + ]); + }); + + it("applies the provider allowlist before the catalog loads", () => { + useProviderCatalogStore.getState().reset(); + useDistroStore.setState({ + loaded: true, + manifest: { present: true, providerAllowlist: "anthropic" }, + }); + useProviderInventoryStore.getState().setEntries([ + providerEntry({ + providerId: "openai", + providerName: "OpenAI", + providerType: "Preferred", + models: [{ id: "gpt-4o", name: "GPT-4o", recommended: true }], + }), + providerEntry({ + providerId: "anthropic", + providerName: "Anthropic", + providerType: "Preferred", + models: [{ id: "claude-sonnet", name: "Claude Sonnet" }], + }), + ]); + + const { result } = renderHook(() => useProviderInventory()); + + expect( + result.current.configuredModelProviderEntries.map( + (entry) => entry.providerId, + ), + ).toEqual(["anthropic"]); + expect(result.current.getModelsForAgent("goose")).toEqual([ + { + id: "claude-sonnet", + name: "Claude Sonnet", + displayName: "Claude Sonnet", + provider: undefined, + providerId: "anthropic", + providerName: "Anthropic", + contextLimit: undefined, + recommended: false, + }, + ]); + }); + it("aggregates custom provider models under Goose", () => { useProviderInventoryStore.getState().setEntries([ providerEntry({ diff --git a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts index af496550..61ed1a91 100644 --- a/ui/goose2/src/features/providers/hooks/useProviderInventory.ts +++ b/ui/goose2/src/features/providers/hooks/useProviderInventory.ts @@ -5,22 +5,37 @@ import type { ProviderInventoryEntryDto, ProviderInventoryModelDto, } from "@aaif/goose-sdk"; -import { getModelProviders } from "../providerCatalog"; +import { getModelProvidersFromEntries } from "../providerCatalog"; import { useDistroStore } from "@/features/settings/stores/distroStore"; -import { filterModelProvidersForDistro } from "../distroProviderConstraints"; +import { + filterModelProvidersForDistro, + isProviderAllowedByAllowlist, + parseProviderAllowlist, +} from "../distroProviderConstraints"; +import { useProviderCatalogStore } from "../stores/providerCatalogStore"; function isConfiguredGooseModelProvider( entry: ProviderInventoryEntryDto, modelProviderIds: Set, + providerAllowlist: Set | null, + catalogLoaded: boolean, ): boolean { if (!entry.configured) { return false; } + if (entry.category === "agent") { + return false; + } + if (entry.providerType === "Custom") { return entry.providerId.startsWith("custom_"); } + if (!catalogLoaded) { + return isProviderAllowedByAllowlist(entry.providerId, providerAllowlist); + } + return modelProviderIds.has(entry.providerId); } @@ -44,6 +59,12 @@ export function useProviderInventory() { const entries = useProviderInventoryStore((s) => s.entries); const loading = useProviderInventoryStore((s) => s.loading); const distro = useDistroStore((s) => s.manifest); + const catalogEntries = useProviderCatalogStore((s) => s.entries); + const catalogLoaded = useProviderCatalogStore((s) => s.loaded); + const providerAllowlist = useMemo( + () => parseProviderAllowlist(distro), + [distro], + ); const getEntry = useCallback( (providerId: string) => entries.get(providerId), @@ -62,19 +83,25 @@ export function useProviderInventory() { const modelProviderIds = useMemo( () => new Set( - filterModelProvidersForDistro(getModelProviders(), distro).map( - (provider) => provider.id, - ), + filterModelProvidersForDistro( + getModelProvidersFromEntries(catalogEntries), + distro, + ).map((provider) => provider.id), ), - [distro], + [catalogEntries, distro], ); const configuredModelProviderEntries = useMemo( () => [...entries.values()].filter((entry) => - isConfiguredGooseModelProvider(entry, modelProviderIds), + isConfiguredGooseModelProvider( + entry, + modelProviderIds, + providerAllowlist, + catalogLoaded, + ), ), - [entries, modelProviderIds], + [catalogLoaded, entries, modelProviderIds, providerAllowlist], ); const getModelsForAgent = useCallback( diff --git a/ui/goose2/src/features/providers/lib/customProviderTypes.ts b/ui/goose2/src/features/providers/lib/customProviderTypes.ts index 1f082696..d05d7382 100644 --- a/ui/goose2/src/features/providers/lib/customProviderTypes.ts +++ b/ui/goose2/src/features/providers/lib/customProviderTypes.ts @@ -5,7 +5,7 @@ import type { CustomProviderDeleteResponse, CustomProviderReadResponse, CustomProviderUpdateResponse, - ProviderCatalogEntryDto, + ProviderTemplateCatalogEntryDto, ProviderTemplateDto, } from "@aaif/goose-sdk"; @@ -53,6 +53,6 @@ export type { CustomProviderDeleteResponse, CustomProviderReadResponse, CustomProviderUpdateResponse, - ProviderCatalogEntryDto, + ProviderTemplateCatalogEntryDto, ProviderTemplateDto, }; diff --git a/ui/goose2/src/features/providers/lib/providerKey.ts b/ui/goose2/src/features/providers/lib/providerKey.ts new file mode 100644 index 00000000..da901d17 --- /dev/null +++ b/ui/goose2/src/features/providers/lib/providerKey.ts @@ -0,0 +1,8 @@ +export function normalizeProviderKey(value: string): string { + return value + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "_") + .split("_") + .filter(Boolean) + .join("_"); +} diff --git a/ui/goose2/src/features/providers/providerCatalog.test.ts b/ui/goose2/src/features/providers/providerCatalog.test.ts index 244e8973..7eaaa5da 100644 --- a/ui/goose2/src/features/providers/providerCatalog.test.ts +++ b/ui/goose2/src/features/providers/providerCatalog.test.ts @@ -1,16 +1,92 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; import { + getAgentProviders, getCatalogEntry, getModelProviders, resolveAgentProviderCatalogId, } from "./providerCatalog"; +import { useProviderCatalogStore } from "./stores/providerCatalogStore"; -describe("provider catalog", () => { - it("exposes Ollama host configuration", () => { - const ollama = getCatalogEntry("ollama"); +const catalogEntries: ProviderCatalogEntry[] = [ + { + id: "goose", + displayName: "Goose", + category: "agent", + description: "Block's open-source coding agent", + setupMethod: "none", + group: "default", + aliases: ["goose"], + }, + { + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "Anthropic's agentic coding tool", + setupMethod: "cli_auth", + binaryName: "claude-agent-acp", + group: "default", + aliases: ["claude-acp", "claude_code", "claude"], + supportsInstall: true, + supportsAuth: true, + supportsAuthStatus: true, + }, + { + id: "codex-acp", + displayName: "Codex", + category: "agent", + description: "OpenAI's coding agent", + setupMethod: "cli_auth", + binaryName: "codex-acp", + group: "default", + aliases: ["codex-acp", "codex_cli", "codex"], + }, + { + id: "ollama", + displayName: "Ollama", + category: "model", + description: "Run local or self-hosted models", + setupMethod: "config_fields", + fields: [ + { + key: "OLLAMA_HOST", + label: "Host", + secret: false, + required: true, + placeholder: "localhost or http://localhost:11434", + defaultValue: "http://localhost:11434", + }, + ], + docsUrl: "https://ollama.com", + group: "default", + }, +]; - expect(ollama?.setupMethod).toBe("config_fields"); - expect(ollama?.fields).toEqual([ +describe("provider catalog selectors", () => { + beforeEach(() => { + useProviderCatalogStore.getState().reset(); + }); + + it("returns the Goose fallback before the cache is loaded", () => { + expect(getCatalogEntry("ollama")).toBeUndefined(); + expect(getAgentProviders().map((provider) => provider.id)).toEqual([ + "goose", + ]); + expect(getModelProviders()).toEqual([]); + }); + + it("uses loaded cache entries for provider selectors", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); + + expect(getAgentProviders().map((provider) => provider.id)).toEqual([ + "goose", + "claude-acp", + "codex-acp", + ]); + expect(getModelProviders().map((provider) => provider.id)).toEqual([ + "ollama", + ]); + expect(getCatalogEntry("ollama")?.fields).toEqual([ { key: "OLLAMA_HOST", label: "Host", @@ -22,87 +98,47 @@ describe("provider catalog", () => { ]); }); - it("uses backend model provider ids for the curated catalog", () => { - const ids = getModelProviders().map((provider) => provider.id); + it("matches direct agent ids", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); - expect(ids).toEqual([ - "anthropic", - "google", - "chatgpt_codex", - "openai", - "mistral", - "ollama", - "openrouter", - "databricks", - "github_copilot", - "custom_deepseek", - "xai", - "groq", - "azure_openai", - "aws_bedrock", - "gcp_vertex_ai", - "litellm", - "lmstudio", - "nvidia", - "cerebras", - "snowflake", - ]); - expect(ids).not.toContain("azure"); - expect(ids).not.toContain("bedrock"); - expect(ids).not.toContain("deepseek"); - expect(ids).not.toContain("local_inference"); - }); - - it("marks the planned promoted model providers", () => { - const promotedIds = getModelProviders() - .filter((provider) => provider.tier === "promoted") - .map((provider) => provider.id); - - expect(promotedIds).toEqual([ - "anthropic", - "google", - "chatgpt_codex", - "openai", - "mistral", - "ollama", - "openrouter", - "databricks", - "github_copilot", - ]); - }); -}); - -describe("resolveAgentProviderCatalogId", () => { - it("matches direct catalog ids", () => { - expect(resolveAgentProviderCatalogId("cursor-agent", "Cursor Agent")).toBe( - "cursor-agent", - ); - }); - - it("matches common agent aliases", () => { - expect(resolveAgentProviderCatalogId("codex-cli", "Codex CLI")).toBe( - "codex-acp", - ); - expect(resolveAgentProviderCatalogId("claude-code", "Claude Code")).toBe( + expect(resolveAgentProviderCatalogId("claude-acp", "Claude Code")).toBe( "claude-acp", ); }); - it("does not treat model providers as agents", () => { - expect( - resolveAgentProviderCatalogId("databricks", "Databricks"), - ).toBeNull(); - }); + it("matches backend-provided agent aliases", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); - it("matches fuzzy agent labels with extra suffixes", () => { - expect( - resolveAgentProviderCatalogId("custom-id", "Claude Code (ACP)"), - ).toBe("claude-acp"); - expect(resolveAgentProviderCatalogId("custom-id", "Codex CLI (ACP)")).toBe( + expect(resolveAgentProviderCatalogId("codex-cli", "Codex CLI")).toBe( "codex-acp", ); expect( - resolveAgentProviderCatalogId("custom-id", "Cursor Agent Stable"), - ).toBe("cursor-agent"); + resolveAgentProviderCatalogId("custom-id", "Claude Code (ACP)"), + ).toBe("claude-acp"); + }); + + it("matches suffixed agent labels from backend aliases", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); + + expect(resolveAgentProviderCatalogId("custom-id", "Codex CLI (ACP)")).toBe( + "codex-acp", + ); + }); + + it("does not match aliases embedded in unrelated labels", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); + + expect( + resolveAgentProviderCatalogId("custom-id", "Acme Claude Tools"), + ).toBeNull(); + expect( + resolveAgentProviderCatalogId("custom-id", "Codex compatible API"), + ).toBeNull(); + }); + + it("does not treat model providers as agents", () => { + useProviderCatalogStore.getState().setEntries(catalogEntries); + + expect(resolveAgentProviderCatalogId("ollama", "Ollama")).toBeNull(); }); }); diff --git a/ui/goose2/src/features/providers/providerCatalog.ts b/ui/goose2/src/features/providers/providerCatalog.ts index 4a103a67..71ceee81 100644 --- a/ui/goose2/src/features/providers/providerCatalog.ts +++ b/ui/goose2/src/features/providers/providerCatalog.ts @@ -1,47 +1,120 @@ import type { ProviderCatalogEntry } from "@/shared/types/providers"; -import { - AGENT_PROVIDER_ALIAS_MAP, - AGENT_PROVIDER_FUZZY_MATCHERS, - normalizeProviderKey, -} from "./providerCatalogAliases"; -import { - AGENT_PROVIDER_CATALOG, - MODEL_PROVIDER_CATALOG, -} from "./providerCatalogEntries"; +import { useProviderCatalogStore } from "./stores/providerCatalogStore"; +import { normalizeProviderKey } from "./lib/providerKey"; -export const PROVIDER_CATALOG: ProviderCatalogEntry[] = [ - ...AGENT_PROVIDER_CATALOG, - ...MODEL_PROVIDER_CATALOG, -]; +export { normalizeProviderKey }; + +export function getProviderCatalog(): ProviderCatalogEntry[] { + return useProviderCatalogStore.getState().entries; +} export function getCatalogEntry( providerId: string, ): ProviderCatalogEntry | undefined { - return PROVIDER_CATALOG.find((p) => p.id === providerId); + return getCatalogEntryFromEntries(getProviderCatalog(), providerId); } export function getAgentProviders(): ProviderCatalogEntry[] { - return AGENT_PROVIDER_CATALOG; + return getAgentProvidersFromEntries(getProviderCatalog()); } export function getModelProviders(): ProviderCatalogEntry[] { - return MODEL_PROVIDER_CATALOG; + return getModelProvidersFromEntries(getProviderCatalog()); +} + +export function getCatalogEntryFromEntries( + entries: ProviderCatalogEntry[], + providerId: string, +): ProviderCatalogEntry | undefined { + return entries.find((provider) => provider.id === providerId); +} + +export function getAgentProvidersFromEntries( + entries: ProviderCatalogEntry[], +): ProviderCatalogEntry[] { + return entries.filter((provider) => provider.category === "agent"); +} + +export function getModelProvidersFromEntries( + entries: ProviderCatalogEntry[], +): ProviderCatalogEntry[] { + return entries.filter((provider) => provider.category === "model"); +} + +export function resolveAgentProviderCatalogIdStrictFromEntries( + entries: ProviderCatalogEntry[], + providerId: string, +): string | null { + const directMatch = entries.find((provider) => provider.id === providerId); + if (directMatch?.category === "agent") { + return directMatch.id; + } + + const normalized = normalizeProviderKey(providerId); + for (const provider of entries) { + if (provider.category !== "agent") { + continue; + } + const aliases = [provider.id, ...(provider.aliases ?? [])]; + if (aliases.some((alias) => normalizeProviderKey(alias) === normalized)) { + return provider.id; + } + } + + return null; } export function resolveAgentProviderCatalogIdStrict( providerId: string, ): string | null { - const directMatch = getAgentProviders().find( - (provider) => provider.id === providerId, + return resolveAgentProviderCatalogIdStrictFromEntries( + getProviderCatalog(), + providerId, ); - if (directMatch) { - return directMatch.id; +} + +function normalizedAliasMatchesCandidate(alias: string, candidate: string) { + const normalizedAlias = normalizeProviderKey(alias); + if (!normalizedAlias) { + return false; } - const normalized = normalizeProviderKey(providerId); - const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[normalized]; - if (aliasMatch) { - return aliasMatch; + const candidates = new Set([candidate]); + if (candidate.endsWith("_acp")) { + candidates.add(candidate.slice(0, -"_acp".length)); + } + + return candidates.has(normalizedAlias); +} + +export function resolveAgentProviderCatalogIdFromEntries( + entries: ProviderCatalogEntry[], + providerId: string, + label?: string, +): string | null { + const directMatch = resolveAgentProviderCatalogIdStrictFromEntries( + entries, + providerId, + ); + if (directMatch) { + return directMatch; + } + + const normalizedCandidates = [providerId, label ?? ""] + .map((value) => normalizeProviderKey(value)) + .filter(Boolean); + + for (const candidate of normalizedCandidates) { + for (const provider of entries) { + if (provider.category !== "agent") { + continue; + } + for (const alias of [provider.id, ...(provider.aliases ?? [])]) { + if (normalizedAliasMatchesCandidate(alias, candidate)) { + return provider.id; + } + } + } } return null; @@ -51,31 +124,9 @@ export function resolveAgentProviderCatalogId( providerId: string, label?: string, ): string | null { - const directMatch = getAgentProviders().find( - (provider) => provider.id === providerId, + return resolveAgentProviderCatalogIdFromEntries( + getProviderCatalog(), + providerId, + label, ); - if (directMatch) { - return directMatch.id; - } - - const normalizedCandidates = [providerId, label ?? ""] - .map((value) => normalizeProviderKey(value)) - .filter(Boolean); - - for (const candidate of normalizedCandidates) { - const aliasMatch = AGENT_PROVIDER_ALIAS_MAP[candidate]; - if (aliasMatch) { - return aliasMatch; - } - } - - for (const candidate of normalizedCandidates) { - for (const [needle, catalogId] of AGENT_PROVIDER_FUZZY_MATCHERS) { - if (candidate.includes(needle)) { - return catalogId; - } - } - } - - return null; } diff --git a/ui/goose2/src/features/providers/providerCatalogAliases.ts b/ui/goose2/src/features/providers/providerCatalogAliases.ts deleted file mode 100644 index 91b19e5b..00000000 --- a/ui/goose2/src/features/providers/providerCatalogAliases.ts +++ /dev/null @@ -1,35 +0,0 @@ -export function normalizeProviderKey(value: string): string { - return value - .toLowerCase() - .split(/[-_\s]+/) - .filter(Boolean) - .join("_"); -} - -export const AGENT_PROVIDER_ALIAS_MAP: Record = { - goose: "goose", - claude_acp: "claude-acp", - claude_code: "claude-acp", - claude: "claude-acp", - codex_acp: "codex-acp", - codex_cli: "codex-acp", - codex: "codex-acp", - copilot_acp: "copilot-acp", - github_copilot: "copilot-acp", - github_copilot_cli: "copilot-acp", - cursor_agent: "cursor-agent", - cursor: "cursor-agent", - amp_acp: "amp-acp", - amp: "amp-acp", - pi_acp: "pi-acp", - pi: "pi-acp", -}; - -export const AGENT_PROVIDER_FUZZY_MATCHERS: Array<[string, string]> = [ - ["goose", "goose"], - ["claude", "claude-acp"], - ["codex", "codex-acp"], - ["cursor", "cursor-agent"], - ["copilot", "copilot-acp"], - ["amp", "amp-acp"], -]; diff --git a/ui/goose2/src/features/providers/providerCatalogEntries.ts b/ui/goose2/src/features/providers/providerCatalogEntries.ts deleted file mode 100644 index a3c1f017..00000000 --- a/ui/goose2/src/features/providers/providerCatalogEntries.ts +++ /dev/null @@ -1,481 +0,0 @@ -import type { ProviderCatalogEntry } from "@/shared/types/providers"; - -export const AGENT_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ - { - id: "goose", - displayName: "Goose", - category: "agent", - description: "Block's open-source coding agent", - setupMethod: "none", - tier: "promoted", - }, - { - id: "claude-acp", - displayName: "Claude Code", - category: "agent", - description: "Anthropic's agentic coding tool", - setupMethod: "cli_auth", - binaryName: "claude-agent-acp", - installCommand: - "npm install -g @anthropic-ai/claude-code @agentclientprotocol/claude-agent-acp", - authCommand: "claude auth login", - authStatusCommand: "claude auth status", - docsUrl: "https://docs.anthropic.com/en/docs/claude-code", - tier: "promoted", - }, - { - id: "codex-acp", - displayName: "Codex", - category: "agent", - description: "OpenAI's coding agent", - setupMethod: "cli_auth", - binaryName: "codex-acp", - installCommand: "npm install -g @openai/codex @zed-industries/codex-acp", - authCommand: "codex login", - authStatusCommand: "codex login status", - docsUrl: "https://github.com/openai/codex", - tier: "promoted", - }, - { - id: "copilot-acp", - displayName: "GitHub Copilot", - category: "agent", - description: "GitHub's AI pair programmer", - setupMethod: "cli_auth", - binaryName: "copilot", - installCommand: "npm install -g @github/copilot", - authCommand: "copilot login", - docsUrl: "https://docs.github.com/en/copilot/github-copilot-in-the-cli", - tier: "promoted", - }, - { - id: "amp-acp", - displayName: "Amp", - category: "agent", - description: "Sourcegraph's coding agent", - setupMethod: "cli_auth", - binaryName: "amp-acp", - installCommand: "npm install -g @sourcegraph/amp@latest amp-acp", - authCommand: "amp login", - authStatusCommand: "amp usage", - docsUrl: "https://ampcode.com", - tier: "standard", - }, - { - id: "cursor-agent", - displayName: "Cursor Agent", - category: "agent", - description: "Cursor's AI agent", - setupMethod: "cli_auth", - binaryName: "cursor-agent", - installCommand: "curl -fsSL https://cursor.com/install | bash", - authCommand: "cursor-agent login", - authStatusCommand: "cursor-agent status", - docsUrl: "https://docs.cursor.com/en/cli/overview", - tier: "standard", - }, - { - id: "pi-acp", - displayName: "Pi", - category: "agent", - description: "Open-source AI coding agent", - setupMethod: "cli_auth", - binaryName: "pi-acp", - docsUrl: "https://github.com/badlogic/pi-mono", - tier: "standard", - showOnlyWhenInstalled: true, - }, -]; - -export const MODEL_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ - { - id: "anthropic", - displayName: "Anthropic", - category: "model", - description: "Claude models", - setupMethod: "single_api_key", - envVar: "ANTHROPIC_API_KEY", - fields: [ - { - key: "ANTHROPIC_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://console.anthropic.com/settings/keys", - tier: "promoted", - }, - { - id: "google", - displayName: "Google Gemini", - category: "model", - description: "Gemini models", - setupMethod: "single_api_key", - envVar: "GOOGLE_API_KEY", - fields: [ - { - key: "GOOGLE_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://aistudio.google.com/apikey", - tier: "promoted", - }, - { - id: "chatgpt_codex", - displayName: "ChatGPT", - category: "model", - description: "OpenAI via ChatGPT subscription", - setupMethod: "oauth_device_code", - nativeConnectQuery: "ChatGPT Codex", - docsUrl: "https://chatgpt.com", - tier: "promoted", - }, - { - id: "openai", - displayName: "OpenAI", - category: "model", - description: "GPT and o-series models", - setupMethod: "config_fields", - envVar: "OPENAI_API_KEY", - fields: [ - { - key: "OPENAI_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://platform.openai.com/api-keys", - tier: "promoted", - }, - { - id: "mistral", - displayName: "Mistral AI", - category: "model", - description: "Frontier models from Mistral AI", - setupMethod: "single_api_key", - envVar: "MISTRAL_API_KEY", - fields: [ - { - key: "MISTRAL_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://console.mistral.ai/api-keys", - tier: "promoted", - }, - { - id: "ollama", - displayName: "Ollama", - category: "model", - description: "Run local or self-hosted models", - setupMethod: "config_fields", - fields: [ - { - key: "OLLAMA_HOST", - label: "Host", - secret: false, - required: true, - placeholder: "localhost or http://localhost:11434", - defaultValue: "http://localhost:11434", - }, - ], - docsUrl: "https://ollama.com", - tier: "promoted", - }, - { - id: "openrouter", - displayName: "OpenRouter", - category: "model", - description: "Unified API for many models", - setupMethod: "single_api_key", - envVar: "OPENROUTER_API_KEY", - fields: [ - { - key: "OPENROUTER_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://openrouter.ai/keys", - tier: "promoted", - }, - { - id: "databricks", - displayName: "Databricks", - category: "model", - description: "Databricks Foundation Models", - setupMethod: "host_with_oauth_fallback", - fields: [ - { - key: "DATABRICKS_HOST", - label: "Host URL", - secret: false, - required: true, - placeholder: "https://dbc-...cloud.databricks.com", - }, - { - key: "DATABRICKS_TOKEN", - label: "Access Token", - secret: true, - required: false, - placeholder: "Paste your access token", - }, - ], - tier: "promoted", - }, - { - id: "github_copilot", - displayName: "GitHub Copilot Models", - category: "model", - description: "Models via GitHub Copilot subscription", - setupMethod: "oauth_device_code", - nativeConnectQuery: "GitHub Copilot", - tier: "promoted", - }, - { - id: "custom_deepseek", - displayName: "DeepSeek", - category: "model", - description: "DeepSeek chat and reasoning models", - setupMethod: "single_api_key", - envVar: "DEEPSEEK_API_KEY", - fields: [ - { - key: "DEEPSEEK_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://platform.deepseek.com/api_keys", - tier: "advanced", - }, - { - id: "xai", - displayName: "xAI", - category: "model", - description: "Grok models", - setupMethod: "single_api_key", - envVar: "XAI_API_KEY", - fields: [ - { - key: "XAI_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - tier: "advanced", - }, - { - id: "groq", - displayName: "Groq", - category: "model", - description: "Fast inference with Groq hardware", - setupMethod: "single_api_key", - envVar: "GROQ_API_KEY", - fields: [ - { - key: "GROQ_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://console.groq.com/keys", - tier: "advanced", - }, - { - id: "azure_openai", - displayName: "Azure OpenAI", - category: "model", - description: "OpenAI models on Azure", - setupMethod: "config_fields", - fields: [ - { - key: "AZURE_OPENAI_ENDPOINT", - label: "Endpoint", - secret: false, - required: true, - placeholder: "https://your-resource.openai.azure.com", - }, - { - key: "AZURE_OPENAI_DEPLOYMENT_NAME", - label: "Deployment", - secret: false, - required: true, - placeholder: "gpt-4o", - }, - { - key: "AZURE_OPENAI_API_KEY", - label: "API Key", - secret: true, - required: false, - placeholder: "Paste your API key", - }, - ], - tier: "advanced", - }, - { - id: "aws_bedrock", - displayName: "AWS Bedrock", - category: "model", - description: "Models on AWS", - setupMethod: "cloud_credentials", - fields: [ - { - key: "AWS_REGION", - label: "AWS Region", - secret: false, - required: false, - placeholder: "us-west-2", - }, - ], - tier: "advanced", - }, - { - id: "gcp_vertex_ai", - displayName: "GCP Vertex AI", - category: "model", - description: "Models on Google Cloud", - setupMethod: "cloud_credentials", - fields: [ - { - key: "GCP_PROJECT_ID", - label: "Project ID", - secret: false, - required: true, - placeholder: "my-gcp-project", - }, - { - key: "GCP_LOCATION", - label: "Location", - secret: false, - required: true, - placeholder: "us-central1", - }, - ], - tier: "advanced", - }, - { - id: "litellm", - displayName: "LiteLLM", - category: "model", - description: "LiteLLM proxy gateway", - setupMethod: "config_fields", - envVar: "LITELLM_API_KEY", - fields: [ - { - key: "LITELLM_HOST", - label: "Host URL", - secret: false, - required: true, - placeholder: "https://your-proxy.example.com", - }, - { - key: "LITELLM_API_KEY", - label: "API Key", - secret: true, - required: false, - placeholder: "Paste your API key", - }, - ], - tier: "advanced", - }, - { - id: "lmstudio", - displayName: "LM Studio", - category: "model", - description: "Run local models with LM Studio", - setupMethod: "config_fields", - fields: [ - { - key: "LMSTUDIO_HOST", - label: "Host URL", - secret: false, - required: false, - placeholder: "http://localhost:1234/v1/chat/completions", - }, - ], - docsUrl: "https://lmstudio.ai/docs/app/api", - tier: "advanced", - }, - { - id: "nvidia", - displayName: "NVIDIA", - category: "model", - description: "Hosted NVIDIA NIM models", - setupMethod: "single_api_key", - envVar: "NVIDIA_API_KEY", - fields: [ - { - key: "NVIDIA_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://build.nvidia.com/models", - tier: "advanced", - }, - { - id: "cerebras", - displayName: "Cerebras", - category: "model", - description: "Fast inference on Cerebras wafer-scale engines", - setupMethod: "single_api_key", - envVar: "CEREBRAS_API_KEY", - fields: [ - { - key: "CEREBRAS_API_KEY", - label: "API Key", - secret: true, - required: true, - placeholder: "Paste your API key", - }, - ], - docsUrl: "https://cloud.cerebras.ai/platform", - tier: "advanced", - }, - { - id: "snowflake", - displayName: "Snowflake", - category: "model", - description: "Snowflake Cortex", - setupMethod: "config_fields", - fields: [ - { - key: "SNOWFLAKE_HOST", - label: "Host URL", - secret: false, - required: true, - placeholder: "https://your-account.snowflakecomputing.com", - }, - { - key: "SNOWFLAKE_TOKEN", - label: "Access Token", - secret: true, - required: true, - placeholder: "Paste your access token", - }, - ], - tier: "advanced", - }, -]; diff --git a/ui/goose2/src/features/providers/stores/providerCatalogStore.ts b/ui/goose2/src/features/providers/stores/providerCatalogStore.ts new file mode 100644 index 00000000..5c2f1d20 --- /dev/null +++ b/ui/goose2/src/features/providers/stores/providerCatalogStore.ts @@ -0,0 +1,99 @@ +import { create } from "zustand"; +import type { ProviderCatalogEntry } from "@/shared/types/providers"; + +export const GOOSE_PROVIDER_CATALOG_ENTRY: ProviderCatalogEntry = { + id: "goose", + displayName: "Goose", + category: "agent", + description: "Block's open-source coding agent", + setupMethod: "none", + group: "default", + aliases: ["goose"], +}; + +function withGooseFallback( + entries: ProviderCatalogEntry[], +): ProviderCatalogEntry[] { + if (entries.some((entry) => entry.id === GOOSE_PROVIDER_CATALOG_ENTRY.id)) { + return entries; + } + return [GOOSE_PROVIDER_CATALOG_ENTRY, ...entries]; +} + +export interface ProviderCatalogState { + entries: ProviderCatalogEntry[]; + loading: boolean; + loaded: boolean; + error: string | null; +} + +interface ProviderCatalogActions { + load: () => Promise; + setEntries: (entries: ProviderCatalogEntry[]) => void; + reset: () => void; +} + +export type ProviderCatalogStore = ProviderCatalogState & + ProviderCatalogActions; + +let loadPromise: Promise | null = null; + +function emptyState(): ProviderCatalogState { + return { + entries: [GOOSE_PROVIDER_CATALOG_ENTRY], + loading: false, + loaded: false, + error: null, + }; +} + +export const useProviderCatalogStore = create( + (set, get) => ({ + ...emptyState(), + + load: async () => { + if (loadPromise) { + return loadPromise; + } + + const current = get(); + if (current.loaded) { + return current.entries; + } + + set({ loading: true, error: null }); + loadPromise = import("../api/catalog") + .then(({ listProviderSetupCatalog }) => listProviderSetupCatalog()) + .then((entries) => { + get().setEntries(entries); + return entries; + }) + .catch((error: unknown) => { + const message = + error instanceof Error ? error.message : "Failed to load catalog"; + set({ loading: false, loaded: false, error: message }); + throw error; + }) + .finally(() => { + loadPromise = null; + }); + + return loadPromise; + }, + + setEntries: (entries) => { + const nextEntries = withGooseFallback(entries); + set({ + entries: nextEntries, + loading: false, + loaded: true, + error: null, + }); + }, + + reset: () => { + loadPromise = null; + set(emptyState()); + }, + }), +); diff --git a/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx index f1da3113..15bb8f34 100644 --- a/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx +++ b/ui/goose2/src/features/settings/ui/AgentProviderCard.tsx @@ -33,8 +33,9 @@ interface AgentProviderCardProps { export function AgentProviderCard({ provider }: AgentProviderCardProps) { const { t } = useTranslation(["settings", "common"]); const isBuiltIn = provider.status === "built_in"; - const hasInstallCommand = !!provider.installCommand; - const hasAuthCommand = !!provider.authCommand; + const supportsInstall = provider.supportsInstall === true; + const supportsAuth = provider.supportsAuth === true; + const supportsAuthStatus = provider.supportsAuthStatus === true; const hasBinary = !!provider.binaryName; const [setupPhase, setSetupPhase] = useState("idle"); const [setupOutput, setSetupOutput] = useState([]); @@ -44,9 +45,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { hasBinary && !isBuiltIn ? "checking" : "installed", ); const [authStatus, setAuthStatus] = useState( - provider.authStatusCommand && hasBinary && !isBuiltIn - ? "checking" - : "unknown", + supportsAuthStatus && hasBinary && !isBuiltIn ? "checking" : "unknown", ); const outputRef = useRef(null); const outputLengthRef = useRef(0); @@ -93,13 +92,13 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { .then((installed) => { if (!isMountedRef.current) return; setInstallStatus(installed ? "installed" : "missing"); - if (installed && provider.authStatusCommand) { + if (installed && supportsAuthStatus) { return checkAgentAuth(provider.id).then((authenticated) => { if (!isMountedRef.current) return; setAuthStatus(authenticated ? "authenticated" : "unauthenticated"); }); } - if (installed && !provider.authStatusCommand) { + if (installed && !supportsAuthStatus) { setAuthStatus(getAuthHint() ? "authenticated" : "unknown"); } if (!installed) { @@ -118,7 +117,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { isBuiltIn, provider.id, provider.binaryName, - provider.authStatusCommand, + supportsAuthStatus, setAuthHint, ]); @@ -145,15 +144,15 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { setSetupOutput([]); lineCounterRef.current = 0; - if (hasInstallCommand && installStatus === "missing") { + if (supportsInstall && installStatus === "missing") { await runInstall(); - } else if (hasAuthCommand) { + } else if (supportsAuth) { await runAuth(); } } async function runInstall() { - if (!provider.installCommand) return; + if (!supportsInstall) return; setSetupPhase("installing"); clearListener(); @@ -183,7 +182,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { } } - if (hasAuthCommand) { + if (supportsAuth) { await runAuth(); } else { if (!isMountedRef.current) return; @@ -198,7 +197,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { } async function runAuth() { - if (!provider.authCommand) return; + if (!supportsAuth) return; setSetupPhase("authenticating"); setSetupOutput([]); @@ -232,14 +231,14 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { const isReady = isBuiltIn || - (installStatus === "installed" && !hasAuthCommand) || + (installStatus === "installed" && !supportsAuth) || (installStatus === "installed" && authStatus === "authenticated"); const needsAuth = installStatus === "installed" && - hasAuthCommand && + supportsAuth && authStatus !== "checking" && authStatus !== "authenticated"; - const needsInstall = installStatus === "missing" && hasInstallCommand; + const needsInstall = installStatus === "missing" && supportsInstall; const isChecking = (installStatus === "checking" && hasBinary) || (installStatus === "installed" && authStatus === "checking"); @@ -388,9 +387,9 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) { : t("providers.agents.progress.verifyingInstallation"); const stepInfo = - setupPhase === "installing" && hasAuthCommand + setupPhase === "installing" && supportsAuth ? t("providers.agents.progress.step", { step: 1, total: 2 }) - : setupPhase === "authenticating" && hasInstallCommand + : setupPhase === "authenticating" && supportsInstall ? t("providers.agents.progress.step", { step: 2, total: 2 }) : null; diff --git a/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx index 1be0fee7..7d046f0e 100644 --- a/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx +++ b/ui/goose2/src/features/settings/ui/ModelProviderPanels.tsx @@ -139,7 +139,7 @@ export function ConnectedFieldsPanel({ field.secret && resolveFieldValue(field, fieldValueMap).isSet ? getDisplayValue(field, fieldValueMap, t) - : field.placeholder + : (field.placeholder ?? undefined) } onChange={(event) => onDraftChange(field.key, event.target.value) @@ -273,7 +273,7 @@ export function SetupFieldsPanel({ placeholder={ field.secret && fieldValue.isSet ? getDisplayValue(field, fieldValueMap, t) - : field.placeholder + : (field.placeholder ?? undefined) } onChange={(event) => onDraftChange(field.key, event.target.value)} disabled={saving} diff --git a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx index 2bb91556..fc706ca1 100644 --- a/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx +++ b/ui/goose2/src/features/settings/ui/ProvidersSettings.tsx @@ -15,8 +15,8 @@ import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; import { IconChevronDown, IconPlus } from "@tabler/icons-react"; import { - getAgentProviders, - getModelProviders, + getAgentProvidersFromEntries, + getModelProvidersFromEntries, } from "@/features/providers/providerCatalog"; import { useCredentials } from "@/features/providers/hooks/useCredentials"; import { useDistroStore } from "@/features/settings/stores/distroStore"; @@ -35,6 +35,7 @@ import type { ProviderTemplate, } from "@/features/providers/ui/CustomProviderForm"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; import { AgentProviderCard } from "./AgentProviderCard"; import { ModelProviderRow } from "./ModelProviderRow"; import { SettingsPage } from "@/shared/ui/SettingsPage"; @@ -120,6 +121,11 @@ export function ProvidersSettings() { const [pendingCustomProviderDelete, setPendingCustomProviderDelete] = useState(null); const inventoryEntries = useProviderInventoryStore((state) => state.entries); + const catalogEntries = useProviderCatalogStore((state) => state.entries); + const catalogLoading = useProviderCatalogStore((state) => state.loading); + const catalogLoaded = useProviderCatalogStore((state) => state.loaded); + const catalogError = useProviderCatalogStore((state) => state.error); + const loadCatalog = useProviderCatalogStore((state) => state.load); const { configuredIds, @@ -135,17 +141,24 @@ export function ProvidersSettings() { const customProvidersApi = useCustomProviders(); const agents = useMemo( - () => toDisplayInfo(getAgentProviders(), configuredIds), - [configuredIds], + () => + toDisplayInfo( + getAgentProvidersFromEntries(catalogEntries), + configuredIds, + ), + [configuredIds, catalogEntries], ); const allModels = useMemo( () => toDisplayInfo( - filterModelProvidersForDistro(getModelProviders(), distro), + filterModelProvidersForDistro( + getModelProvidersFromEntries(catalogEntries), + distro, + ), configuredIds, ), - [configuredIds, distro], + [configuredIds, distro, catalogEntries], ); const sortedModels = useMemo(() => { @@ -159,10 +172,10 @@ export function ProvidersSettings() { }, [allModels]); useEffect(() => { - if (!loading && modelOrder === null) { + if (!loading && catalogLoaded && modelOrder === null) { setModelOrder(sortedModels.map((model) => model.id)); } - }, [loading, modelOrder, sortedModels]); + }, [loading, catalogLoaded, modelOrder, sortedModels]); const orderedModels = useMemo(() => { if (!modelOrder) { @@ -190,11 +203,11 @@ export function ProvidersSettings() { }); }, [allModels, modelOrder, sortedModels]); - const promotedModels = orderedModels.filter( - (m) => m.tier === "promoted" || m.tier === "standard", + const defaultModels = orderedModels.filter((m) => m.group === "default"); + const additionalModels = orderedModels.filter( + (m) => m.group === "additional", ); - const advancedModels = orderedModels.filter((m) => m.tier === "advanced"); - const visibleModels = showAllModels ? orderedModels : promotedModels; + const visibleModels = showAllModels ? orderedModels : defaultModels; const customProviders = useMemo( () => @@ -329,6 +342,35 @@ export function ProvidersSettings() { } > + {catalogError && ( +
+
+

{catalogError}

+ +
+
+ )} + + {catalogLoading && ( +
+ + {t("providers.catalog.loading")} +
+ )} +

@@ -417,7 +459,7 @@ export function ProvidersSettings() { ))}

- {!showAllModels && advancedModels.length > 0 && ( + {!showAllModels && additionalModels.length > 0 && ( )} - {showAllModels && advancedModels.length > 0 && ( + {showAllModels && additionalModels.length > 0 && (