From 188489c89025dad998286de903eb788118e35efe Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Fri, 2 Oct 2026 16:41:16 -0500 Subject: [PATCH] Fix provider model inventories and Bedrock verification --- AGENTS.md | 2 +- app/src/ai/llms.rs | 187 ++++++-- app/src/ai/llms_tests.rs | 185 ++++++++ app/src/ai/provider/client.rs | 4 +- app/src/ai/provider/discovery.rs | 159 ++++--- app/src/ai/provider/discovery_tests.rs | 21 + app/src/ai/provider/external_config.rs | 12 +- app/src/ai/remote_logging.rs | 26 +- app/src/settings/ai.rs | 43 +- app/src/settings/ai_tests.rs | 2 + app/src/settings_view/ai_page.rs | 266 ++++++++--- app/src/settings_view/provider_setup_view.rs | 449 ++++++++++++------- crates/galaxy_logging/src/native.rs | 4 + 13 files changed, 1016 insertions(+), 344 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb5e7ffd..33e2949f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,7 +131,7 @@ ACP: controller.rs → ResponseStream → acp_output_stream (session-owned lifec - `convert.rs` — Bedrock request construction and prompt-caching behavior - `client.rs` — AWS SDK client construction, runtime creation, and independent background streaming calls - `models.rs` — Model registry and cross-region inference prefix logic -- `discovery.rs` — AWS profile listing and model discovery (STS identity check + ListFoundationModels) +- `discovery.rs` — Checks bundled `galaxy_bedrock_model_catalog` candidates with AWS `GetFoundationModelAvailability`; agreement, authorization, entitlement, and regional availability must all pass. Cached access is never restored as authorization; per-model enablement survives successful refreshes. Sweeps run once at startup when enabled and once per explicit connection Test; settings saves, reloads, and model toggles must never trigger another sweep. Live verification belongs to `LLMPreferences` and is scoped to the exact connection configuration. - `diagnostic.rs` — Debug logging (enabled via `GALAXY_BEDROCK_DIAGNOSTICS=1`) - `external_config.rs` — Fallback config from Claude Code/OpenCode settings diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 76b30e59..3d4eaae1 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -605,6 +605,11 @@ pub struct LLMPreferences { catalog_refresh_generations: HashMap, u64>, #[cfg(not(target_family = "wasm"))] chatgpt_subscription_models_refresh_in_flight: bool, + bedrock_models_refresh_in_flight: bool, + #[cfg(not(target_family = "wasm"))] + bedrock_verified_config: Option, + #[cfg(not(target_family = "wasm"))] + bedrock_verified_model_ids: HashSet, #[cfg(not(target_family = "wasm"))] acp_selections: HashMap, } @@ -708,8 +713,23 @@ impl LLMPreferences { | AISettingsChangedEvent::AcpAgents { .. } | AISettingsChangedEvent::AcpAgentId { .. } | AISettingsChangedEvent::BedrockModels { .. } + | AISettingsChangedEvent::BedrockAuthMethod { .. } + | AISettingsChangedEvent::BedrockProfile { .. } + | AISettingsChangedEvent::BedrockRegion { .. } + | AISettingsChangedEvent::BedrockAccessKeyId { .. } + | AISettingsChangedEvent::BedrockSecretAccessKey { .. } + | AISettingsChangedEvent::BedrockCrossRegionInference { .. } ) { - let previous_ids: Vec<_> = me.openai_provider_routing.keys().cloned().collect(); + let previous_ids: Vec<_> = me + .models_by_feature + .agent_mode + .choices + .iter() + .filter(|model| { + matches!(model.provider, LLMProvider::LiteLLM | LLMProvider::Bedrock) + }) + .map(|model| model.id.as_str().to_owned()) + .collect(); me.inject_bedrock_models(ctx); me.inject_openai_models(ctx); if matches!( @@ -720,11 +740,6 @@ impl LLMPreferences { ) { me.fetch_openai_models_from_endpoint(ctx); } - if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. }) - && *AISettings::as_ref(ctx).bedrock_enabled.value() - { - me.refresh_bedrock_models(ctx); - } me.refresh_changed_litellm_connections(ctx); // Safety: ensure the default model is still present in choices. // If all provider models were removed, the default_id would dangle. @@ -770,6 +785,11 @@ impl LLMPreferences { catalog_refresh_generations: HashMap::new(), #[cfg(not(target_family = "wasm"))] chatgpt_subscription_models_refresh_in_flight: false, + bedrock_models_refresh_in_flight: false, + #[cfg(not(target_family = "wasm"))] + bedrock_verified_config: None, + #[cfg(not(target_family = "wasm"))] + bedrock_verified_model_ids: HashSet::new(), #[cfg(not(target_family = "wasm"))] acp_selections: HashMap::new(), }; @@ -789,9 +809,8 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] { - // Make the persisted Bedrock catalog available immediately. Agreement - // revalidation stays in the background and replaces this cache when it - // completes, so startup never waits on the per-model AWS checks. + // Saved toggle preferences survive startup, but access must be + // verified against AWS before a Bedrock model becomes selectable. me.inject_bedrock_models(ctx); me.refresh_bedrock_models(ctx); me.inject_openai_models(ctx); @@ -807,40 +826,87 @@ impl LLMPreferences { #[cfg(not(target_family = "wasm"))] fn refresh_bedrock_models(&mut self, ctx: &mut ModelContext) { + if self.bedrock_models_refresh_in_flight { + return; + } let settings = AISettings::as_ref(ctx); if !*settings.bedrock_enabled.value() { return; } - let config = crate::ai::provider::client::BedrockClientConfig { - auth_method: *settings.bedrock_auth_method.value(), - profile: settings.bedrock_profile.value().clone(), - region: settings.bedrock_region.value().clone(), - access_key_id: settings.bedrock_access_key_id.value().clone(), - secret_access_key: settings.bedrock_secret_access_key.value().clone(), - session_token: None, - cross_region_inference: *settings.bedrock_cross_region_inference.value(), - }; + self.bedrock_models_refresh_in_flight = true; + let config = bedrock_discovery_config(settings); + let requested_config = config.clone(); + self.bedrock_verified_config = None; + self.bedrock_verified_model_ids.clear(); + self.inject_bedrock_models(ctx); let _ = ctx.spawn( async move { crate::ai::provider::discovery::discover_available_models(config).await }, - |me, result, ctx| match result { - Ok(models) => { - AISettings::handle(ctx).update(ctx, |settings, ctx| { - if let Err(error) = settings.bedrock_models.set_value(models, ctx) { - log::warn!("[bedrock] Failed to persist discovered models: {error}"); - } - }); - me.inject_bedrock_models(ctx); - me.ensure_default_model_present(); - ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); + move |me, result, ctx| { + let result = result.map(|discovery| { + for record in discovery.records { + crate::ai::remote_logging::log_model_event(ctx, record); + } + discovery.models + }); + me.bedrock_models_refresh_in_flight = false; + if !*AISettings::as_ref(ctx).bedrock_enabled.value() { + return; } - Err(error) => { - log::debug!("[bedrock] Startup model discovery unavailable: {error}"); + if bedrock_discovery_config(AISettings::as_ref(ctx)) != requested_config { + return; + } + match result { + Ok(models) => { + // Verification belongs to this live connection, not serialized settings. + me.bedrock_verified_model_ids = + models.iter().map(|model| model.model_id.clone()).collect(); + me.bedrock_verified_config = Some(requested_config); + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let models = super::provider::discovery::preserve_model_toggles( + settings.bedrock_models.value(), + models, + ); + if let Err(error) = settings.bedrock_models.set_value(models, ctx) { + log::warn!( + "[bedrock] Failed to persist discovered models: {error}" + ); + } + }); + me.inject_bedrock_models(ctx); + me.ensure_default_model_present(); + ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); + } + Err(error) => { + log::debug!("[bedrock] Startup model discovery unavailable: {error}"); + } } }, ); } + #[cfg(not(target_family = "wasm"))] + pub(crate) fn record_bedrock_verification( + &mut self, + config: super::provider::client::BedrockClientConfig, + models: &[BedrockModelConfig], + ctx: &mut ModelContext, + ) { + self.bedrock_verified_model_ids = + models.iter().map(|model| model.model_id.clone()).collect(); + self.bedrock_verified_config = Some(config); + self.inject_bedrock_models(ctx); + self.ensure_default_model_present(); + ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn bedrock_model_is_verified(&self, model_id: &str, ctx: &AppContext) -> bool { + self.bedrock_verified_config.as_ref() + == Some(&bedrock_discovery_config(AISettings::as_ref(ctx))) + && self.bedrock_verified_model_ids.contains(model_id) + } + #[cfg(not(target_family = "wasm"))] fn inject_bedrock_models(&mut self, ctx: &AppContext) { // Remove only previously injected Bedrock entries. Other enabled provider @@ -906,6 +972,9 @@ impl LLMPreferences { let effective = effective; for model in effective { + if !model.enabled || !self.bedrock_model_is_verified(&model.model_id, ctx) { + continue; + } let metadata = model_metadata(&model.model_id); if metadata.is_some_and(|metadata| metadata.supports_agent_runtime() == Some(false)) { log::debug!( @@ -1078,9 +1147,11 @@ impl LLMPreferences { configured_models }; - // The registry replaces legacy settings. Otherwise stale legacy credentials - // win duplicate model IDs before the configured provider is considered. - if settings.openai_providers.value().is_empty() && !single_provider_models.is_empty() { + // Only a registry entry for the same endpoint replaces the legacy + // connection. Adding an unrelated subscription must not hide LiteLLM. + let include_legacy = + settings.legacy_openai_provider_enabled() && !single_provider_models.is_empty(); + if include_legacy { let base_url = settings.openai_base_url.value().clone(); let api_key = { let key = settings.openai_api_key.value().clone(); @@ -1132,7 +1203,7 @@ impl LLMPreferences { .is_none_or(|project| project.trim().is_empty()), OpenAIProviderKind::ChatGPTSubscription => false, }; - if !provider.enabled || missing_credentials || provider.models.is_empty() { + if missing_credentials || provider.models.is_empty() { return None; } Some(( @@ -1147,6 +1218,12 @@ impl LLMPreferences { )) }), ); + // Keep existing raw-model selections routed to registry connections. + // The legacy connection remains selectable under a qualified key when + // it serves the same model as a registered provider. + if include_legacy && provider_entries.len() > 1 { + provider_entries.rotate_left(1); + } if provider_entries.is_empty() { return; @@ -1154,6 +1231,7 @@ impl LLMPreferences { let mut total_injected = 0; let mut seen_model_ids: HashSet = HashSet::new(); + let mut seen_connections = HashSet::new(); for ( provider_name, provider_kind, @@ -1165,14 +1243,13 @@ impl LLMPreferences { models, ) in provider_entries { - if !provider_enabled { + let connection_identity = format!("{provider_kind:?}|{provider_name}|{base_url}|{provider_project_id:?}|{provider_location:?}"); + if !seen_connections.insert(connection_identity.clone()) { continue; } + let mut connection_model_ids = HashSet::new(); for model in &models { - if !model.enabled { - continue; - } - if !seen_model_ids.insert(model.model_id.clone()) { + if !connection_model_ids.insert(model.model_id.clone()) { continue; } @@ -1189,10 +1266,25 @@ impl LLMPreferences { for reasoning_effort in reasoning_efforts { let reasoning_effort = reasoning_effort.cloned(); - let model_key = reasoning_effort.as_deref().map_or_else( + let mut model_key = reasoning_effort.as_deref().map_or_else( || model.model_id.clone(), |effort| openai_model_variant_id(&model.model_id, effort), ); + if !seen_model_ids.insert(model_key.clone()) { + use sha2::{Digest, Sha256}; + let connection_hash = + format!("{:x}", Sha256::digest(connection_identity.as_bytes())); + model_key = format!("{model_key}::connection::{connection_hash}"); + if !seen_model_ids.insert(model_key.clone()) { + continue; + } + } + // Disabled entries still reserve their selection keys so + // toggling a connection cannot silently reroute another + // connection's saved model selection. + if !provider_enabled || !model.enabled { + continue; + } // Register the routing entry. Reasoning variants keep the provider's // actual model ID while using their synthetic key only for selection. @@ -3355,3 +3447,18 @@ fn get_cached_models(_app: &mut AppContext) -> Option { #[cfg(test)] #[path = "llms_tests.rs"] mod tests; + +#[cfg(not(target_family = "wasm"))] +fn bedrock_discovery_config( + settings: &AISettings, +) -> crate::ai::provider::client::BedrockClientConfig { + crate::ai::provider::client::BedrockClientConfig { + auth_method: *settings.bedrock_auth_method.value(), + profile: settings.bedrock_profile.value().clone(), + region: settings.bedrock_region.value().clone(), + access_key_id: settings.bedrock_access_key_id.value().clone(), + secret_access_key: settings.bedrock_secret_access_key.value().clone(), + session_token: None, + cross_region_inference: *settings.bedrock_cross_region_inference.value(), + } +} diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index 523bc2d6..4d8c5c5c 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -161,6 +161,8 @@ fn openai_model(model_id: &str) -> OpenAIModelConfig { fn bedrock_model(model_id: &str) -> BedrockModelConfig { BedrockModelConfig { + enabled: true, + access_verified: true, model_id: model_id.to_string(), display_name: model_id.to_string(), vision_supported: false, @@ -232,6 +234,9 @@ fn empty_preferences() -> LLMPreferences { litellm_connections: Vec::new(), catalog_refresh_generations: HashMap::new(), chatgpt_subscription_models_refresh_in_flight: false, + bedrock_models_refresh_in_flight: false, + bedrock_verified_config: None, + bedrock_verified_model_ids: HashSet::new(), acp_selections: HashMap::new(), } } @@ -304,6 +309,14 @@ fn bedrock_and_openai_provider_models_coexist_in_picker() { let mut preferences = empty_preferences(); app.read(|ctx| { preferences.inject_openai_models(ctx); + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); }); @@ -323,6 +336,14 @@ fn bedrock_and_openai_provider_models_coexist_in_picker() { let mut preferences = empty_preferences(); app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); preferences.inject_openai_models(ctx); }); @@ -429,6 +450,9 @@ fn acp_models_are_injected_only_while_acp_is_enabled() { litellm_connections: Vec::new(), catalog_refresh_generations: HashMap::new(), chatgpt_subscription_models_refresh_in_flight: false, + bedrock_models_refresh_in_flight: false, + bedrock_verified_config: None, + bedrock_verified_model_ids: HashSet::new(), acp_selections: HashMap::new(), }; app.read(|ctx| preferences.inject_acp_models(ctx)); @@ -489,6 +513,14 @@ fn acp_is_selectable_before_protocol_model_discovery_completes() { let mut preferences = empty_preferences(); app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); preferences.inject_acp_models(ctx); }); @@ -571,6 +603,14 @@ fn acp_models_expand_reasoning_levels_for_only_the_configured_agent() { let mut preferences = empty_preferences(); app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); preferences.inject_acp_models(ctx); }); @@ -710,6 +750,14 @@ fn acp_bedrock_models_are_hidden_while_bedrock_is_disabled() { let mut preferences = empty_preferences(); app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); preferences.inject_acp_models(ctx); }); @@ -751,6 +799,14 @@ fn disabled_providers_do_not_leave_models_in_the_runtime_inventory() { let mut preferences = empty_preferences(); app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences.bedrock_verified_model_ids = AISettings::as_ref(ctx) + .bedrock_models + .value() + .iter() + .map(|model| model.model_id.clone()) + .collect(); preferences.inject_bedrock_models(ctx); preferences.inject_openai_models(ctx); }); @@ -940,6 +996,94 @@ fn legacy_chatgpt_default_limits_routing() { ); } +#[test] +fn subscription_and_litellm_connections_keep_overlapping_models_and_independent_toggles() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings.openai_enabled.set_value(true, ctx).unwrap(); + settings + .openai_base_url + .set_value("https://legacy.test/v1".to_owned(), ctx) + .unwrap(); + settings + .openai_models + .set_value(vec![openai_model("legacy-only")], ctx) + .unwrap(); + settings + .openai_providers + .set_value( + vec![ + OpenAIProviderConfig { + kind: OpenAIProviderKind::ChatGPTSubscription, + enabled: true, + name: "Subscription".to_owned(), + base_url: String::new(), + api_key: None, + project_id: None, + location: None, + models: vec![openai_model("shared-model")], + }, + OpenAIProviderConfig { + kind: OpenAIProviderKind::LiteLLM, + enabled: true, + name: "LiteLLM".to_owned(), + base_url: "https://proxy.test/v1".to_owned(), + api_key: None, + project_id: None, + location: None, + models: vec![openai_model("shared-model")], + }, + ], + ctx, + ) + .unwrap(); + }); + let mut preferences = empty_preferences(); + app.read(|ctx| preferences.inject_openai_models(ctx)); + let models = &preferences.models_by_feature.agent_mode.choices; + assert_eq!(models.len(), 3); + for (label, kind) in [ + ("Subscription", OpenAIProviderKind::ChatGPTSubscription), + ("LiteLLM", OpenAIProviderKind::LiteLLM), + ] { + let model = models + .iter() + .find(|model| model.description.as_deref() == Some(label)) + .unwrap(); + let route = preferences + .openai_client_config_for_model(model.id.as_str()) + .unwrap(); + assert_eq!(route.kind, kind); + assert_eq!(route.model.as_deref(), Some("shared-model")); + } + assert!(models + .iter() + .any(|model| model.id.as_str() == "legacy-only")); + let litellm_id = models + .iter() + .find(|model| model.description.as_deref() == Some("LiteLLM")) + .unwrap() + .id + .clone(); + AISettings::handle(&app).update(&mut app, |settings, ctx| { + let mut providers = settings.openai_providers.value().clone(); + providers[0].models[0].enabled = false; + settings.openai_providers.set_value(providers, ctx).unwrap(); + }); + app.read(|ctx| preferences.inject_openai_models(ctx)); + let models = &preferences.models_by_feature.agent_mode.choices; + assert_eq!(models.len(), 2); + assert!(models + .iter() + .all(|model| model.description.as_deref() != Some("Subscription"))); + assert!(models + .iter() + .any(|model| model.description.as_deref() == Some("LiteLLM"))); + assert!(models.iter().any(|model| model.id == litellm_id)); + }); +} + #[test] fn provider_registry_overrides_legacy_endpoint_for_duplicate_models() { App::test((), |mut app| async move { @@ -1071,3 +1215,44 @@ async fn model_refresh_distinguishes_empty_catalog_from_failure() { response.remove_async().await; } } + +#[test] +fn bedrock_verification_survives_settings_reload_but_not_connection_changes() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let model = bedrock_model("anthropic.claude-test"); + let reloaded: BedrockModelConfig = + serde_json::from_value(serde_json::to_value(&model).unwrap()).unwrap(); + assert!(!reloaded.access_verified); + let mut preferences = empty_preferences(); + app.read(|ctx| { + preferences.bedrock_verified_config = + Some(super::bedrock_discovery_config(AISettings::as_ref(ctx))); + preferences + .bedrock_verified_model_ids + .insert(model.model_id.clone()); + }); + app.update(|ctx| { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .bedrock_models + .set_value(vec![reloaded], ctx) + .unwrap(); + }) + }); + app.read(|ctx| assert!(preferences.bedrock_model_is_verified(&model.model_id, ctx))); + // Another process/startup cannot inherit an in-memory approval. + app.read(|ctx| { + assert!(!empty_preferences().bedrock_model_is_verified(&model.model_id, ctx)) + }); + app.update(|ctx| { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + settings + .bedrock_region + .set_value("different-region".to_owned(), ctx) + .unwrap(); + }) + }); + app.read(|ctx| assert!(!preferences.bedrock_model_is_verified(&model.model_id, ctx))); + }); +} diff --git a/app/src/ai/provider/client.rs b/app/src/ai/provider/client.rs index b77ce134..f179937e 100644 --- a/app/src/ai/provider/client.rs +++ b/app/src/ai/provider/client.rs @@ -29,7 +29,7 @@ pub struct BedrockClient { region: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct BedrockClientConfig { pub auth_method: BedrockAuthMethod, pub profile: String, @@ -101,7 +101,7 @@ impl BedrockClient { BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => { let mut loader = aws_config::defaults(BehaviorVersion::latest()); - if !config.profile.is_empty() && config.profile != "default" { + if !config.profile.is_empty() { loader = loader.profile_name(&config.profile); } diff --git a/app/src/ai/provider/discovery.rs b/app/src/ai/provider/discovery.rs index 401c04b2..3dd6263f 100644 --- a/app/src/ai/provider/discovery.rs +++ b/app/src/ai/provider/discovery.rs @@ -8,34 +8,46 @@ use aws_config::BehaviorVersion; use aws_sdk_bedrock::Client; use aws_sdk_bedrockruntime::config::Region; use futures::{stream, StreamExt}; -use galaxy_bedrock_model_catalog::model_metadata; +use galaxy_bedrock_model_catalog::models as catalog_models; +use serde_json::json; use super::client::{BedrockClientConfig, BedrockError}; +use crate::ai::remote_logging::{RemoteLogLevel, RemoteLogRecord}; use crate::settings::ai::BedrockModelConfig; +pub(crate) struct BedrockDiscovery { + pub models: Vec, + pub records: Vec, +} + const AVAILABILITY_CHECK_CONCURRENCY: usize = 8; -pub async fn discover_available_models( +pub(crate) async fn discover_available_models( config: BedrockClientConfig, -) -> Result, String> { +) -> Result { let aws_config = load_aws_config(&config) .await .map_err(|error| error.to_string())?; let client = Client::new(&aws_config); - let catalog = client - .list_foundation_models() - .send() - .await - .map_err(|error| format!("Could not list AWS Bedrock foundation models: {error}"))?; - - // AWS exposes agreement/authorization state only through an individual - // GetFoundationModelAvailability call. Keep using that control-plane API, - // but bound the independent checks so a large catalog does not serialize - // startup discovery one model at a time. - let checks = catalog.model_summaries().iter().cloned().map(|summary| { + // The bundled catalog defines candidates; AWS defines account access. + // Never add arbitrary entries from ListFoundationModels to this catalog. + let candidates = catalog_models().to_vec(); + let sweep_id = uuid::Uuid::new_v4().to_string(); + let region = aws_config + .region() + .map(|region| region.as_ref()) + .unwrap_or("UNKNOWN") + .to_owned(); + log::info!( + "[bedrock] Starting availability sweep {sweep_id}: {} catalog models, region={region}", + candidates.len() + ); + let checks = candidates.into_iter().map(|metadata| { let client = client.clone(); + let sweep_id = sweep_id.clone(); + let region = region.clone(); async move { - let model_id = summary.model_id(); + let model_id = metadata.model_id.as_str(); let availability = match client .get_foundation_model_availability() .model_id(model_id) @@ -44,67 +56,59 @@ pub async fn discover_available_models( { Ok(availability) => availability, Err(error) => { - log::debug!( - "[bedrock] Availability check failed for {model_id}; excluding model: {error}" - ); - return None; + let error = crate::ai::remote_logging::sanitize_error(format!("{error:?}")); + log::warn!("[bedrock] Availability sweep {sweep_id}: model={model_id}, region={region}, decision=excluded, error={error}"); + return (None, RemoteLogRecord { + level: RemoteLogLevel::Warn, + message: "Bedrock model availability check failed".to_owned(), + context: json!({"event": "bedrock_model_availability", "sweep_id": sweep_id, "model_id": model_id, "aws_region": region, "decision": "excluded", "error": error}), + }); } }; + let agreement = availability.agreement_availability().map(|value| value.status().as_str()); + let authorization = availability.authorization_status().as_str(); + let entitlement = availability.entitlement_availability().as_str(); + let region_status = availability.region_availability().as_str(); + let access_allowed = model_availability_is_usable(agreement, authorization, entitlement, region_status); + let runtime_supported = metadata.supports_agent_runtime() != Some(false); + let allowed = access_allowed && runtime_supported; + let decision = if allowed { "allowed" } else { "excluded" }; + log::info!("[bedrock] Availability sweep {sweep_id}: model={model_id}, agreement={}, authorization={authorization}, entitlement={entitlement}, region_status={region_status}, runtime_supported={runtime_supported}, decision={decision}", agreement.unwrap_or("MISSING")); + let record = RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Bedrock model availability checked".to_owned(), + context: json!({"event": "bedrock_model_availability", "sweep_id": sweep_id, "model_id": model_id, "aws_region": region, "agreement": agreement, "authorization": authorization, "entitlement": entitlement, "region_availability": region_status, "agreement_error": availability.agreement_availability().and_then(|value| value.error_message()).map(crate::ai::remote_logging::sanitize_error), "access_allowed": access_allowed, "runtime_supported": runtime_supported, "decision": decision}), + }; + if !allowed { return (None, record); } - if !model_availability_is_usable( - availability - .agreement_availability() - .map(|agreement| agreement.status().as_str()), - availability.authorization_status().as_str(), - availability.entitlement_availability().as_str(), - availability.region_availability().as_str(), - ) { - log::debug!( - "[bedrock] Excluding {model_id}: agreement={}, authorization={}, entitlement={}, region={}", - availability - .agreement_availability() - .map(|agreement| agreement.status().as_str()) - .unwrap_or("MISSING"), - availability.authorization_status().as_str(), - availability.entitlement_availability().as_str(), - availability.region_availability().as_str(), - ); - return None; - } - - let metadata = model_metadata(model_id); - let display_name = summary.model_name().map(str::to_owned).unwrap_or_else(|| { - metadata - .map(|metadata| metadata.model_name.clone()) - .unwrap_or_else(|| prettify_model_id(model_id)) - }); - let vision_supported = summary - .input_modalities() - .iter() - .any(|modality| modality.as_str() == "IMAGE") - || metadata.is_some_and(|metadata| metadata.supports_vision_input()); - - Some(BedrockModelConfig { + (Some(BedrockModelConfig { model_id: model_id.to_owned(), - display_name, - vision_supported, - }) + display_name: metadata.model_name.clone(), + vision_supported: metadata.supports_vision_input(), + enabled: true, + access_verified: true, + }), record) } }); - let mut models = stream::iter(checks) + let results = stream::iter(checks) .buffer_unordered(AVAILABILITY_CHECK_CONCURRENCY) - .filter_map(futures::future::ready) .collect::>() .await; + let (models, mut records): (Vec<_>, Vec<_>) = results.into_iter().unzip(); + let mut models = models.into_iter().flatten().collect::>(); models.sort_by(|left, right| left.display_name.cmp(&right.display_name)); - if models.is_empty() { - return Err( - "AWS returned no Bedrock models that are authorized and available in this region." - .to_string(), - ); - } - Ok(models) + log::info!( + "[bedrock] Availability sweep {sweep_id} complete: checked={}, allowed={}", + records.len(), + models.len() + ); + records.push(RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Bedrock availability sweep completed".to_owned(), + context: json!({"event": "bedrock_availability_sweep_completed", "sweep_id": sweep_id, "aws_region": region, "catalog_count": catalog_models().len(), "checked_count": records.len(), "allowed_count": models.len()}), + }); + Ok(BedrockDiscovery { models, records }) } fn model_availability_is_usable( @@ -119,6 +123,21 @@ fn model_availability_is_usable( && region_status == "AVAILABLE" } +pub(crate) fn preserve_model_toggles( + existing: &[BedrockModelConfig], + mut discovered: Vec, +) -> Vec { + for model in &mut discovered { + if let Some(previous) = existing + .iter() + .find(|previous| previous.model_id == model.model_id) + { + model.enabled = previous.enabled; + } + } + discovered +} + async fn load_aws_config( config: &BedrockClientConfig, ) -> Result { @@ -126,7 +145,7 @@ async fn load_aws_config( crate::settings::ai::BedrockAuthMethod::Profile | crate::settings::ai::BedrockAuthMethod::Sso => { let mut loader = aws_config::defaults(BehaviorVersion::latest()); - if !config.profile.is_empty() && config.profile != "default" { + if !config.profile.is_empty() { loader = loader.profile_name(&config.profile); } if !config.region.is_empty() { @@ -162,14 +181,6 @@ async fn load_aws_config( Ok(sdk_config) } -fn prettify_model_id(model_id: &str) -> String { - model_id - .rsplit('.') - .next() - .unwrap_or(model_id) - .replace(['-', ':'], " ") -} - #[cfg(test)] #[path = "discovery_tests.rs"] mod tests; diff --git a/app/src/ai/provider/discovery_tests.rs b/app/src/ai/provider/discovery_tests.rs index 4d73764f..560f5f45 100644 --- a/app/src/ai/provider/discovery_tests.rs +++ b/app/src/ai/provider/discovery_tests.rs @@ -1,5 +1,26 @@ use super::model_availability_is_usable; +#[test] +fn cached_access_is_not_authorization_and_refresh_preserves_disabled_models() { + let model = crate::settings::BedrockModelConfig { + model_id: "test.model".to_owned(), + display_name: "Test".to_owned(), + vision_supported: false, + enabled: false, + access_verified: true, + }; + let saved = serde_json::to_value(&model).unwrap(); + assert!(saved.get("access_verified").is_none()); + let restored: crate::settings::BedrockModelConfig = serde_json::from_value(saved).unwrap(); + assert!(!restored.access_verified); + assert!(!restored.enabled); + let mut discovered = model.clone(); + discovered.enabled = true; + let refreshed = super::preserve_model_toggles(&[model], vec![discovered]); + assert!(!refreshed[0].enabled); + assert!(refreshed[0].access_verified); +} + #[test] fn requires_every_availability_status() { assert!(model_availability_is_usable( diff --git a/app/src/ai/provider/external_config.rs b/app/src/ai/provider/external_config.rs index 3cff89b6..71bc8145 100644 --- a/app/src/ai/provider/external_config.rs +++ b/app/src/ai/provider/external_config.rs @@ -141,6 +141,8 @@ fn parse_claude_code_model_map( .map(|(source_id, arn)| { let display_name = derive_display_name(&source_id); BedrockModelConfig { + enabled: true, + access_verified: false, model_id: arn, display_name, vision_supported: true, @@ -276,8 +278,14 @@ pub fn list_aws_profiles() -> Vec { let mut profiles = BTreeSet::new(); if let Some(home) = dirs::home_dir() { - parse_aws_config_file(home.join(".aws").join("config"), &mut profiles); - parse_aws_credentials_file(home.join(".aws").join("credentials"), &mut profiles); + let config_path = std::env::var_os("AWS_CONFIG_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".aws").join("config")); + let credentials_path = std::env::var_os("AWS_SHARED_CREDENTIALS_FILE") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".aws").join("credentials")); + parse_aws_config_file(config_path, &mut profiles); + parse_aws_credentials_file(credentials_path, &mut profiles); } profiles.into_iter().collect() diff --git a/app/src/ai/remote_logging.rs b/app/src/ai/remote_logging.rs index acce14d3..3adbf129 100644 --- a/app/src/ai/remote_logging.rs +++ b/app/src/ai/remote_logging.rs @@ -16,7 +16,7 @@ use std::{ use chrono::Utc; use galaxy_core::channel::ChannelState; -use galaxyui::{Entity, ModelContext, SingletonEntity}; +use galaxyui::{Entity, ModelContext, SingletonEntity, View, ViewContext}; use serde::Serialize; use serde_json::{json, Value}; use settings::Setting; @@ -123,6 +123,30 @@ where ); } +/// Views must log without updating AISettings: its singleton cannot be read +/// while it is temporarily borrowed by a model update. +pub(crate) fn log_view_event(ctx: &mut ViewContext, record: RemoteLogRecord) { + let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { + return; + }; + let payload = RemoteLogPayload { + level: record.level.as_str(), + message: record.message, + service: REMOTE_LOG_SERVICE, + context: enrich_context(record.context), + }; + #[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))] + write_local_log(&payload); + let _ = ctx.spawn( + async move { send_remote_log(config, payload).await }, + |_, result, _| { + if let Err(error) = result { + log::warn!("[remote-logging] Failed to send remote AI log: {error}"); + } + }, + ); +} + #[cfg(all(feature = "remote_logs_local_file", not(target_family = "wasm")))] fn write_local_log(payload: &RemoteLogPayload) { let state = LOCAL_LOG_FILE.get_or_init(|| Mutex::new(None)); diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index ec64763c..c052b36a 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -819,6 +819,12 @@ impl BedrockAuthMethod { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)] #[schemars(description = "Configuration for a single AWS Bedrock model.")] pub struct BedrockModelConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + /// Live AWS verification is never restored from disk. + #[serde(skip)] + #[schemars(skip)] + pub access_verified: bool, #[schemars( description = "The Bedrock model ID (e.g. anthropic.claude-sonnet-4-20250514-v1:0)." )] @@ -2347,27 +2353,33 @@ impl AISettings { /// consulted only for the legacy single-endpoint configuration, which has no per-provider /// enablement field. pub fn is_openai_provider_enabled(&self) -> bool { - if !self.openai_providers.value().is_empty() { - return self + self.legacy_openai_provider_enabled() + || self .openai_providers .value() .iter() - .any(|provider| provider.enabled); - } + .any(|provider| provider.enabled) + } + pub(crate) fn legacy_openai_provider_enabled(&self) -> bool { *self.openai_enabled.value() + && !self.openai_providers.value().iter().any(|provider| { + matches!( + provider.kind, + OpenAIProviderKind::OpenAI | OpenAIProviderKind::LiteLLM + ) && provider.base_url.trim_end_matches('/') + == self.openai_base_url.value().trim_end_matches('/') + }) } /// Returns whether an OpenAI-compatible provider is enabled and has at least one enabled model. pub fn has_enabled_openai_provider(&self) -> bool { - if !self.openai_providers.value().is_empty() { - return self.openai_providers.value().iter().any(|provider| { - provider.enabled && provider.models.iter().any(|model| model.enabled) - }); - } - - self.is_openai_provider_enabled() - && self.openai_models.value().iter().any(|model| model.enabled) + self.openai_providers + .value() + .iter() + .any(|provider| provider.enabled && provider.models.iter().any(|model| model.enabled)) + || (self.legacy_openai_provider_enabled() + && self.openai_models.value().iter().any(|model| model.enabled)) } pub(crate) fn configured_acp_providers(&self) -> Vec { @@ -2434,7 +2446,12 @@ impl AISettings { /// Returns whether Galaxy has a local model provider or agent runtime enabled. pub fn has_enabled_ai_runtime(&self) -> bool { - (*self.bedrock_enabled.value() && !self.bedrock_models.value().is_empty()) + (*self.bedrock_enabled.value() + && self + .bedrock_models + .value() + .iter() + .any(|model| model.enabled)) || self.has_enabled_openai_provider() || (cfg!(unix) && FeatureFlag::AgentClientProtocol.is_enabled() diff --git a/app/src/settings/ai_tests.rs b/app/src/settings/ai_tests.rs index 367bcfc2..f98f61fc 100644 --- a/app/src/settings/ai_tests.rs +++ b/app/src/settings/ai_tests.rs @@ -441,6 +441,8 @@ fn orchestration_is_enabled_when_ai_is_enabled() { .bedrock_models .set_value( vec![BedrockModelConfig { + enabled: true, + access_verified: true, model_id: "test-model".to_string(), display_name: "Test model".to_string(), vision_supported: false, diff --git a/app/src/settings_view/ai_page.rs b/app/src/settings_view/ai_page.rs index f5d40a34..51bd016d 100644 --- a/app/src/settings_view/ai_page.rs +++ b/app/src/settings_view/ai_page.rs @@ -21,7 +21,7 @@ use galaxyui::elements::{ Dismiss, Empty, Expanded, Fill, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkLens, HyperlinkUrl, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, Padding, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, - Shrinkable, Stack, Text, + Shrinkable, Stack, TableStateHandle, Text, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::keymap::{ContextPredicate, FixedBinding, Keystroke}; @@ -42,8 +42,8 @@ use strum::IntoEnumIterator; use super::execution_profile_view::{ExecutionProfileView, ExecutionProfileViewEvent}; use super::provider_setup_view::{ - AcpProviderDraft, BedrockProviderDraft, ProviderSetupProviderType, ProviderSetupView, - ProviderSetupViewEvent, + model_catalog_table, AcpProviderDraft, BedrockProviderDraft, ProviderSetupProviderType, + ProviderSetupView, ProviderSetupViewEvent, }; use super::settings_page::{ build_sub_header, build_toggle_element, render_body_item_label, @@ -2570,12 +2570,17 @@ pub enum AISettingsPageAction { RemoveFromMCPDenylist(uuid::Uuid), CreateProfile, ToggleBedrockEnabled, + ToggleBedrockModel(String), ToggleOpenAIEnabled, ToggleAcpEnabled, FetchOpenAIProviderModels(usize), AddOpenAIProvider(ProviderSetupProviderType), EditOpenAIProvider(usize), ToggleOpenAIProviderEnabled(usize), + ToggleOpenAIProviderModel { + provider_index: usize, + model_id: String, + }, RemoveOpenAIProvider(usize), EditBedrockProvider, RemoveBedrockProvider, @@ -3223,11 +3228,18 @@ impl TypedActionView for AISettingsPageView { }); ctx.notify(); } + AISettingsPageAction::ToggleBedrockModel(model_id) => { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let mut models = settings.bedrock_models.value().clone(); + if let Some(model) = models.iter_mut().find(|model| model.model_id == *model_id) + { + model.enabled = !model.enabled; + report_if_error!(settings.bedrock_models.set_value(models, ctx)); + } + }); + } AISettingsPageAction::ToggleBedrockEnabled => { AISettings::handle(ctx).update(ctx, |settings, ctx| { - if !*settings.bedrock_enabled.value() { - report_if_error!(settings.bedrock_models.set_value(Vec::new(), ctx)); - } report_if_error!(settings.bedrock_enabled.toggle_and_save_value(ctx)); }); ctx.notify(); @@ -3264,6 +3276,23 @@ impl TypedActionView for AISettingsPageView { report_if_error!(settings.openai_providers.set_value(providers, ctx)); }); } + AISettingsPageAction::ToggleOpenAIProviderModel { + provider_index, + model_id, + } => { + AISettings::handle(ctx).update(ctx, |settings, ctx| { + let mut providers = settings.openai_providers.value().clone(); + if let Some(model) = providers.get_mut(*provider_index).and_then(|provider| { + provider + .models + .iter_mut() + .find(|model| model.model_id == *model_id) + }) { + model.enabled = !model.enabled; + report_if_error!(settings.openai_providers.set_value(providers, ctx)); + } + }); + } AISettingsPageAction::RemoveOpenAIProvider(provider_index) => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let mut providers = settings.openai_providers.value().clone(); @@ -6428,6 +6457,8 @@ impl ModelProviderSection { struct OpenAIProviderCardState { enabled_switch: SwitchStateHandle, + model_switches: RefCell>, + model_table_state: TableStateHandle, edit_button: ViewHandle, remove_button: ViewHandle, } @@ -6439,6 +6470,8 @@ struct AcpProviderCardState { struct ProviderSettingsWidget { provider_type: ProviderSetupProviderType, bedrock_enabled_toggle: SwitchStateHandle, + bedrock_model_switches: RefCell>, + bedrock_model_table_state: TableStateHandle, add_openai_provider_button: ViewHandle, add_litellm_provider_button: ViewHandle, add_chatgpt_provider_button: ViewHandle, @@ -6461,6 +6494,8 @@ impl ProviderSettingsWidget { .enumerate() .map(|(index, _)| OpenAIProviderCardState { enabled_switch: SwitchStateHandle::default(), + model_switches: RefCell::new(HashMap::new()), + model_table_state: TableStateHandle::new(0, |_, _| Vec::new()), edit_button: ctx.add_typed_action_view(move |_| { ActionButton::new("Edit", SecondaryTheme).on_click(move |ctx| { ctx.dispatch_typed_action(AISettingsPageAction::EditOpenAIProvider(index)); @@ -6550,6 +6585,8 @@ impl ProviderSettingsWidget { Self { provider_type, bedrock_enabled_toggle: SwitchStateHandle::default(), + bedrock_model_switches: RefCell::new(HashMap::new()), + bedrock_model_table_state: TableStateHandle::new(0, |_, _| Vec::new()), add_openai_provider_button, add_litellm_provider_button, add_chatgpt_provider_button, @@ -6593,17 +6630,7 @@ impl ProviderSettingsWidget { model: &OpenAIModelConfig, provider: &OpenAIProviderConfig, ) -> String { - let context_size = if provider.kind == OpenAIProviderKind::ChatGPTSubscription { - // Use the same limit as requests, including for older saved catalogs - // that still contain the optional long-context maximum. - openai_model_context_size(model, provider.kind) - } else { - model.context_size - }; - let mut details = vec![ - if model.enabled { "Enabled" } else { "Disabled" }.to_string(), - format!("Context: {context_size}"), - ]; + let mut details = Vec::new(); if let Some(default_context_size) = model .default_context_size @@ -6686,60 +6713,193 @@ impl ProviderSettingsWidget { .finish(); } - Container::new(Flex::column().with_children(rows).finish()) - .with_background(appearance.theme().surface_2()) - .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) - .finish() + Container::new( + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_children(rows) + .finish(), + ) + .with_background(appearance.theme().surface_2()) + .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .finish() } fn render_openai_model_catalog( + &self, + provider_index: usize, provider: &OpenAIProviderConfig, appearance: &Appearance, ) -> Box { - let rows = provider + if provider.models.is_empty() { + return Self::render_model_catalog( + Vec::new(), + "No models configured for this connection.", + appearance, + ); + } + let card = &self.provider_cards[provider_index]; + let switches = provider .models .iter() .map(|model| { - Self::render_model_row( - model.display_name.clone(), - model.model_id.clone(), - Self::format_openai_model_details(model, provider), - appearance, - ) + card.model_switches + .borrow_mut() + .entry(model.model_id.clone()) + .or_default() + .clone() }) .collect::>(); - Self::render_model_catalog( - rows, - "No models configured for this connection.", + let provider = provider.clone(); + model_catalog_table( + card.model_table_state.clone(), + provider.models.len(), + move |index, app| { + let appearance = Appearance::as_ref(app); + let model = &provider.models[index]; + let model_id = model.model_id.clone(); + let toggle = appearance + .ui_builder() + .switch(switches[index].clone()) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AISettingsPageAction::ToggleOpenAIProviderModel { + provider_index, + model_id: model_id.clone(), + }) + }) + .finish(); + let text = |value: String| { + Text::new(value, appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish() + }; + let model_info = Flex::column() + .with_spacing(3.) + .with_child( + Text::new( + model.display_name.clone(), + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(), + ) + .with_child( + Text::new( + model.model_id.clone(), + appearance.monospace_font_family(), + 10., + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(), + ) + .finish(); + vec![ + Flex::row().with_child(toggle).finish(), + model_info, + text(openai_model_context_size(model, provider.kind).to_string()), + text(Self::format_openai_model_details(model, &provider)), + ] + }, appearance, ) } fn render_bedrock_model_catalog( + &self, models: &[BedrockModelConfig], appearance: &Appearance, ) -> Box { - let rows = models + if models.is_empty() { + return Self::render_model_catalog( + Vec::new(), + "No Bedrock models passed AWS access checks.", + appearance, + ); + } + let switches = models .iter() .map(|model| { - let mut details = Vec::new(); - if model.vision_supported { - details.push("Images".to_string()); - } - Self::render_model_row( - model.display_name.clone(), - model.model_id.clone(), - if details.is_empty() { - "Available".to_string() - } else { - details.join(" · ") - }, - appearance, - ) + self.bedrock_model_switches + .borrow_mut() + .entry(model.model_id.clone()) + .or_default() + .clone() }) .collect::>(); - Self::render_model_catalog(rows, "No Bedrock models discovered yet.", appearance) + let models = models.to_vec(); + model_catalog_table( + self.bedrock_model_table_state.clone(), + models.len(), + move |index, app| { + let appearance = Appearance::as_ref(app); + let model = &models[index]; + let id = model.model_id.clone(); + let toggle = appearance + .ui_builder() + .switch(switches[index].clone()) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AISettingsPageAction::ToggleBedrockModel( + id.clone(), + )) + }) + .finish(); + let text = |value: String| { + Text::new(value, appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .soft_wrap(true) + .finish() + }; + let name = Flex::column() + .with_spacing(3.) + .with_child( + Text::new( + model.display_name.clone(), + appearance.ui_font_family(), + CONTENT_FONT_SIZE, + ) + .with_color(appearance.theme().active_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(), + ) + .with_child( + Text::new( + model.model_id.clone(), + appearance.monospace_font_family(), + 10., + ) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish(), + ) + .finish(); + let context = galaxy_bedrock_model_catalog::model_metadata(&model.model_id) + .and_then(|metadata| metadata.context_window_tokens) + .map(|tokens| tokens.to_string()) + .unwrap_or_else(|| "Unknown".to_owned()); + let details = if !LLMPreferences::as_ref(app) + .bedrock_model_is_verified(&model.model_id, app) + { + "Awaiting AWS access verification" + } else if model.vision_supported { + "Images" + } else { + "Text" + }; + vec![ + Flex::row().with_child(toggle).finish(), + name, + text(context), + text(details.to_owned()), + ] + }, + appearance, + ) } fn render_acp_model_catalog( @@ -6876,7 +7036,8 @@ impl ProviderSettingsWidget { .with_child(header) .with_child(summary) .with_child(enablement) - .with_child(Self::render_openai_model_catalog(provider, appearance)) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(self.render_openai_model_catalog(provider_index, provider, appearance)) .finish(), ) .with_padding(Padding::uniform(16.)) @@ -6956,7 +7117,7 @@ impl ProviderSettingsWidget { ) .finish(), ) - .with_child(Self::render_bedrock_model_catalog( + .with_child(self.render_bedrock_model_catalog( AISettings::as_ref(app).bedrock_models.value(), appearance, )) @@ -7146,6 +7307,7 @@ impl ProviderSettingsWidget { } let mut section = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(10.) .with_child(header.finish()) .with_child( @@ -7196,7 +7358,9 @@ impl SettingsWidget for ProviderSettingsWidget { let settings = AISettings::as_ref(app); let providers = settings.openai_providers.value(); - let mut column = Flex::column().with_spacing(18.); + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(18.); let is_setup_visible = view .inline_provider_setup diff --git a/app/src/settings_view/provider_setup_view.rs b/app/src/settings_view/provider_setup_view.rs index 8a4ac13f..21b5569b 100644 --- a/app/src/settings_view/provider_setup_view.rs +++ b/app/src/settings_view/provider_setup_view.rs @@ -3,7 +3,8 @@ use galaxy_core::ui::theme::Fill; use galaxyui::elements::{ Border, ChildView, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, - MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, ScrollbarWidth, Text, + MainAxisSize, MouseStateHandle, Padding, ParentElement, Radius, RowBackground, ScrollbarWidth, + Table, TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::platform::Cursor; @@ -16,6 +17,50 @@ use galaxyui::{ }; use pathfinder_color::ColorU; +pub(super) fn model_catalog_table( + state: TableStateHandle, + row_count: usize, + render_row: impl Fn(usize, &AppContext) -> Vec> + 'static, + appearance: &Appearance, +) -> Box { + let headers = ["Use", "Model", "Context", "Capabilities"] + .into_iter() + .enumerate() + .map(|(index, label)| { + TableHeader::new( + Text::new(label.to_owned(), appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .with_style(Properties::default().weight(Weight::Semibold)) + .finish(), + ) + .with_width(if index == 3 { + TableColumnWidth::Flex(1.) + } else { + TableColumnWidth::Intrinsic + }) + }) + .collect(); + state.invalidate_all_intrinsic_widths(); + Table::new(state, 0., 0.) + .with_headers(headers) + .with_row_count(row_count) + .with_row_render_fn(render_row) + .with_config(TableConfig { + border_width: 1., + border_color: appearance.theme().outline().into(), + outer_border: true, + column_dividers: false, + row_dividers: true, + cell_padding: 12., + header_background: appearance.theme().surface_2().into(), + row_background: RowBackground::uniform(appearance.theme().surface_1().into()), + fixed_header: false, + vertical_sizing: TableVerticalSizing::ExpandToContent, + measure_body_cells_for_intrinsic_widths: true, + }) + .finish() +} + #[cfg(not(target_family = "wasm"))] use crate::ai::chatgpt_auth::{ChatGPTAuthModel, ChatGPTAuthModelEvent, ChatGPTAuthState}; use crate::ai::harness_display; @@ -32,8 +77,8 @@ use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, }; +use crate::view_components::{Dropdown, DropdownItem}; -const SETUP_WIDTH: f32 = 900.; const INPUT_FONT_SIZE: f32 = 12.; const MODEL_LOGO_SIZE: f32 = 20.; @@ -171,6 +216,7 @@ pub enum ProviderSetupViewAction { CycleModelCapability(usize, CapabilityKey), ConnectChatGPT, SelectBedrockAuth(BedrockAuthMethod), + SelectBedrockProfile(String), ToggleBedrockCrossRegion, ToggleBedrockAutoLogin, SelectAcpAgent(String), @@ -197,7 +243,8 @@ pub struct ProviderSetupView { api_key_editor: ViewHandle, project_id_editor: ViewHandle, location_editor: ViewHandle, - bedrock_profile_editor: ViewHandle, + bedrock_profile_dropdown: ViewHandle>, + bedrock_region_request: usize, bedrock_region_editor: ViewHandle, bedrock_refresh_command_editor: ViewHandle, bedrock_access_key_editor: ViewHandle, @@ -210,11 +257,13 @@ pub struct ProviderSetupView { bedrock_cross_region_toggle: SwitchStateHandle, bedrock_auto_login_toggle: SwitchStateHandle, model_switches: Vec, + bedrock_model_switches: Vec, model_capability_switches: Vec<[SwitchStateHandle; 2]>, model_capability_buttons: Vec>>, model_context_editors: Vec>, provider_type_scroll_state: ClippedScrollStateHandle, models_scroll_state: ClippedScrollStateHandle, + model_table_state: TableStateHandle, step_tab_mouse_states: Vec, back_button: ViewHandle, cancel_button: ViewHandle, @@ -245,8 +294,9 @@ impl ProviderSetupView { let api_key_editor = Self::create_editor("sk-... (optional)", true, ctx); let project_id_editor = Self::create_editor("my-google-cloud-project", false, ctx); let location_editor = Self::create_editor("global", false, ctx); - let bedrock_profile_editor = Self::create_editor("default", false, ctx); - let bedrock_region_editor = Self::create_editor("us-east-1", false, ctx); + let bedrock_profile_dropdown = ctx.add_typed_action_view(Dropdown::new); + let bedrock_region_editor = + Self::create_editor("Region from AWS profile (or enter an override)", false, ctx); let bedrock_refresh_command_editor = Self::create_editor("aws sso login", false, ctx); let bedrock_access_key_editor = Self::create_editor("AKIA...", false, ctx); let bedrock_secret_key_editor = Self::create_editor("Secret access key", true, ctx); @@ -332,7 +382,6 @@ impl ProviderSetupView { } }); for (editor, update) in [ - (bedrock_profile_editor.clone(), 0), (bedrock_region_editor.clone(), 1), (bedrock_refresh_command_editor.clone(), 2), (bedrock_access_key_editor.clone(), 3), @@ -345,7 +394,6 @@ impl ProviderSetupView { if matches!(event, EditorEvent::Edited(_)) { let value = editor.as_ref(ctx).buffer_text(ctx); match update { - 0 => me.draft_bedrock.profile = value, 1 => me.draft_bedrock.region = value, 2 => me.draft_bedrock.auth_refresh_command = value, 3 => me.draft_bedrock.access_key_id = value, @@ -430,7 +478,8 @@ impl ProviderSetupView { api_key_editor, project_id_editor, location_editor, - bedrock_profile_editor, + bedrock_profile_dropdown, + bedrock_region_request: 0, bedrock_region_editor, bedrock_refresh_command_editor, bedrock_access_key_editor, @@ -443,11 +492,13 @@ impl ProviderSetupView { bedrock_cross_region_toggle: SwitchStateHandle::default(), bedrock_auto_login_toggle: SwitchStateHandle::default(), model_switches: Vec::new(), + bedrock_model_switches: Vec::new(), model_capability_switches: Vec::new(), model_capability_buttons: Vec::new(), model_context_editors: Vec::new(), provider_type_scroll_state: ClippedScrollStateHandle::default(), models_scroll_state: ClippedScrollStateHandle::default(), + model_table_state: TableStateHandle::new(0, |_, _| Vec::new()), step_tab_mouse_states: (0..4).map(|_| MouseStateHandle::default()).collect(), back_button, cancel_button, @@ -544,6 +595,9 @@ impl ProviderSetupView { }; self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); + if self.provider_type == ProviderSetupProviderType::Bedrock { + self.load_bedrock_profile_region(ctx); + } self.sync_provider_type_buttons(ctx); self.sync_acp_agent_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); @@ -601,6 +655,9 @@ impl ProviderSetupView { self.draft_models.clear(); self.discovery_state = DiscoveryState::Idle; self.sync_editors(ctx); + if self.draft_bedrock.region.is_empty() { + self.load_bedrock_profile_region(ctx); + } self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.sync_model_switches(ctx); @@ -652,6 +709,43 @@ impl ProviderSetupView { ctx.notify(); } + fn load_bedrock_profile_region(&mut self, ctx: &mut ViewContext) { + self.bedrock_region_request += 1; + #[cfg(not(target_family = "wasm"))] + { + use aws_config::meta::region::ProvideRegion; + let request = self.bedrock_region_request; + let profile = self.draft_bedrock.profile.clone(); + let requested_profile = profile.clone(); + let previous_region = self.draft_bedrock.region.clone(); + ctx.spawn( + async move { + let provider = aws_config::profile::ProfileFileRegionProvider::builder() + .profile_name(profile) + .build(); + ProvideRegion::region(&provider) + .await + .map(|region| region.as_ref().to_owned()) + }, + move |me, region, ctx| { + if request != me.bedrock_region_request + || me.provider_type != ProviderSetupProviderType::Bedrock + || me.draft_bedrock.profile != requested_profile + || me.draft_bedrock.region != previous_region + { + return; + } + me.draft_bedrock.region = region.unwrap_or_default(); + me.bedrock_region_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text(&me.draft_bedrock.region, ctx); + }); + me.update_next_button(ctx); + ctx.notify(); + }, + ); + } + } + fn sync_editors(&self, ctx: &mut ViewContext) { self.name_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_name, ctx); @@ -668,8 +762,30 @@ impl ProviderSetupView { self.location_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_location, ctx); }); - self.bedrock_profile_editor.update(ctx, |editor, ctx| { - editor.system_reset_buffer_text(&self.draft_bedrock.profile, ctx); + let mut profiles = vec!["default".to_owned()]; + #[cfg(not(target_family = "wasm"))] + profiles.extend(crate::ai::provider::external_config::list_aws_profiles()); + profiles.push(self.draft_bedrock.profile.clone()); + profiles.retain(|profile| !profile.is_empty()); + profiles.sort(); + profiles.dedup(); + self.bedrock_profile_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_items( + profiles + .into_iter() + .map(|profile| { + DropdownItem::new( + profile.clone(), + ProviderSetupViewAction::SelectBedrockProfile(profile), + ) + }) + .collect(), + ctx, + ); + dropdown.set_selected_by_action( + ProviderSetupViewAction::SelectBedrockProfile(self.draft_bedrock.profile.clone()), + ctx, + ); }); self.bedrock_region_editor.update(ctx, |editor, ctx| { editor.system_reset_buffer_text(&self.draft_bedrock.region, ctx); @@ -740,6 +856,8 @@ impl ProviderSetupView { } fn sync_model_switches(&mut self, ctx: &mut ViewContext) { + self.bedrock_model_switches + .resize_with(self.draft_bedrock.models.len(), SwitchStateHandle::default); while self.model_switches.len() < self.draft_models.len() { self.model_switches.push(SwitchStateHandle::default()); } @@ -959,13 +1077,30 @@ impl ProviderSetupView { session_token: None, cross_region_inference: self.draft_bedrock.cross_region_inference, }; + let requested_config = config.clone(); ctx.spawn( async move { crate::ai::provider::discovery::discover_available_models(config).await }, move |me, result, ctx| match result { - Ok(models) => { - me.draft_bedrock.models = models; + Ok(discovery) => { + for record in discovery.records { + crate::ai::remote_logging::log_view_event(ctx, record); + } + let models = discovery.models; + LLMPreferences::handle(ctx).update(ctx, |preferences, ctx| { + preferences.record_bedrock_verification( + requested_config, + &models, + ctx, + ); + }); + me.draft_bedrock.models = + crate::ai::provider::discovery::preserve_model_toggles( + &me.draft_bedrock.models, + models, + ); + me.sync_model_switches(ctx); me.discovery_state = DiscoveryState::Idle; me.step = ProviderSetupStep::Models; me.update_next_button(ctx); @@ -1152,6 +1287,7 @@ impl ProviderSetupView { .collect::>(); let list = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(10.) .with_children(cards) .finish(); @@ -1359,11 +1495,8 @@ impl ProviderSetupView { ); match self.draft_bedrock.auth_method { BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => { - children.push(self.render_input( - appearance, - "AWS profile", - &self.bedrock_profile_editor, - )); + children.push(Self::render_label(appearance, "AWS profile")); + children.push(ChildView::new(&self.bedrock_profile_dropdown).finish()); if self.draft_bedrock.auth_method == BedrockAuthMethod::Sso { children.push(self.render_input( appearance, @@ -1518,6 +1651,7 @@ impl ProviderSetupView { ) -> Box { let list = Flex::column() .with_spacing(spacing) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_children(rows) .finish(); let scrollable = ClippedScrollable::vertical( @@ -1539,7 +1673,6 @@ impl ProviderSetupView { .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) .finish(), ) - .with_width(SETUP_WIDTH - 56.) .with_max_height(430.) .finish() } @@ -1588,47 +1721,9 @@ impl ProviderSetupView { .finish() } - fn render_model_table_header(&self, appearance: &Appearance) -> Box { - let header = |label: &str| { - Text::new(label.to_owned(), appearance.ui_font_family(), 11.) - .with_color(appearance.theme().nonactive_ui_text_color().into()) - .with_style(Properties::default().weight(Weight::Semibold)) - .finish() - }; - Container::new( - Flex::row() - .with_spacing(12.) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(ConstrainedBox::new(header("Use")).with_width(52.).finish()) - .with_child( - ConstrainedBox::new(header("Model")) - .with_width(250.) - .finish(), - ) - .with_child( - ConstrainedBox::new(header("Context")) - .with_width(140.) - .finish(), - ) - .with_child( - ConstrainedBox::new(header("Capabilities")) - .with_width(330.) - .finish(), - ) - .finish(), - ) - .with_padding(Padding::uniform(10.).with_vertical(9.)) - .with_background(appearance.theme().surface_2()) - .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) - .finish() - } - fn render_model_capabilities( - &self, - appearance: &Appearance, - index: usize, + buttons: &[ViewHandle], ) -> Box { - let buttons = &self.model_capability_buttons[index]; let first_row = buttons[..3] .iter() .map(|button| ChildView::new(button).finish()) @@ -1654,12 +1749,14 @@ impl ProviderSetupView { .finish() } - fn render_openai_model_row( - &self, + fn render_openai_model_cells( appearance: &Appearance, index: usize, model: &OpenAIModelConfig, - ) -> Box { + switch: SwitchStateHandle, + context_editor: ViewHandle, + buttons: &[ViewHandle], + ) -> Vec> { let model_info = Flex::column() .with_spacing(4.) .with_child( @@ -1679,16 +1776,9 @@ impl ProviderSetupView { .finish(), ) .finish(); - let model_info = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(10.) - .with_child(self.render_model_logo()) - .with_child(model_info) - .finish(); - let context_input = appearance .ui_builder() - .text_input(self.model_context_editors[index].clone()) + .text_input(context_editor) .with_style(UiComponentStyles { padding: Some(Coords { top: 8., @@ -1702,94 +1792,77 @@ impl ProviderSetupView { .build() .finish(); - Container::new( - Flex::row() - .with_spacing(12.) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - ConstrainedBox::new( - appearance - .ui_builder() - .switch(self.model_switches[index].clone()) - .check(model.enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel( - index, - )); - }) - .finish(), - ) - .with_width(52.) - .finish(), - ) - .with_child(ConstrainedBox::new(model_info).with_width(250.).finish()) - .with_child(ConstrainedBox::new(context_input).with_width(140.).finish()) - .with_child( - ConstrainedBox::new(self.render_model_capabilities(appearance, index)) - .with_width(330.) - .finish(), - ) - .finish(), - ) - .with_padding(Padding::uniform(12.).with_vertical(10.)) - .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) - .finish() + let toggle = appearance + .ui_builder() + .switch(switch) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(index)) + }) + .finish(); + vec![ + Flex::row().with_child(toggle).finish(), + model_info, + ConstrainedBox::new(context_input).with_width(100.).finish(), + Self::render_model_capabilities(buttons), + ] } fn render_models(&self, appearance: &Appearance) -> Box { if self.provider_type == ProviderSetupProviderType::Bedrock { - let rows = self - .draft_bedrock - .models - .iter() - .map(|model| { - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(10.) - .with_child(self.render_model_logo()) - .with_child( - Flex::column() - .with_spacing(2.) - .with_child( - Text::new( - model.display_name.clone(), - appearance.ui_font_family(), - INPUT_FONT_SIZE, - ) - .with_color(appearance.theme().active_ui_text_color().into()) - .finish(), - ) - .with_child( - Text::new( - model.model_id.clone(), - appearance.monospace_font_family(), - 10., - ) - .with_color(appearance.theme().nonactive_ui_text_color().into()) - .soft_wrap(true) - .finish(), - ) - .finish(), - ) - .finish() - }) - .collect::>(); - return Flex::column() - .with_spacing(12.) + let models = self.draft_bedrock.models.clone(); + let switches = self.bedrock_model_switches.clone(); + let table = model_catalog_table( + self.model_table_state.clone(), + models.len(), + move |index, app| { + let appearance = Appearance::as_ref(app); + let model = &models[index]; + let text = |value: String| { + Text::new(value, appearance.ui_font_family(), 11.) + .with_color(appearance.theme().nonactive_ui_text_color().into()) + .finish() + }; + let toggle = appearance + .ui_builder() + .switch(switches[index].clone()) + .check(model.enabled) + .build() + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(ProviderSetupViewAction::ToggleModel(index)) + }) + .finish(); + let name = Flex::column() + .with_spacing(3.) + .with_child(text(model.display_name.clone())) + .with_child(text(model.model_id.clone())) + .finish(); + let context = galaxy_bedrock_model_catalog::model_metadata(&model.model_id) + .and_then(|metadata| metadata.context_window_tokens) + .map(|tokens| tokens.to_string()) + .unwrap_or_else(|| "Unknown".to_owned()); + vec![ + Flex::row().with_child(toggle).finish(), + name, + text(context), + text( + if model.vision_supported { + "Images" + } else { + "Text" + } + .to_owned(), + ), + ] + }, + appearance, + ); + return Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch).with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) - .with_child( - Text::new( - "These models passed AWS Bedrock availability checks. Model selection is managed by discovery and cannot be edited manually.", - appearance.ui_font_family(), - INPUT_FONT_SIZE, - ) - .with_color(appearance.theme().nonactive_ui_text_color().into()) - .soft_wrap(true) - .finish(), - ) - .with_child(self.render_model_table(appearance, rows, 10.)) - .finish(); + .with_child(Text::new("Only models passing AWS agreement, authorization, entitlement, and region checks are offered. Choose which models Galaxy should enable.", appearance.ui_font_family(), INPUT_FONT_SIZE) + .with_color(appearance.theme().nonactive_ui_text_color().into()).soft_wrap(true).finish()) + .with_child(self.render_model_table(appearance, vec![table], 0.)).finish(); } if self.provider_type == ProviderSetupProviderType::Acp { let option_rows = self @@ -1873,17 +1946,29 @@ impl ProviderSetupView { .with_child(catalog) .finish(); } - let mut rows = vec![self.render_model_table_header(appearance)]; - rows.extend( - self.draft_models - .iter() - .enumerate() - .map(|(index, model)| self.render_openai_model_row(appearance, index, model)), + let models = self.draft_models.clone(); + let switches = self.model_switches.clone(); + let editors = self.model_context_editors.clone(); + let buttons = self.model_capability_buttons.clone(); + let table = model_catalog_table( + self.model_table_state.clone(), + models.len(), + move |index, app| { + Self::render_openai_model_cells( + Appearance::as_ref(app), + index, + &models[index], + switches[index].clone(), + editors[index].clone(), + &buttons[index], + ) + }, + appearance, ); - - let table = self.render_model_table(appearance, rows, 0.); + let table = self.render_model_table(appearance, vec![table], 0.); Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(12.) .with_child(self.render_input(appearance, "Connection name", &self.name_editor)) .with_child( @@ -2101,6 +2186,7 @@ impl View for ProviderSetupView { ProviderSetupStep::Models => self.render_models(appearance), }; Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_spacing(20.) .with_child(self.render_step_indicator(appearance)) .with_child(content) @@ -2126,6 +2212,11 @@ impl TypedActionView for ProviderSetupView { self.draft_api_key = None; } self.sync_editors(ctx); + if self.provider_type == ProviderSetupProviderType::Bedrock + && self.draft_bedrock.region.is_empty() + { + self.load_bedrock_profile_region(ctx); + } self.sync_provider_type_buttons(ctx); self.sync_bedrock_auth_buttons(ctx); self.update_next_button(ctx); @@ -2217,7 +2308,17 @@ impl TypedActionView for ProviderSetupView { ctx.notify(); } ProviderSetupViewAction::ToggleModel(index) => { - if let Some(model) = self.draft_models.get_mut(*index) { + let models = if self.provider_type == ProviderSetupProviderType::Bedrock { + &mut self.draft_bedrock.models + } else { + if let Some(model) = self.draft_models.get_mut(*index) { + model.enabled = !model.enabled; + self.update_next_button(ctx); + ctx.notify(); + } + return; + }; + if let Some(model) = models.get_mut(*index) { model.enabled = !model.enabled; self.update_next_button(ctx); ctx.notify(); @@ -2237,6 +2338,34 @@ impl TypedActionView for ProviderSetupView { #[cfg(not(target_family = "wasm"))] ChatGPTAuthModel::handle(ctx).update(ctx, |model, ctx| model.connect(ctx)); } + ProviderSetupViewAction::SelectBedrockProfile(profile) => { + if self.draft_bedrock.profile != *profile { + self.draft_bedrock.profile = profile.clone(); + self.draft_bedrock.region.clear(); + self.draft_bedrock.models.clear(); + self.discovery_state = DiscoveryState::Idle; + self.bedrock_region_editor.update(ctx, |editor, ctx| { + editor.system_reset_buffer_text("", ctx); + }); + // The dropdown dispatches this action while it is borrowed. + // Update its selected label only after that handler returns. + let selected_profile = profile.clone(); + ctx.spawn(async {}, move |me, _, ctx| { + if me.draft_bedrock.profile != selected_profile { + return; + } + me.bedrock_profile_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_action( + ProviderSetupViewAction::SelectBedrockProfile(selected_profile), + ctx, + ); + }); + }); + self.load_bedrock_profile_region(ctx); + self.update_next_button(ctx); + ctx.notify(); + } + } ProviderSetupViewAction::SelectBedrockAuth(method) => { self.draft_bedrock.auth_method = *method; self.sync_bedrock_auth_buttons(ctx); diff --git a/crates/galaxy_logging/src/native.rs b/crates/galaxy_logging/src/native.rs index 84e6d6a4..85984903 100644 --- a/crates/galaxy_logging/src/native.rs +++ b/crates/galaxy_logging/src/native.rs @@ -21,6 +21,7 @@ const MAX_FILES_IN_CLI_ROTATION: usize = 10; const CLI_LOG_SUBDIRECTORY: &str = "oz"; const SESSION_LOG_SUBDIRECTORY: &str = "session-logs"; const TEMP_LOG_FILE_SUFFIX: &str = "old.temp"; +const BEDROCK_DISCOVERY_LOG_TARGET: &str = "galaxy::ai::provider::discovery"; const INPUT_CLASSIFIER_LOG_TARGET: &str = "input_classifier"; const FILE_WATCHER_LOG_TARGET: &str = "notify"; const FILE_WATCHER_DEBOUNCER_LOG_TARGET: &str = "notify_debouncer_full"; @@ -589,6 +590,9 @@ fn init_internal( base_logger .filter(Some("naga"), LevelFilter::Warn) .filter(Some("wgpu_core"), LevelFilter::Warn) + // Bedrock access checks contain model IDs and approval statuses only. + // Retain the complete audit even when full-session logging is disabled. + .filter(Some(BEDROCK_DISCOVERY_LOG_TARGET), LevelFilter::Info) // ANSI rendering emits trace records for every character and terminal control sequence. // Preserve meaningful info/warnings/errors without filling full-session logs with typing, // carriage-return, linefeed, cursor-motion, and scrolling noise.