Bedrock model discovery and validation (#9997)

This commit is contained in:
Gellért Stadler 2026-07-01 23:19:28 +02:00 committed by GitHub
parent e5a0162513
commit 7026e14585
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 720 additions and 135 deletions

26
Cargo.lock generated
View file

@ -678,6 +678,31 @@ dependencies = [
"uuid",
]
[[package]]
name = "aws-sdk-bedrock"
version = "1.146.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd3d870793928e6c18de8273dcdceed08af8cc9083be9d7de3e52efc7ac01e9e"
dependencies = [
"arc-swap",
"aws-credential-types",
"aws-runtime",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-observability",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"http 1.4.2",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-bedrockruntime"
version = "1.133.0"
@ -4876,6 +4901,7 @@ dependencies = [
"async-trait",
"aws-config",
"aws-lc-rs",
"aws-sdk-bedrock",
"aws-sdk-bedrockruntime",
"aws-sdk-sagemakerruntime",
"aws-smithy-types",

View file

@ -811,10 +811,8 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
let spin = spinner();
spin.start("Checking your configuration...");
let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM")
.map(|val| val == "1" || val.to_lowercase() == "true")
.unwrap_or(false);
let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok();
let (toolshim_enabled, toolshim_model) =
goose::providers::provider_test::toolshim_settings_from_env();
match test_provider_configuration(provider_name, &model, toolshim_enabled, toolshim_model).await
{

View file

@ -17,6 +17,7 @@ use goose::agents::ExtensionConfig;
use goose::config::resolve_extensions_for_new_session;
use goose::config::{Config, GooseMode};
use goose::providers::create;
use goose::providers::provider_test::test_provider_model;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::session_manager::SessionType;
@ -591,6 +592,11 @@ async fn update_agent_provider(
if let Some(request_params) = payload.request_params {
model_config = model_config.with_merged_request_params(request_params);
}
test_provider_model(&payload.provider, &model)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
let model_info = resolve_provider_model_info(&payload.provider, &model)
.await
.map_err(|e| (e.status, e.message))?;

View file

@ -19,6 +19,7 @@ use goose::providers::catalog::{
ProviderTemplate,
};
use goose::providers::create_with_default_model;
use goose::providers::provider_test::test_provider_model;
use goose::providers::providers as get_providers;
use goose::{
agents::execute_commands, agents::ExtensionConfig, slash_commands::recipe_slash_command,
@ -108,6 +109,8 @@ fn normalize_custom_provider_api_key(api_key: String) -> Option<String> {
#[derive(Deserialize, ToSchema)]
pub struct CheckProviderRequest {
pub provider: String,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Deserialize, ToSchema)]
@ -827,8 +830,20 @@ pub async fn update_custom_provider(
request_body = CheckProviderRequest,
)]
pub async fn check_provider(
Json(CheckProviderRequest { provider }): Json<CheckProviderRequest>,
Json(CheckProviderRequest { provider, model }): Json<CheckProviderRequest>,
) -> Result<(), ErrorResponse> {
if let Some(model) = model.filter(|model| !model.trim().is_empty()) {
test_provider_model(&provider, &model)
.await
.map_err(|err| {
ErrorResponse::bad_request(format!(
"Provider '{}' with model '{}' check failed: {}",
provider, model, err
))
})?;
return Ok(());
}
create_with_default_model(&provider, Vec::new())
.await
.map_err(|err| {
@ -845,19 +860,22 @@ pub async fn check_provider(
pub async fn set_config_provider(
Json(SetProviderRequest { provider, model }): Json<SetProviderRequest>,
) -> Result<(), ErrorResponse> {
create_with_default_model(&provider, Vec::new())
test_provider_model(&provider, &model)
.await
.and_then(|_| {
let config = Config::global();
goose::config::set_active_provider(config, &provider, &model)
.map_err(|e| anyhow::anyhow!(e))
})
.map_err(|err| {
ErrorResponse::bad_request(format!(
"Failed to set provider to '{}' with model '{}': {}",
provider, model, err
))
})?;
let config = Config::global();
goose::config::set_active_provider(config, &provider, &model).map_err(|err| {
ErrorResponse::bad_request(format!(
"Failed to set provider to '{}' with model '{}': {}",
provider, model, err
))
})?;
Ok(())
}

View file

@ -35,6 +35,7 @@ local-inference = [
aws-providers = [
"dep:aws-config",
"dep:aws-smithy-types",
"dep:aws-sdk-bedrock",
"dep:aws-sdk-bedrockruntime",
"dep:aws-sdk-sagemakerruntime",
"dep:smithy-transport-reqwest",
@ -234,6 +235,7 @@ llama-cpp-sys-2 = { workspace = true, optional = true }
image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
subtle = { version = "2.5", default-features = false, features = ["std"] }
gethostname = "1.1.0"
aws-sdk-bedrock = { version = "1.132", default-features = false, features = ["rt-tokio"], optional = true }
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { workspace = true }

View file

@ -215,7 +215,8 @@ impl GooseAcpAgent {
}
if let Some(model_id) = model_id.as_deref() {
let model_exists = entry.default_model == model_id
let model_exists = entry.supports_refresh
|| entry.default_model == model_id
|| entry.models.iter().any(|model| model.id == model_id);
if !model_exists {
return Err(agent_client_protocol::Error::invalid_params().data(format!(

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata};
use super::openai_compatible::{handle_status, stream_responses_compat};
@ -8,11 +8,16 @@ use crate::session_context::SESSION_ID_HEADER;
use anyhow::Result;
use async_stream::try_stream;
use async_trait::async_trait;
use aws_sdk_bedrock::types::{
FoundationModelLifecycleStatus, FoundationModelSummary, InferenceProfileStatus,
InferenceProfileType, InferenceType, ModelModality,
};
use aws_sdk_bedrock::Client as BedrockControlClient;
use aws_sdk_bedrockruntime::config::ProvideCredentials;
use aws_sdk_bedrockruntime::operation::converse::ConverseError;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamError;
use aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError;
use aws_sdk_bedrockruntime::{types as bedrock, Client};
use aws_sdk_bedrockruntime::{types as bedrock, Client as BedrockRuntimeClient};
use base64::Engine;
use futures::future::BoxFuture;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
@ -36,15 +41,8 @@ pub const BEDROCK_DOC_LINK: &str =
"https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html";
pub const BEDROCK_DEFAULT_MODEL: &str = "us.anthropic.claude-sonnet-4-5-20250929-v1:0";
pub const BEDROCK_KNOWN_MODELS: &[&str] = &[
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
"us.anthropic.claude-sonnet-4-20250514-v1:0",
"us.anthropic.claude-3-7-sonnet-20250219-v1:0",
"us.anthropic.claude-opus-4-20250514-v1:0",
"us.anthropic.claude-opus-4-1-20250805-v1:0",
"openai.gpt-5.5",
"openai.gpt-5.4",
];
pub const BEDROCK_MANTLE_MODELS: &[&str] = &["openai.gpt-5.5", "openai.gpt-5.4"];
pub const BEDROCK_BOOTSTRAP_MODELS: &[&str] = &[BEDROCK_DEFAULT_MODEL];
pub const BEDROCK_DEFAULT_MAX_RETRIES: usize = 6;
pub const BEDROCK_DEFAULT_INITIAL_RETRY_INTERVAL_MS: u64 = 2000;
@ -54,7 +52,9 @@ pub const BEDROCK_DEFAULT_MAX_RETRY_INTERVAL_MS: u64 = 120_000;
#[derive(Debug, serde::Serialize)]
pub struct BedrockProvider {
#[serde(skip)]
client: Client,
client: BedrockRuntimeClient,
#[serde(skip)]
control_plane_client: BedrockControlClient,
#[serde(skip)]
retry_config: RetryConfig,
#[serde(skip)]
@ -163,15 +163,26 @@ impl BedrockProvider {
))
.build();
Client::from_conf(bedrock_config)
BedrockRuntimeClient::from_conf(bedrock_config)
} else {
Self::create_client_with_credentials(&sdk_config).await?
Self::create_runtime_client_with_credentials(&sdk_config).await?
};
let control_plane_client = if let Some(ref token) = bearer_token {
let bedrock_config = aws_sdk_bedrock::Config::new(&sdk_config)
.to_builder()
.bearer_token(aws_sdk_bedrock::config::Token::new(token.clone(), None))
.build();
BedrockControlClient::from_conf(bedrock_config)
} else {
BedrockControlClient::new(&sdk_config)
};
let retry_config = Self::load_retry_config(config);
Ok(Self {
client,
control_plane_client,
retry_config,
name: BEDROCK_PROVIDER_NAME.to_string(),
region: resolved_region,
@ -181,7 +192,9 @@ impl BedrockProvider {
})
}
async fn create_client_with_credentials(sdk_config: &aws_config::SdkConfig) -> Result<Client> {
async fn create_runtime_client_with_credentials(
sdk_config: &aws_config::SdkConfig,
) -> Result<BedrockRuntimeClient> {
sdk_config
.credentials_provider()
.ok_or_else(|| anyhow::anyhow!("No AWS credentials provider configured"))?
@ -194,7 +207,152 @@ impl BedrockProvider {
)
})?;
Ok(Client::new(sdk_config))
Ok(BedrockRuntimeClient::new(sdk_config))
}
fn bootstrap_models(include_mantle: bool) -> Vec<String> {
let mut models: Vec<String> = BEDROCK_BOOTSTRAP_MODELS
.iter()
.map(|model| model.to_string())
.collect();
if include_mantle {
models.extend(BEDROCK_MANTLE_MODELS.iter().map(|model| model.to_string()));
}
models
}
fn merge_discovered_model_ids(
inference_profiles: impl IntoIterator<Item = String>,
foundation_models: impl IntoIterator<Item = String>,
extra_models: impl IntoIterator<Item = String>,
) -> Vec<String> {
let mut models = BTreeSet::new();
models.extend(inference_profiles);
models.extend(foundation_models);
models.extend(extra_models);
models.into_iter().collect()
}
fn is_excluded_non_chat_model_id(model_id: &str) -> bool {
let id = model_id.to_lowercase();
id.contains(".embed")
|| id.contains("-embed-")
|| id.ends_with("-embed")
|| id.contains(".rerank")
|| id.contains("-rerank-")
|| id.ends_with("-rerank")
}
fn is_chat_capable_model_id(model_id: &str) -> bool {
!Self::is_excluded_non_chat_model_id(model_id)
}
fn is_chat_capable_foundation_model(summary: &FoundationModelSummary) -> bool {
if !Self::is_chat_capable_model_id(summary.model_id()) {
return false;
}
let has_text_input = summary.input_modalities().contains(&ModelModality::Text);
let has_text_output = summary.output_modalities().contains(&ModelModality::Text);
let has_embedding_output = summary
.output_modalities()
.contains(&ModelModality::Embedding);
let streaming = summary.response_streaming_supported().unwrap_or(false);
let not_legacy = summary
.model_lifecycle()
.map(|lifecycle| lifecycle.status() != &FoundationModelLifecycleStatus::Legacy)
.unwrap_or(true);
has_text_input && has_text_output && !has_embedding_output && streaming && not_legacy
}
fn is_mantle_model_id(model_id: &str) -> bool {
BEDROCK_MANTLE_MODELS.contains(&model_id)
}
async fn fetch_inference_profile_ids(&self) -> Result<Vec<String>, ProviderError> {
let mut ids = BTreeSet::new();
for profile_type in [
InferenceProfileType::SystemDefined,
InferenceProfileType::Application,
] {
let mut next_token = None;
loop {
let mut request = self
.control_plane_client
.list_inference_profiles()
.type_equals(profile_type.clone());
if let Some(token) = &next_token {
request = request.next_token(token);
}
let response = request.send().await.map_err(|err| {
ProviderError::ExecutionError(format!(
"Failed to list Bedrock inference profiles: {}",
err
))
})?;
for summary in response.inference_profile_summaries() {
if summary.status() == &InferenceProfileStatus::Active {
let id = summary.inference_profile_id();
if Self::is_chat_capable_model_id(id) {
ids.insert(id.to_string());
}
}
}
next_token = response.next_token().map(|token| token.to_string());
if next_token.is_none() {
break;
}
}
}
Ok(ids.into_iter().collect())
}
async fn fetch_foundation_model_ids(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.control_plane_client
.list_foundation_models()
.by_inference_type(InferenceType::OnDemand)
.by_output_modality(ModelModality::Text)
.send()
.await
.map_err(|err| {
ProviderError::ExecutionError(format!(
"Failed to list Bedrock foundation models: {}",
err
))
})?;
Ok(response
.model_summaries()
.iter()
.filter(|summary| Self::is_chat_capable_foundation_model(summary))
.map(|summary| summary.model_id().to_string())
.collect())
}
async fn fetch_models_from_aws(&self) -> Result<Vec<String>, ProviderError> {
let inference_profiles = self.fetch_inference_profile_ids().await?;
let foundation_models = self.fetch_foundation_model_ids().await?;
let extra_models = if self.bearer_token.is_some() {
BEDROCK_MANTLE_MODELS
.iter()
.map(|model| model.to_string())
.collect()
} else {
Vec::new()
};
Ok(Self::merge_discovered_model_ids(
inference_profiles,
foundation_models,
extra_models,
))
}
fn load_retry_config(config: &crate::config::Config) -> RetryConfig {
@ -402,6 +560,12 @@ impl BedrockProvider {
"Bedrock validation error: {}",
err.message().unwrap_or("unknown validation error")
)),
ConverseError::ResourceNotFoundException(err) => {
ProviderError::ExecutionError(format!(
"Bedrock model not found or not accessible: {}",
err.message().unwrap_or("unknown resource error")
))
}
ConverseError::ModelErrorException(err) => {
ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err))
}
@ -494,6 +658,18 @@ impl BedrockProvider {
err
))
}
ConverseStreamError::ValidationException(err) => {
ProviderError::ExecutionError(format!(
"Bedrock validation error: {}",
err.message().unwrap_or("unknown validation error")
))
}
ConverseStreamError::ResourceNotFoundException(err) => {
ProviderError::ExecutionError(format!(
"Bedrock model not found or not accessible: {}",
err.message().unwrap_or("unknown resource error")
))
}
ConverseStreamError::ModelErrorException(err) => {
ProviderError::ExecutionError(format!("Failed to call Bedrock: {:?}", err))
}
@ -689,9 +865,9 @@ impl goose_providers::base::ProviderDescriptor for BedrockProvider {
ProviderMetadata::new(
BEDROCK_PROVIDER_NAME,
"Amazon Bedrock",
"Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile <profile-name>' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.",
"Run models through Amazon Bedrock. Supports AWS SSO profiles - run 'aws sso login --profile <profile-name>' before using. Configure with AWS_PROFILE and AWS_REGION, use environment variables/credentials, or use AWS_BEARER_TOKEN_BEDROCK for bearer token authentication. Region is required for bearer token auth (can be set via AWS_REGION, AWS_DEFAULT_REGION, or AWS profile). Model discovery requires bedrock:ListFoundationModels and bedrock:ListInferenceProfiles permissions. Prompt caching can be enabled for Anthropic Claude models by setting BEDROCK_ENABLE_CACHING=true. Responses stream via the ConverseStream API; set BEDROCK_DISABLE_STREAMING=true to fall back to blocking Converse calls.",
BEDROCK_DEFAULT_MODEL,
BEDROCK_KNOWN_MODELS.to_vec(),
BEDROCK_BOOTSTRAP_MODELS.to_vec(),
BEDROCK_DOC_LINK,
vec![
ConfigKey::new("AWS_PROFILE", false, false, Some("default"), true),
@ -731,8 +907,25 @@ impl Provider for BedrockProvider {
self.retry_config.clone()
}
fn skip_canonical_filtering(&self) -> bool {
true
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
Ok(BEDROCK_KNOWN_MODELS.iter().map(|s| s.to_string()).collect())
match self.fetch_models_from_aws().await {
Ok(models) if !models.is_empty() => Ok(models),
Ok(_) => {
tracing::debug!("Bedrock model discovery returned no models, using bootstrap list");
Ok(Self::bootstrap_models(self.bearer_token.is_some()))
}
Err(err) => {
tracing::warn!(
"Bedrock model discovery failed ({}), using bootstrap list",
err
);
Ok(Self::bootstrap_models(self.bearer_token.is_some()))
}
}
}
async fn stream(
@ -756,7 +949,7 @@ impl Provider for BedrockProvider {
let (base_name, effort) = extract_reasoning_effort(without_prefix);
let bedrock_model_id = format!("openai.{}", base_name);
let is_mantle_model = BEDROCK_KNOWN_MODELS.contains(&bedrock_model_id.as_str());
let is_mantle_model = Self::is_mantle_model_id(&bedrock_model_id);
if is_mantle_model {
let mut normalized_config = ModelConfig {
@ -919,11 +1112,23 @@ mod tests {
.behavior_version(aws_config::BehaviorVersion::latest())
.region(aws_config::Region::new("us-east-1"))
.build();
let client = Client::new(&sdk_config);
let client = BedrockRuntimeClient::new(&sdk_config);
let control_plane_client = BedrockControlClient::new(&sdk_config);
let model = ModelConfig {
model_name: model_name.to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
request_params: None,
reasoning: None,
};
(
BedrockProvider {
client,
control_plane_client,
retry_config: RetryConfig::default(),
name: "aws_bedrock".to_string(),
region: None,
@ -931,16 +1136,7 @@ mod tests {
http_client: reqwest::Client::new(),
mantle_base_url: None,
},
ModelConfig {
model_name: model_name.to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
request_params: None,
reasoning: None,
},
model,
)
}
@ -1006,6 +1202,21 @@ mod tests {
);
}
#[test]
#[serial]
fn test_caching_disabled_by_default() {
std::env::set_var("BEDROCK_ENABLE_CACHING", "false");
let (provider, model) =
create_mock_provider_and_model("us.anthropic.claude-sonnet-4-5-20250929-v1:0");
assert!(
!provider.should_enable_caching(&model),
"Caching should be disabled by default"
);
std::env::remove_var("BEDROCK_ENABLE_CACHING");
}
#[test]
fn test_caching_disabled_for_non_claude_models() {
let (provider, model) = create_mock_provider_and_model("amazon.titan-text-express-v1");
@ -1093,9 +1304,19 @@ mod tests {
.region(aws_config::Region::new("us-east-1"))
.build();
let model = ModelConfig::new("openai.gpt-5.5");
let model = ModelConfig {
model_name: "openai.gpt-5.5".to_string(),
context_limit: None,
temperature: None,
max_tokens: None,
toolshim: false,
toolshim_model: None,
request_params: None,
reasoning: None,
};
let provider = BedrockProvider {
client: Client::new(&sdk_config),
client: BedrockRuntimeClient::new(&sdk_config),
control_plane_client: BedrockControlClient::new(&sdk_config),
retry_config: RetryConfig::default(),
name: "aws_bedrock".to_string(),
region: Some("us-east-1".to_string()),
@ -1497,4 +1718,116 @@ mod tests {
other => panic!("expected RedactedThinking, got {:?}", other),
}
}
#[test]
fn test_merge_discovered_model_ids_dedupes_and_sorts() {
let models = BedrockProvider::merge_discovered_model_ids(
[
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
"us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
],
[
"anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(),
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
],
["openai.gpt-5.5".to_string()],
);
assert_eq!(
models,
vec![
"anthropic.claude-3-5-sonnet-20240620-v1:0".to_string(),
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
"openai.gpt-5.5".to_string(),
"us.anthropic.claude-sonnet-4-5-20250929-v1:0".to_string(),
]
);
}
#[test]
fn test_bootstrap_models_includes_mantle_with_bearer_token() {
let without_mantle = BedrockProvider::bootstrap_models(false);
assert_eq!(without_mantle, vec![BEDROCK_DEFAULT_MODEL.to_string()]);
let with_mantle = BedrockProvider::bootstrap_models(true);
assert!(with_mantle.contains(&"openai.gpt-5.5".to_string()));
assert!(with_mantle.contains(&BEDROCK_DEFAULT_MODEL.to_string()));
}
#[test]
fn test_is_mantle_model_id() {
assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.5"));
assert!(BedrockProvider::is_mantle_model_id("openai.gpt-5.4"));
assert!(!BedrockProvider::is_mantle_model_id(
"openai.gpt-oss-120b-1:0"
));
assert!(!BedrockProvider::is_mantle_model_id(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
));
}
#[test]
fn test_skip_canonical_filtering_enabled() {
let (provider, _) = create_mock_provider_and_model("test");
assert!(provider.skip_canonical_filtering());
}
#[test]
fn test_is_chat_capable_foundation_model_filters_embeddings() {
let chat_model = FoundationModelSummary::builder()
.model_arn("arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0")
.model_id("anthropic.claude-3-5-sonnet-20240620-v1:0")
.input_modalities(ModelModality::Text)
.output_modalities(ModelModality::Text)
.response_streaming_supported(true)
.build()
.unwrap();
assert!(BedrockProvider::is_chat_capable_foundation_model(
&chat_model
));
let embedding_model = FoundationModelSummary::builder()
.model_arn("arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0")
.model_id("amazon.titan-embed-text-v2:0")
.input_modalities(ModelModality::Text)
.output_modalities(ModelModality::Embedding)
.response_streaming_supported(false)
.build()
.unwrap();
assert!(!BedrockProvider::is_chat_capable_foundation_model(
&embedding_model
));
let cohere_embed_v4 = FoundationModelSummary::builder()
.model_arn("arn:aws:bedrock:us-east-1::foundation-model/cohere.embed-v4:0")
.model_id("cohere.embed-v4:0")
.input_modalities(ModelModality::Text)
.output_modalities(ModelModality::Text)
.output_modalities(ModelModality::Embedding)
.response_streaming_supported(true)
.build()
.unwrap();
assert!(!BedrockProvider::is_chat_capable_foundation_model(
&cohere_embed_v4
));
}
#[test]
fn test_is_chat_capable_model_id_excludes_embed_and_rerank_profiles() {
assert!(!BedrockProvider::is_chat_capable_model_id(
"cohere.embed-v4:0"
));
assert!(!BedrockProvider::is_chat_capable_model_id(
"us.cohere.embed-v4:0"
));
assert!(!BedrockProvider::is_chat_capable_model_id(
"amazon.titan-embed-text-v2:0"
));
assert!(!BedrockProvider::is_chat_capable_model_id(
"cohere.rerank-v3-5:0"
));
assert!(BedrockProvider::is_chat_capable_model_id(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0"
));
}
}

View file

@ -70,7 +70,10 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<AvianProvider>(false);
registry.register::<AzureProvider>(false);
#[cfg(feature = "aws-providers")]
registry.register::<BedrockProvider>(false);
registry.register_with_inventory::<BedrockProvider>(
false,
Some(registrations::bedrock_inventory()),
);
#[cfg(feature = "local-inference")]
registry.register::<LocalInferenceProvider>(false);
registry.register_with_inventory::<ChatGptCodexProvider>(

View file

@ -6,6 +6,8 @@ use crate::config::{self, Config};
use crate::providers::acp_tooling::{acp_adapter_installed, resolved_acp_command};
use crate::providers::amp_acp::{AMP_ACP_BINARY, AMP_ACP_PROVIDER_NAME};
use crate::providers::base::ProviderDescriptor;
#[cfg(feature = "aws-providers")]
use crate::providers::bedrock::{BedrockProvider, BEDROCK_PROVIDER_NAME};
use crate::providers::chatgpt_codex::TokenCache as ChatGptCodexTokenCache;
use crate::providers::claude_acp::{CLAUDE_ACP_BINARY, CLAUDE_ACP_PROVIDER_NAME};
use crate::providers::codex_acp::CODEX_ACP_PROVIDER_NAME;
@ -125,6 +127,20 @@ pub fn ollama_inventory() -> InventoryRegistration {
})
}
#[cfg(feature = "aws-providers")]
pub fn bedrock_inventory() -> InventoryRegistration {
InventoryRegistration::new(true, || {
let config = Config::global();
let metadata = BedrockProvider::metadata();
Ok(default_inventory_identity(
BEDROCK_PROVIDER_NAME,
BEDROCK_PROVIDER_NAME,
&metadata.config_keys,
config,
))
})
}
pub fn huggingface_inventory() -> InventoryRegistration {
InventoryRegistration::new(false, || {
let metadata = HuggingFaceProvider::metadata();

View file

@ -3,45 +3,88 @@ use anyhow::Result;
use futures::StreamExt;
use rmcp::model::ToolAnnotations;
use rmcp::{model::Tool, object};
use std::time::Duration;
use tokio::time::timeout;
const PROVIDER_TEST_TIMEOUT: Duration = Duration::from_secs(60);
pub fn provider_model_validation_enabled() -> bool {
!matches!(
std::env::var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION"),
Ok(value) if value == "1" || value.eq_ignore_ascii_case("true")
)
}
pub fn toolshim_settings_from_env() -> (Option<bool>, Option<String>) {
let toolshim_enabled = std::env::var("GOOSE_TOOLSHIM")
.map(|val| val == "1" || val.to_lowercase() == "true")
.ok();
let toolshim_model = std::env::var("GOOSE_TOOLSHIM_OLLAMA_MODEL").ok();
(toolshim_enabled, toolshim_model)
}
pub async fn test_provider_model(provider_name: &str, model: &str) -> Result<()> {
if !provider_model_validation_enabled() {
return Ok(());
}
test_provider_configuration(provider_name, model, None, None).await
}
pub async fn test_provider_configuration(
provider_name: &str,
model: &str,
toolshim_enabled: bool,
toolshim_enabled: Option<bool>,
toolshim_model: Option<String>,
) -> Result<()> {
let model_config = crate::model_config::model_config_from_user_config(provider_name, model)?
.with_max_tokens(Some(50))
.with_toolshim(toolshim_enabled)
.with_toolshim_model(toolshim_model);
let mut model_config =
crate::model_config::model_config_from_user_config(provider_name, model)?
.with_max_tokens(Some(50));
if let Some(toolshim_enabled) = toolshim_enabled {
model_config = model_config.with_toolshim(toolshim_enabled);
}
if toolshim_model.is_some() {
model_config = model_config.with_toolshim_model(toolshim_model);
}
let provider = create(provider_name, Vec::new()).await?;
let messages =
vec![Message::user().with_text("What is the weather like in San Francisco today?")];
let tools = if !toolshim_enabled {
let tools = if !model_config.toolshim {
vec![create_sample_weather_tool()]
} else {
vec![]
};
let mut stream = crate::session_context::with_session_id(
Some("test-session-id".to_string()),
provider.stream(
&model_config,
"You are an AI agent called goose. You use tools of connected extensions to solve problems.",
&messages,
&tools.into_iter().collect::<Vec<_>>(),
),
)
.await?;
timeout(PROVIDER_TEST_TIMEOUT, async {
let mut stream = crate::session_context::with_session_id(
Some("test-session-id".to_string()),
provider.stream(
&model_config,
"You are an AI agent called goose. You use tools of connected extensions to solve problems.",
&messages,
&tools.into_iter().collect::<Vec<_>>(),
),
)
.await?;
let first_chunk = stream
.next()
.await
.ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?;
first_chunk?;
let first_chunk = stream
.next()
.await
.ok_or_else(|| anyhow::anyhow!("Provider test stream returned no events"))?;
first_chunk?;
Ok::<(), anyhow::Error>(())
})
.await
.map_err(|_| {
anyhow::anyhow!(
"Provider configuration test timed out after {}s",
PROVIDER_TEST_TIMEOUT.as_secs()
)
})??;
Ok(())
}

View file

@ -574,16 +574,13 @@ fn test_steer_session_adds_input_to_active_prompt() {
let mut steer_sent = false;
let mut steer_message_id: Option<String> = None;
let mut final_response = None;
let mut observed_updates = Vec::new();
let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
tokio::select! {
response = &mut prompt => {
final_response = Some(response.unwrap());
break;
}
_ = tokio::time::sleep(Duration::from_millis(10)), if !steer_sent => {
let updates = session.session_updates();
biased;
updates = session.wait_for_session_updates(), if !steer_sent => {
if let Some(run_id) = updates.iter().find_map(active_run_id_from_update) {
let response = send_custom(
conn.cx(),
@ -607,6 +604,11 @@ fn test_steer_session_adds_input_to_active_prompt() {
steer_message_id = mid.map(ToString::to_string);
steer_sent = true;
}
observed_updates.extend(updates);
}
response = &mut prompt => {
final_response = Some(response.unwrap());
break;
}
}
}
@ -615,7 +617,8 @@ fn test_steer_session_adds_input_to_active_prompt() {
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert!(steer_sent, "test never observed an active run id");
let updates = session.session_updates();
let mut updates = observed_updates;
updates.extend(session.session_updates());
let agent_text = collect_agent_text(&updates);
assert!(
agent_text.contains("saw steer"),

View file

@ -770,6 +770,7 @@ where
if std::env::var_os("GOOSE_PATH_ROOT").is_none() {
std::env::set_var("GOOSE_PATH_ROOT", ACP_CONFIG_ROOT.path());
}
std::env::set_var("GOOSE_SKIP_PROVIDER_MODEL_VALIDATION", "1");
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
let handle = std::thread::Builder::new()

View file

@ -62,6 +62,17 @@ impl AcpServerSession {
.collect()
}
pub async fn wait_for_session_updates(&self) -> Vec<SessionUpdate> {
loop {
let notified = self.notify.notified();
let updates = self.session_updates();
if !updates.is_empty() {
return updates;
}
notified.await;
}
}
async fn send_prompt(
&mut self,
content: Vec<ContentBlock>,

View file

@ -3033,6 +3033,10 @@
"provider"
],
"properties": {
"model": {
"type": "string",
"nullable": true
},
"provider": {
"type": "string"
}

View file

@ -11,6 +11,36 @@ import { getAcpClient } from './acpConnection';
export type { CanonicalModelInfoDto, ProviderSecretDto };
function acpErrorMessage(error: unknown): string | null {
if (typeof error !== 'object' || error === null) {
return null;
}
const candidate = 'error' in error && isRecord(error.error) ? error.error : error;
if (!isRecord(candidate)) {
return null;
}
if (typeof candidate.data === 'string') {
return candidate.data;
}
return typeof candidate.message === 'string' ? candidate.message : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeAcpError(error: unknown, fallback: string): Error {
const message = acpErrorMessage(error);
if (message) {
return new Error(message);
}
if (error instanceof Error) {
return error;
}
return new Error(fallback);
}
function updateRequestToCreate(
request: UpdateCustomProviderRequest
): CustomProviderCreateRequest_unstable {
@ -175,8 +205,12 @@ export async function acpReadDefaults(): Promise<{
}
export async function acpSaveDefaults(providerId: string, modelId?: string | null): Promise<void> {
const client = await getAcpClient();
await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null });
try {
const client = await getAcpClient();
await client.goose.defaultsSave_unstable({ providerId, modelId: modelId ?? null });
} catch (error) {
throw normalizeAcpError(error, 'Failed to save default provider/model');
}
}
export async function acpClearDefaults(): Promise<void> {
@ -259,26 +293,30 @@ export async function acpSetSessionProviderModel(
modelId?: string | null,
thinkingEffort?: ThinkingEffort | null
): Promise<AppliedSessionProviderModel> {
const client = await getAcpClient();
let response = await client.setSessionConfigOption({
sessionId,
configId: 'provider',
value: providerId,
});
if (modelId) {
response = await client.setSessionConfigOption({
try {
const client = await getAcpClient();
let response = await client.setSessionConfigOption({
sessionId,
configId: 'model',
value: modelId,
configId: 'provider',
value: providerId,
});
}
if (thinkingEffort != null) {
response = await client.setSessionConfigOption({
sessionId,
configId: 'thinking_effort',
value: thinkingEffort,
});
}
if (modelId) {
response = await client.setSessionConfigOption({
sessionId,
configId: 'model',
value: modelId,
});
}
if (thinkingEffort != null) {
response = await client.setSessionConfigOption({
sessionId,
configId: 'thinking_effort',
value: thinkingEffort,
});
}
return extractAppliedSessionProviderModel(response.configOptions);
return extractAppliedSessionProviderModel(response.configOptions);
} catch (error) {
throw normalizeAcpError(error, 'Failed to update session provider/model');
}
}

View file

@ -60,6 +60,7 @@ export type ChatRequest = {
};
export type CheckProviderRequest = {
model?: string | null;
provider: string;
};

View file

@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { toastError, toastSuccess } from '../toasts';
import Model, { getProviderMetadata } from './settings/models/modelInterface';
import Model, { getProviderMetadata, validateProviderModel } from './settings/models/modelInterface';
import type { ProviderMetadata } from '../types/providers';
import { acpChatSessionActions, acpChatSessionStore } from '../acp/chatSessionStore';
import {
@ -93,10 +93,13 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
async (sessionId: string | null, model: Model) => {
const modelName = model.name;
const providerName = model.provider;
let phase = 'agent';
let phase = 'validation';
try {
await validateProviderModel(providerName, modelName);
if (sessionId) {
phase = 'agent';
const applied = await acpSetSessionProviderModel(
sessionId,
providerName,
@ -128,13 +131,14 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
return true;
} catch (error) {
console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`);
const message = errorMessage(error);
toastError({
title: intl.formatMessage(i18n.modelChangeFailed, {
provider: providerName,
model: modelName,
}),
msg: `${error}`,
traceback: errorMessage(error),
msg: message,
traceback: message,
});
return false;
}

View file

@ -1,5 +1,10 @@
import {
checkProvider,
getProviderModelInfo,
getProviderModels,
} from '../../../api';
import { listLocalModels } from '../../../acp/local-inference';
import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers';
import { acpListProviderDetails } from '../../../acp/providers';
import type { ProviderDetails, ThinkingEffort } from '../../../types/providers';
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
@ -43,6 +48,13 @@ export async function getProviderMetadata(providerName: string) {
return matches.metadata;
}
export async function validateProviderModel(provider: string, model: string): Promise<void> {
await checkProvider({
body: { provider, model },
throwOnError: true,
});
}
export interface ProviderModelsResult {
provider: ProviderDetails;
models: Model[] | null;
@ -64,13 +76,16 @@ export async function fetchModelsForProviders(
return { provider: p, models: downloadedModels, error: null, warning: null };
}
const providerModels = await acpListProviderModels(p.name);
const models = providerModels.map(
const response = await getProviderModels({
path: { name: p.name },
throwOnError: true,
});
const models = (response.data || []).map(
(m) =>
({
name: m.id,
name: m.name,
provider: p.name,
context_limit: m.contextLimit ?? undefined,
context_limit: m.context_limit,
reasoning: m.reasoning ?? undefined,
}) as Model
);
@ -118,9 +133,11 @@ export async function fetchModelReasoning(
fallback?: boolean
): Promise<boolean | null> {
try {
const models = await acpListProviderModels(provider);
const match = models.find((m) => m.id === model);
return match?.reasoning ?? fallback ?? null;
const response = await getProviderModelInfo({
path: { name: provider },
body: { model },
});
return response.data?.reasoning ?? fallback ?? null;
} catch {
return fallback ?? null;
}

View file

@ -132,6 +132,10 @@ const i18n = defineMessages({
id: 'switchModelModal.loadingModels',
defaultMessage: 'Loading models…',
},
checkingModel: {
id: 'switchModelModal.checkingModel',
defaultMessage: 'Checking model…',
},
selectModelPlaceholder: {
id: 'switchModelModal.selectModelPlaceholder',
defaultMessage: 'Select a model, type to search',
@ -302,6 +306,7 @@ export const SwitchModelModal = ({
const [selectedPredefinedModel, setSelectedPredefinedModel] = useState<Model | null>(null);
const [predefinedModels, setPredefinedModels] = useState<Model[]>([]);
const [loadingModels, setLoadingModels] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [userClearedModel, setUserClearedModel] = useState(false);
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
const [providerWarnings, setProviderWarnings] = useState<Record<string, string>>({});
@ -395,41 +400,46 @@ export const SwitchModelModal = ({
setAttemptedSubmit(true);
const isFormValid = validateForm();
if (isFormValid) {
let modelObj: Model;
if (!isFormValid) {
return;
}
if (usePredefinedModels && selectedPredefinedModel) {
modelObj = selectedPredefinedModel;
} else {
const providerMetaData = await getProviderMetadata(provider || '');
const providerDisplayName = providerMetaData.display_name;
modelObj = {
name: model,
provider: provider,
subtext: providerDisplayName,
} as Model;
}
let modelObj: Model;
if (usePredefinedModels && selectedPredefinedModel) {
modelObj = selectedPredefinedModel;
} else {
const providerMetaData = await getProviderMetadata(provider || '');
modelObj = {
name: model,
provider: provider,
subtext: providerMetaData.display_name,
} as Model;
}
modelObj = {
...modelObj,
reasoning: selectedModelReasoning ?? modelObj.reasoning,
};
if (showThinkingControl) {
const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off';
modelObj = {
...modelObj,
reasoning: selectedModelReasoning ?? modelObj.reasoning,
request_params: { ...modelObj.request_params, thinking_effort: effort },
};
acpSaveThinkingEffort(effort).catch(console.warn);
}
if (showThinkingControl) {
const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off';
modelObj = {
...modelObj,
request_params: { ...modelObj.request_params, thinking_effort: effort },
};
acpSaveThinkingEffort(effort).catch(console.warn);
}
setIsSubmitting(true);
try {
const success = await changeModel(sessionId, modelObj);
if (success) {
onModelSelected?.(modelObj.name, modelObj.provider || '');
trackModelChanged(modelObj.provider || '', modelObj.name);
onClose();
}
onClose();
} finally {
setIsSubmitting(false);
}
};
@ -978,11 +988,13 @@ export const SwitchModelModal = ({
{intl.formatMessage(i18n.quickStartGuide)}
</a>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} type="button">
<Button variant="outline" onClick={handleClose} type="button" disabled={isSubmitting}>
{intl.formatMessage(i18n.cancel)}
</Button>
<Button onClick={handleSubmit} disabled={!isValid}>
{intl.formatMessage(i18n.selectModelButton)}
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting
? intl.formatMessage(i18n.checkingModel)
: intl.formatMessage(i18n.selectModelButton)}
</Button>
</div>
</DialogFooter>

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Überprüfen Sie Ihre Anbieterkonfiguration unter Einstellungen → Anbieter"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Modell wird überprüft…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Wählen Sie ein Modell:"
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Check your provider configuration in Settings → Providers"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Checking model…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Choose a model:"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "Cargando modelos…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Comprobando modelo…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "Para usar inferencia local, primero debes descargar un modelo a tu computadora. Ve a Ajustes → Modelos para gestionar los modelos locales."
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Vérifiez la configuration de votre fournisseur dans Paramètres → Fournisseurs"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Vérification du modèle…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Choisissez un modèle :"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "मॉडल लोड हो रहे हैं…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "मॉडल की जाँच की जा रही है…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "स्थानीय अनुमान का उपयोग करने के लिए, आपको पहले अपने कंप्यूटर पर एक मॉडल डाउनलोड करना होगा। स्थानीय मॉडल प्रबंधित करने के लिए Settings → मॉडल पर जाएं।"
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Periksa konfigurasi penyedia Anda di Pengaturan → Penyedia"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Memeriksa model…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Pilih model:"
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Controlla la configurazione del provider in Impostazioni → Provider"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Verifica del modello…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Scegli un modello:"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "モデルを読み込み中…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "モデルを確認中…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "ローカル推論を使用するには、先にモデルをコンピューターにダウンロードする必要があります。設定 → モデルでローカルモデルを管理できます。"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "모델 로드 중…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "모델 확인 중…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "로컬 추론을 사용하려면 먼저 모델을 컴퓨터에 다운로드해야 합니다. 로컬 모델을 관리하려면 설정 → 모델로 이동하세요."
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Semak konfigurasi penyedia anda dalam Tetapan → Penyedia"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Menyemak model…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Pilih model:"
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Verifique a configuração do seu fornecedor em Definições → Fornecedores"
},
"switchModelModal.checkingModel": {
"defaultMessage": "A verificar modelo…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Escolha um modelo:"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "Загрузка моделей…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Проверка модели…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "Чтобы использовать локальный инференс, сначала скачайте модель на компьютер. Перейдите в Настройки → Модели для управления локальными моделями."
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "Modeller yükleniyor…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Model kontrol ediliyor…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "Yerel çıkarımı kullanmak için öncelikle bilgisayarınıza bir model indirmeniz gerekir. Yerel modelleri yönetmek için Ayarlar → Modeller'e gidin."
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "Kiểm tra cấu hình nhà cung cấp của bạn trong Cài đặt → Nhà cung cấp"
},
"switchModelModal.checkingModel": {
"defaultMessage": "Đang kiểm tra mô hình…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "Chọn một mô hình:"
},

View file

@ -4223,6 +4223,9 @@
"switchModelModal.loadingModels": {
"defaultMessage": "正在加载模型…"
},
"switchModelModal.checkingModel": {
"defaultMessage": "正在检查模型…"
},
"switchModelModal.localModelsDescription": {
"defaultMessage": "要使用本地推理,你需要先下载一个模型到电脑上。前往 设置 → 模型 管理本地模型。"
},

View file

@ -4175,6 +4175,9 @@
"switchModelModal.checkProviderConfig": {
"defaultMessage": "請在「設定」→「提供者」中檢查您的提供者設定"
},
"switchModelModal.checkingModel": {
"defaultMessage": "正在檢查模型…"
},
"switchModelModal.chooseModel": {
"defaultMessage": "選擇模型:"
},